From f42e227ca25d29309ffed4f25244b4399793b674 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Sun, 9 Aug 2026 15:48:13 -0700 Subject: [PATCH 1/2] Port the functionality of ParseFilename to native Swift --- .../MetadataImporters/MetadataHelper.swift | 57 ++- Classes/MetadataImporters/VideoFilename.swift | 379 ++++++++++++++++++ Subler.xcodeproj/project.pbxproj | 6 +- 3 files changed, 408 insertions(+), 34 deletions(-) create mode 100644 Classes/MetadataImporters/VideoFilename.swift diff --git a/Classes/MetadataImporters/MetadataHelper.swift b/Classes/MetadataImporters/MetadataHelper.swift index 7519eb90..80bfa22f 100644 --- a/Classes/MetadataImporters/MetadataHelper.swift +++ b/Classes/MetadataImporters/MetadataHelper.swift @@ -54,40 +54,33 @@ private func parseAnimeFilename(_ filename: String) -> MetadataSearchTerms? { return result } +// Formerly invoked the bundled ParseFilename perl script; now calls the +// native Swift port in VideoFilename.swift. Result handling matches the +// original perl-output parsing: a TV match is only returned when series +// name, season and episode were all found (the perl pipeline dropped empty +// output lines, so partial matches fell through to nil), and a movie match +// requires a non-empty title. private func parseFilename(_ filename: String) -> MetadataSearchTerms? { - guard let path = Bundle.main.path(forResource: "ParseFilename", ofType: "") else { return nil } + let file = ParsedVideoFilename.parse(filename) - let stdOut = Pipe() - let stdOutWrite = stdOut.fileHandleForWriting - - // Use the ParseFilename perl script - let task = Process() - task.launchPath = "/usr/bin/perl" - task.arguments = ["-I\(path)/lib", "\(path)/ParseFilename.pl", filename] - task.standardOutput = stdOutWrite - - task.launch() - task.waitUntilExit() - stdOutWrite.closeFile() - - let outputData = stdOut.fileHandleForReading.readDataToEndOfFile() - guard let outputString = String(data: outputData, encoding: .utf8) else { return nil } - let lines = outputString.split(separator: "\n") - - if lines.isEmpty == false { - if lines.first == "tv" && lines.count >= 4 { - let newSeriesName = lines[1].isEmpty == false ? lines[1].replacingOccurrences(of: ".", with: " ") : filename - return MetadataSearchTerms.tvShow(seriesName: newSeriesName, season: Int(lines[2]), episode: Int(lines[3])) - } - else if lines.first == "movie" && lines.count >= 2 { - let newTitle = lines[1].replacingOccurrences(of: ".", with: " ") - .replacingOccurrences(of: "(", with: " ") - .replacingOccurrences(of: ")", with: " ") - .replacingOccurrences(of: "[", with: " ") - .replacingOccurrences(of: "]", with: " ") - .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - return MetadataSearchTerms.movie(title: newTitle) - } + if let name = file.name, name.isEmpty == false, + let season = file.seasonInt, let episode = file.episodeInt { + let newSeriesName = name.replacingOccurrences(of: ".", with: " ") + return MetadataSearchTerms.tvShow(seriesName: newSeriesName, season: season, episode: episode) + } + else if file.isEpisode { + // Episode without a usable series name or season; like the original + // pipeline, do not guess. + return nil + } + else if let movie = file.movie, movie.isEmpty == false { + let newTitle = movie.replacingOccurrences(of: ".", with: " ") + .replacingOccurrences(of: "(", with: " ") + .replacingOccurrences(of: ")", with: " ") + .replacingOccurrences(of: "[", with: " ") + .replacingOccurrences(of: "]", with: " ") + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return MetadataSearchTerms.movie(title: newTitle) } return nil diff --git a/Classes/MetadataImporters/VideoFilename.swift b/Classes/MetadataImporters/VideoFilename.swift new file mode 100644 index 00000000..aeac1c29 --- /dev/null +++ b/Classes/MetadataImporters/VideoFilename.swift @@ -0,0 +1,379 @@ +// +// VideoFilename.swift +// Subler +// +// Native Swift port of the bundled ParseFilename perl script, +// i.e. of Video::Filename 0.35.1 by Behan Webster (with the 2010 +// movie-year modification by Douglas Stebila) and of the roman2int +// function from Text::Roman by Peter de Padua Krauss. Those modules are +// dual-licensed Artistic/GPL; this derived port inherits those terms. +// +// Only Foundation is used, so this file can also be compiled standalone +// for the test harness. +// + +import Foundation + +// MARK: - Regex helpers + +private func regex(_ pattern: String) -> NSRegularExpression { + return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) +} + +private func captures(of re: NSRegularExpression, in string: String) -> [String?]? { + let range = NSRange(string.startIndex..., in: string) + guard let match = re.firstMatch(in: string, options: [], range: range) else { return nil } + return (1.. String) -> String { + let range = NSRange(string.startIndex..., in: string) + var result = "" + var last = string.startIndex + for match in re.matches(in: string, options: [], range: range) { + guard let matchRange = Range(match.range, in: string) else { continue } + let groups: [String?] = (0.. Int? { + guard !input.isEmpty else { return nil } + let upper = input.uppercased() + // Mixed case (e.g. "Xv") is not a roman numeral. + guard upper == input || input.lowercased() == input else { return nil } + guard upper.range(of: "^[IXCMVLD]+$", options: .regularExpression) != nil else { return nil } + guard upper.range(of: "([IXCM])\\1{3,}|([VLD])\\2+", options: .regularExpression) == nil + else { return nil } + + // Substitute subtractive pairs with placeholder symbols, then reject + // smaller-order symbols appearing after a subtractive pair of that order. + var t = upper + for (pair, sub) in [("IV", "A"), ("IX", "B"), ("XL", "E"), + ("XC", "F"), ("CD", "G"), ("CM", "H")] { + t = t.replacingOccurrences(of: pair, with: sub) + } + guard t.range(of: "[AB].*?I|[EF].*?X|[GH].*?C", options: .regularExpression) == nil + else { return nil } + + let values: [Character: Int] = ["I": 1, "V": 5, "X": 10, "L": 50, "C": 100, + "D": 500, "M": 1000, + "A": 4, "B": 9, "E": 40, "F": 90, "G": 400, "H": 900] + var sum = 0 + var previous = 0 + for ch in t.reversed() { + guard let value = values[ch] else { return nil } + if value < previous { return nil } + sum += value + previous = value + } + return sum +} + +// MARK: - English number words (Video::Filename _num2int/_allnum2int) + +/// Converts an English number phrase ("twenty five", "one hundred and two") +/// to an integer, replicating _num2int's parsing order exactly. +func englishNumberToInt(_ input: String) -> Int { + var str = input.lowercased()[...] + var n = 0, c = 0, sum = 0 + + // (prefix, action); order matters and mirrors the perl cascade. + let steps: [(String, () -> Void)] = [ + ("zero", {}), ("and", {}), ("&", {}), + ("one", { n += 1 }), + ("two", { n += 2 }), ("twen", { n += 2 }), + ("three", { n += 3 }), ("thir", { n += 3 }), + ("four", { n += 4 }), + ("five", { n += 5 }), ("fif", { n += 5 }), + ("six", { n += 6 }), + ("seven", { n += 7 }), + ("eight", { n += 8 }), + ("nine", { n += 9 }), + ("ten", { n += 10 }), ("teen", { n += 10 }), ("een", { n += 10 }), + ("eleven", { n += 11 }), + ("twelve", { n += 12 }), + ("ty", { n *= 10 }), ("y", { n *= 10 }), + ("hundred", { c += n * 100; n = 0 }), + ("thousand", { sum += (c + n) * 1000; c = 0; n = 0 }), + ("million", { sum += (c + n) * 1_000_000; c = 0; n = 0 }), + ("billion", { sum += (c + n) * 1_000_000_000; c = 0; n = 0 }), + ("trillion", { sum += (c + n) * 1_000_000_000_000; c = 0; n = 0 }), + ] + + outer: while !str.isEmpty { + while let first = str.first, first == " " || first == "," || first.isWhitespace { + str = str.dropFirst() + } + if str.isEmpty { break } + for (prefix, action) in steps { + if str.hasPrefix(prefix) { + str = str.dropFirst(prefix.count) + action() + continue outer + } + } + break // unlike perl, don't spin forever on unparseable input + } + return sum + c + n +} + +private let numberWordsPattern: String = { + let single = "zero|one|two|three|five|(?:twen|thir|four|fif|six|seven|nine)(?:teen|ty)?" + + "|eight(?:een|y)?|ten|eleven|twelve" + let mult = "hundred|thousand|(?:m|b|tr)illion" + let word = "(?:\(single)|\(mult))" + let wordOrJoiner = "(?:\(single)|\(mult)|\\s|,|and|&)" + return "((?:\(word)\(wordOrJoiner)+)?\(word))" +}() + +// The keyword contexts in which numbers are translated. +private let numberPrefix = "(?:d|dvd|disc|disk|s|se|season|e|ep|episode)[\\s._-]+" +private let numberEnd = "(?:day|part)[\\s._-]+" + +private let romanPattern = "[MC]*[DC]*[CX]*[LX]*[XI]*[VI]*" + +private let romanAfterPrefixRE = regex("\\b(\(numberPrefix))(\(romanPattern))\\b") +private let romanAtEndRE = regex("\\b(\(numberEnd))(\(romanPattern))$") +private let wordsAfterPrefixRE = regex("(\(numberPrefix))\\b\(numberWordsPattern)\\b") +private let wordsAtEndRE = regex("(\(numberEnd))\\b\(numberWordsPattern)$") +private let wordsAnywhereRE = regex("\\b\(numberWordsPattern)\\b") + +/// Translates roman numerals appearing after season/episode/disc keywords. +private func allRomanToInt(_ string: String) -> String { + var result = replacingMatches(of: romanAfterPrefixRE, in: string) { groups in + let prefix = groups[1] ?? "" + let numeral = groups[2] ?? "" + if let value = roman2int(numeral) { return prefix + String(value) } + return groups[0] ?? "" + } + result = replacingMatches(of: romanAtEndRE, in: result) { groups in + let prefix = groups[1] ?? "" + let numeral = groups[2] ?? "" + if let value = roman2int(numeral) { return prefix + String(value) } + return groups[0] ?? "" + } + return result +} + +/// Translates English number words after season/episode/disc keywords +/// (or anywhere, if contextFree is true). +private func allNumberWordsToInt(_ string: String, contextFree: Bool = false) -> String { + if contextFree { + return replacingMatches(of: wordsAnywhereRE, in: string) { groups in + String(englishNumberToInt(groups[1] ?? groups[0] ?? "")) + } + } + var result = replacingMatches(of: wordsAfterPrefixRE, in: string) { groups in + (groups[1] ?? "") + String(englishNumberToInt(groups[2] ?? "")) + } + result = replacingMatches(of: wordsAtEndRE, in: result) { groups in + (groups[1] ?? "") + String(englishNumberToInt(groups[2] ?? "")) + } + return result +} + +// MARK: - Video::Filename + +/// The parse result. String fields are nil if never matched; numeric +/// accessors convert on demand (nil for empty or non-numeric values). +struct ParsedVideoFilename { + var name: String? + var dvd: String? + var season: String? + var episode: String? + var endep: String? + var subep: String? + var part: String? + var epname: String? + var movie: String? + var year: String? + var imdb: String? + var title: String? + var ext: String? + + var seasonInt: Int? { season.flatMap { Int($0) } } + var episodeInt: Int? { episode.flatMap { Int($0) } } + var dvdInt: Int? { dvd.flatMap { Int($0) } } + var partInt: Int? { part.flatMap { Int($0) } } + + var isTVShow: Bool { season != nil && episode != nil } + var isEpisode: Bool { episode != nil } + var isMovie: Bool { movie != nil || imdb != nil } +} + +/// One filename pattern: an ICU-compatible regex plus the field each capture +/// group maps to (nil for groups whose value is not stored). Groups mapping +/// to an already-set field are ignored (first match wins), like perl's +/// "unless defined" assignment. +private struct FilePattern { + let re: NSRegularExpression + let keys: [WritableKeyPath?] +} + +// The 9 patterns of Video::Filename, in priority order. Where the perl +// 5.10+ originals use conditional groups -- (?()...) -- or named +// backreferences, they are rewritten as bracketed/unbracketed alternations +// with duplicate capture groups mapped to the same field. +private let filePatterns: [FilePattern] = [ + // DVD Episode Support - DddEee + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]+)?(?:d|dvd|disc|disk)[\\s._]?(\\d{1,2})" + + "[x\\/\\s._-]*(?:e|ep|episode)[\\s._]?(\\d{1,2}(?:\\.\\d{1,2})?)" + + "(?:-?(?:(?:e|ep)[\\s._]*)?(\\d{1,2}))?" + + "(?:[\\s._]?(?:p|part)[\\s._]?(\\d+))?([a-z])?" + + "(?:[\\/\\s._-]*([^\\/]+?))?$"), + keys: [\.name, \.dvd, \.episode, \.endep, \.part, \.subep, \.epname]), + + // TV Show Support - SssEee or Season_ss_Episode_ss + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]+)?(?:s|se|season|series)[\\s._-]?(\\d+)" + + "[x\\/\\s._-]*(?:e|ep|episode|[\\/\\s._-]+)[\\s._-]?(\\d+)" + + "(?:-?(?:(?:e|ep)[\\s._]*)?(\\d+))?" + + "(?:[\\s._]?(?:p|part)[\\s._]?(\\d+))?([a-z])?" + + "(?:[\\/\\s._-]*([^\\/]+?))?$"), + keys: [\.name, \.season, \.episode, \.endep, \.part, \.subep, \.epname]), + + // Movie IMDB Support + FilePattern( + re: regex("^(.*?)?(?:[\\/\\s._-]*(?:\\[((?:19|20)\\d{2})\\]|((?:19|20)\\d{2})))?" + + "(?:[\\/\\s._-]*(?:\\[(?:(?:imdb|tt)[\\s._-]*)*(\\d{7})\\]" + + "|(?:(?:imdb|tt)[\\s._-]*)*(\\d{7})))" + + "(?:[\\s._-]*([^\\/]+?))?$"), + keys: [\.movie, \.year, \.year, \.imdb, \.imdb, \.title]), + + // Movie + Year Support + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]*)?(?:\\[\\(?((?:19|20)\\d{2})\\)?\\]" + + "|((?:19|20)\\d{2}))(?:[\\s._-]*([^\\/]+?))?$"), + keys: [\.movie, \.year, \.year, \.title]), + + // TV Show Support - see + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]*)?(\\d{1,2}?)(\\d{2})" + + "(?:[^0-9][\\s._-]*(.+?))?$"), + keys: [\.name, \.season, \.episode, \.epname]), + + // TV Show Support - sxee + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]*)?" + + "(?:\\[(\\d{1,2})[x\\/](\\d{1,2})(?:-(?:\\d{1,2}x)?(\\d{1,2}))?\\]" + + "|(\\d{1,2})[x\\/](\\d{1,2})(?:-(?:\\d{1,2}x)?(\\d{1,2}))?)" + + "(?:[\\s._-]*([^\\/]+?))?$"), + keys: [\.name, \.season, \.episode, \.endep, \.season, \.episode, \.endep, \.epname]), + + // TV Show Support - season only + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]+)?(?:s|se|season|series)[\\s._]?(\\d{1,2})" + + "(?:[\\/\\s._-]*([^\\/]+?))?$"), + keys: [\.name, \.season, \.epname]), + + // TV Show Support - episode only + FilePattern( + re: regex("^(?:(.*?)[\\/\\s._-]*)?(?:(?:e|ep|episode)[\\s._]?)?(\\d{1,2})" + + "(?:-(?:e|ep)?(\\d{1,2}))?(?:(?:p|part)(\\d+))?([a-z])?" + + "(?:[\\/\\s._-]*([^\\/]+?))?$"), + keys: [\.name, \.episode, \.endep, \.part, \.subep, \.epname]), + + // Default Movie Support + FilePattern(re: regex("^(.*)$"), keys: [\.movie]), +] + +extension ParsedVideoFilename { + + static func parse(_ path: String) -> ParsedVideoFilename { + var result = ParsedVideoFilename() + var file = path + + // Strip the extension. + if let extMatch = captures(of: extRE, in: file), let ext = extMatch.first ?? nil { + result.ext = ext.lowercased() + file = String(file.dropLast(ext.count + 1)) + } + + // Translate appropriate roman/english numbers to numerals. + file = allRomanToInt(file) + file = allNumberWordsToInt(file) + + // Strip out any irrelevant numbers which screw up parsing. + // (Like perl's s/// without /g: first occurrence only, case-sensitive.) + for noise in ["480p", "720p", "1080p", "x264", "x265"] { + if let range = file.range(of: noise) { + file.removeSubrange(range) + } + } + + // Run the pre-processed filename through the list of patterns; + // first match wins. + for pattern in filePatterns { + guard let groups = captures(of: pattern.re, in: file) else { continue } + for (index, keyPath) in pattern.keys.enumerated() { + guard let keyPath, index < groups.count, let value = groups[index] else { continue } + if result[keyPath: keyPath] == nil { + result[keyPath: keyPath] = value + } + } + break + } + + // Process Series/Movie: strip directory parts, trim whitespace. + for keyPath in [\ParsedVideoFilename.name, \.movie, \.epname, \.title] { + guard var value = result[keyPath: keyPath] else { continue } + if let slash = value.range(of: "/", options: .backwards) { + value = String(value[slash.upperBound...]) + } + value = value.trimmingCharacters(in: .whitespaces) + result[keyPath: keyPath] = value + } + + // Guess part from epname. + if let epname = result.epname, result.part == nil { + let converted = allNumberWordsToInt(epname, contextFree: true) + for pattern in ["(?:Episode|Part|PT) (\\d+)", + "(\\d+)\\s*(?:of|-)\\s*\\d+", + "^(\\d+)", + "[\\s._-](\\d+)$"] { + if let groups = captures(of: regex(pattern), in: converted), + let value = groups.first ?? nil { + result.part = value + break + } + } + } + + // Cosmetics: strip leading zeros. + for keyPath in [\ParsedVideoFilename.dvd, \.season, \.episode, \.endep, \.part] { + if let value = result[keyPath: keyPath] { + result[keyPath: keyPath] = value.replacingOccurrences( + of: "^0+", with: "", options: .regularExpression) + } + } + if let endep = result.endep, endep == result.episode { + result.endep = nil + } + + return result + } +} + +private let extRE = regex("\\.([0-9a-z]+)$") diff --git a/Subler.xcodeproj/project.pbxproj b/Subler.xcodeproj/project.pbxproj index 7388251c..e63f6a64 100644 --- a/Subler.xcodeproj/project.pbxproj +++ b/Subler.xcodeproj/project.pbxproj @@ -50,7 +50,6 @@ A9433A8D1F2F5C3600BB38CA /* ChapterResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9433A8C1F2F5C3600BB38CA /* ChapterResult.swift */; }; A9433A911F2F5D8A00BB38CA /* ChapterDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9433A901F2F5D8A00BB38CA /* ChapterDB.swift */; }; A944E5921F498FC4002E43F6 /* PresetPrefsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A944E5911F498FC4002E43F6 /* PresetPrefsViewController.swift */; }; - A94A6A591ED1C5CC00049F7D /* ParseFilename in Resources */ = {isa = PBXBuildFile; fileRef = A94A6A581ED1C5CA00049F7D /* ParseFilename */; }; A956C1E4202F1B170050C5E2 /* Document.swift in Sources */ = {isa = PBXBuildFile; fileRef = A956C1E3202F1B170050C5E2 /* Document.swift */; }; A956CE651F99E5BB00093992 /* ActivityWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A956CE641F99E5BB00093992 /* ActivityWindowController.swift */; }; A956CE691F99E93D00093992 /* ButtonToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A956CE681F99E93D00093992 /* ButtonToolbarItem.swift */; }; @@ -100,6 +99,7 @@ A9ACF03222A2BCF200AD5B12 /* OCRPrefsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = A9ACF03422A2BCF200AD5B12 /* OCRPrefsViewController.xib */; }; A9AE26C01F3B66470041A57D /* SquaredTVArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AE26BD1F3B66470041A57D /* SquaredTVArt.swift */; }; A9AE70E11F2F7B3D001CF27C /* MetadataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AE70E01F2F7B3D001CF27C /* MetadataHelper.swift */; }; + A9AE70F11F2F7B3D001CF27C /* VideoFilename.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AE70F01F2F7B3D001CF27C /* VideoFilename.swift */; }; A9AF2D1D206116680088CE91 /* SectionsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AF2D1C206116680088CE91 /* SectionsTableView.swift */; }; A9B1056E2025D9B9009E91FC /* TracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B1056C2025D9B9009E91FC /* TracksViewController.swift */; }; A9B514191EFC12310055A035 /* TheTVDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B514181EFC12310055A035 /* TheTVDB.swift */; }; @@ -365,6 +365,7 @@ A9ACF03322A2BCF200AD5B12 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/OCRPrefsViewController.xib; sourceTree = ""; }; A9AE26BD1F3B66470041A57D /* SquaredTVArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SquaredTVArt.swift; path = Classes/MetadataImporters/SquaredTVArt.swift; sourceTree = ""; }; A9AE70E01F2F7B3D001CF27C /* MetadataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataHelper.swift; path = Classes/MetadataImporters/MetadataHelper.swift; sourceTree = ""; }; + A9AE70F01F2F7B3D001CF27C /* VideoFilename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoFilename.swift; path = Classes/MetadataImporters/VideoFilename.swift; sourceTree = ""; }; A9AF2D1C206116680088CE91 /* SectionsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SectionsTableView.swift; path = Classes/SectionsTableView.swift; sourceTree = ""; }; A9B1056C2025D9B9009E91FC /* TracksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TracksViewController.swift; path = Classes/TracksViewController.swift; sourceTree = ""; }; A9B4EE4E1643EBF3009AF6BC /* Subler.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Subler.entitlements; sourceTree = ""; }; @@ -714,6 +715,7 @@ children = ( A9CBAB1D1F29C083001C26B3 /* MetadataImporter.swift */, A9AE70E01F2F7B3D001CF27C /* MetadataHelper.swift */, + A9AE70F01F2F7B3D001CF27C /* VideoFilename.swift */, A90BC5E21F3A139700F9F3F3 /* MetadataResult.swift */, A900CE3D1F3ACF9700CCA08E /* MetadataResultMap.swift */, A97FA41D23E9837000EE5833 /* Ratings.swift */, @@ -877,7 +879,6 @@ A92BBABE1F5584EA007EA182 /* PresetEditorViewController.xib in Resources */, A90CD5C51FD177280070A067 /* OutputPrefsViewController.xib in Resources */, A9A4A966202F51FA00482D12 /* SaveOptions.xib in Resources */, - A94A6A591ED1C5CC00049F7D /* ParseFilename in Resources */, A9ECA05D2029A11500D5F30C /* DetailsViewController.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -974,6 +975,7 @@ A9E385782C316FBE000A050D /* CollectionView.swift in Sources */, A9C09C9C1F9A08A9007939EE /* MultiSelectViewController.swift in Sources */, A9AE70E11F2F7B3D001CF27C /* MetadataHelper.swift in Sources */, + A9AE70F11F2F7B3D001CF27C /* VideoFilename.swift in Sources */, A90057B61F99DE2700FA4ED1 /* ComboBoxCellView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 59dcbff76493e6fcd3abceff5798294962b351f0 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Sun, 9 Aug 2026 17:10:15 -0700 Subject: [PATCH 2/2] Tests for Swift file name parser The tests compare the output of the Swift parser to what the Perl parser used to generate. --- Tests/Makefile | 23 ++ Tests/VideoFilenameTests.swift | 98 +++++++ Tests/parse-corpus.txt | 102 +++++++ Tests/parse-expected.txt | 473 +++++++++++++++++++++++++++++++++ 4 files changed, 696 insertions(+) create mode 100644 Tests/Makefile create mode 100644 Tests/VideoFilenameTests.swift create mode 100644 Tests/parse-corpus.txt create mode 100644 Tests/parse-expected.txt diff --git a/Tests/Makefile b/Tests/Makefile new file mode 100644 index 00000000..fa45602a --- /dev/null +++ b/Tests/Makefile @@ -0,0 +1,23 @@ +# VideoFilename parser tests. The Subler app itself is built with Xcode / +# xcodebuild; run these from this directory with "make test". + +SWIFTC ?= swiftc +BUILD_DIR = build +TEST_BIN = $(BUILD_DIR)/parse-tests +VF = ../Classes/MetadataImporters/VideoFilename.swift + +# The Swift port of ParseFilename is compared against reference output +# (parse-expected.txt) generated by running the original perl script over +# parse-corpus.txt. +test: $(TEST_BIN) + $(TEST_BIN) --corpus parse-corpus.txt parse-expected.txt + +$(TEST_BIN): $(VF) VideoFilenameTests.swift + mkdir -p $(BUILD_DIR) + $(SWIFTC) -O -parse-as-library -o $(TEST_BIN) \ + $(VF) VideoFilenameTests.swift + +clean: + rm -rf $(BUILD_DIR) + +.PHONY: test clean diff --git a/Tests/VideoFilenameTests.swift b/Tests/VideoFilenameTests.swift new file mode 100644 index 00000000..cb188790 --- /dev/null +++ b/Tests/VideoFilenameTests.swift @@ -0,0 +1,98 @@ +// +// VideoFilenameTests.swift +// Subler +// +// Test driver for the VideoFilename Swift port. Two modes: +// +// parse-tests --parse +// Parses a single video name string (the string itself — not contents +// of a file) and prints the result in the exact output format of the +// original ParseFilename.pl, so the port can be diffed against perl. +// +// parse-tests --corpus +// Parses every name in corpus.txt (one per line) and compares the +// combined output (blocks separated by "---" lines) against +// expected.txt, which was generated by running the perl script over +// the same corpus. Exits non-zero on any mismatch. +// + +import Foundation + +/// Replicates ParseFilename.pl's output for a parsed filename +/// (perl's undef prints as an empty line). +func perlStyleOutput(_ file: ParsedVideoFilename) -> String { + var lines: [String] = [] + if file.isTVShow { + lines = ["tv", file.name ?? "", file.season ?? "", file.episode ?? ""] + } else if file.isEpisode { + lines = ["tv", file.name ?? "", file.season ?? "", file.episode ?? "", file.part ?? ""] + } else if file.isMovie { + lines = ["movie", file.movie ?? ""] + } else { + lines = ["unknown"] + } + return lines.joined(separator: "\n") + "\n" +} + +@main +struct ParseTests { + static func main() { + run() + } +} + +private func run() { + let arguments = CommandLine.arguments + + if arguments.count >= 3 && arguments[1] == "--parse" { + print(perlStyleOutput(ParsedVideoFilename.parse(arguments[2])), terminator: "") + exit(0) + } + + if arguments.count >= 4 && arguments[1] == "--corpus" { + guard let corpus = try? String(contentsOfFile: arguments[2], encoding: .utf8), + let expected = try? String(contentsOfFile: arguments[3], encoding: .utf8) else { + FileHandle.standardError.write("error: cannot read corpus or expected file\n".data(using: .utf8)!) + exit(2) + } + + let names = corpus.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + let expectedBlocks = expected.components(separatedBy: "---\n").filter { !$0.isEmpty } + + guard names.count == expectedBlocks.count else { + FileHandle.standardError.write( + "error: corpus has \(names.count) entries but expected file has \(expectedBlocks.count) blocks\n" + .data(using: .utf8)!) + exit(2) + } + + var failures = 0 + for (name, expectedBlock) in zip(names, expectedBlocks) { + let actual = perlStyleOutput(ParsedVideoFilename.parse(name)) + if actual != expectedBlock { + failures += 1 + print("FAIL: \(name)") + print(" expected: \(expectedBlock.replacingOccurrences(of: "\n", with: "\\n"))") + print(" actual: \(actual.replacingOccurrences(of: "\n", with: "\\n"))") + } + } + + if failures > 0 { + print("\(failures) of \(names.count) filenames FAILED") + exit(1) + } else { + print("all \(names.count) filenames OK") + exit(0) + } + } + + FileHandle.standardError.write(""" + usage: parse-tests --parse + parse-tests --corpus + + --parse parse a video name string (not a file path to read) + --corpus compare parses of every name in corpus.txt to expected.txt + + """.data(using: .utf8)!) + exit(2) +} diff --git a/Tests/parse-corpus.txt b/Tests/parse-corpus.txt new file mode 100644 index 00000000..625de1fc --- /dev/null +++ b/Tests/parse-corpus.txt @@ -0,0 +1,102 @@ +D01E02.Episode_name.avi +Series Name.D01E02.Episode_name.avi +Series Name/D01E02.Episode_name.avi +Series Name/D01E02/Episode_name.avi +Series Name.D01E02a.Episode_name.avi +Series Name.D01E02p4.Episode_name.avi +Series Name.D01E02-03.Episode_name.avi +Series Name.D01E02-E03.Episode_name.avi +Series Name.D01E02E.03.Episode_name.avi +Series Name/D01E02E03/Episode_name.avi +D01E02E03/Episode name.avi +Series Name.DVD_01.Episode_02.Episode_name.avi +Series Name.disk_V.Episode_XI.Episode_name.avi +Series Name.disc_V.Episode_XI.Part.XXV.Episode_name.avi +Series Name.DVD01.Ep02.Episode_name.avi +Series Name/dvd_01.Episode_02.Episode_name.avi +Series Name/disk_01/Episode_02.Episode_name.avi +Series Name/D.I/Ep02.Episode_name.avi +Series Name/D three/Ep five Episode_name.avi +S01E02.Episode_name.avi +Series Name.S01E02.Episode_name.avi +Series Name/S01E02.Episode_name.avi +Series Name/S01E02/Episode_name.avi +Series/Name/S01E02/Episode_name.avi +Series/Name/S01940E0237/Episode_name.avi +Series Name.S01E02a.Episode_name.avi +Series Name.S01E02p4.Episode_name.avi +Series Name.S01E02-03.Episode_name.avi +Series Name.S01E02-E03.Episode_name.avi +Series Name.S01E02E.03.Episode_name.avi +Series Name/S01E02E03/Episode_name.avi +S01E02E03/Episode name.avi +Series Name.Season_01.Episode_02.Episode_name.avi +Series Name.Season_V.Episode_XI.Episode_name.avi +Series Name.Season_V.Episode_XI.Part.XXV.Episode_name.avi +Series Name.Se01.Ep02.Episode_name.avi +Series Name/Season_01.Episode_02.Episode_name.avi +Series Name/Season_01/Episode_02.Episode_name.avi +Series Name/Season_01/02.Episode_name.avi +Series Name/S.I/Ep02.Episode_name.avi +Series Name/S.one/Ep twelve.Episode_name.avi +Movie Name [1996] [imdb 1234567].mkv +Movie Name [1996] [imdb tt1234567].mkv +Movie Name [1996] [1234567].avi +Movie Name [1996] [tt1234567] foo.avi +Movie Name [1996]/tt1234567-foo.avi +Movie/Name/tt1234567_foo.avi +Movie Name.[tt0096657] bar.avi +Movie Name.tt0096657-foo.avi +Movie.Name.tt0096657foo.avi +Movie Name.tt0096657_foo.avi +Movie Name.tt0096657.avi +Movie Name.[0096657].avi +imdb-tt0096657.avi +tt0096657.mov +tt0096857 +1234576 +Movie (1988).avi +Movie.[1988].avi +Movie.2000.title.avi +Movie/2009.title.avi +SN102.Episode_name.avi +Series Name.102.Episode_name.avi +Series Name/102.Episode_name.avi +Series Name.1x02.Episode_name.avi +Series Name/1x02.Episode_name.avi +Series Name.[1x02].Episode_name.avi +Series Name.1x02-03.Episode_name.avi +Series Name.1x02-1x03.Episode_name.avi +Series Name.s1.Episode_name.avi +Series Name.s01.Episode_name.avi +Series Name/se01.Episode_name.avi +Series Name.season_1.Episode_name.avi +Series Name/season_1/Episode_name.avi +Series Name/season ten/Episode_name.avi +Series Name.Episode_02.Episode_name.avi +Series Name/Episode_02.Episode_name.avi +Series Name/Ep02.Episode_name.avi +E02.Episode_name.avi +Series Name.E02.Episode_name.avi +Series Name.02.Episode_name.avi +Series Name/E02.Episode_name.avi +Series Name/02.Episode_name.avi +Series Name/E02/Episode_name.avi +Series Name/02/Episode_name.avi +Series Name.E02a.Episode_name.avi +Series Name.E02p3.Episode_name.avi +Series Name.E02-03.Episode_name.avi +Series Name.E02-E03.Episode_name.avi +Movie.mov +Pluribus-S01E04.mp4 +Seinfeld S03E05.m4v +Seinfeld.S03E05.The.Library.720p.WEB-DL.mkv +The Matrix (1999).mp4 +The.Matrix.1999.1080p.x264.mkv +Some.Show.S01E02.720p.HDTV.x264-GROUP.mkv +Movie.Name.2023.2160p.WEB-DL.mkv +Breaking Bad 1x02.mkv +Breaking.Bad.1x02.Cat's.in.the.Bag.mkv +Show Name - S05E12 - Episode Title.m4v +My Movie.mp4 +randomfile.mp4 diff --git a/Tests/parse-expected.txt b/Tests/parse-expected.txt new file mode 100644 index 00000000..3b010a05 --- /dev/null +++ b/Tests/parse-expected.txt @@ -0,0 +1,473 @@ +tv + + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 +4 +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv + + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +11 + +--- +tv +Series Name + +11 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +5 + +--- +tv + +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Name +1 +2 +--- +tv +Name +1940 +237 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv + +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +5 +11 +--- +tv +Series Name +5 +11 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +12 +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Movie.Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie +Movie Name +--- +movie + +--- +movie + +--- +movie + +--- +movie + +--- +movie +Movie ( +--- +movie +Movie +--- +movie +Movie +--- +movie +Movie +--- +tv +SN +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +tv +Series Name +1 +2 +--- +unknown +--- +unknown +--- +unknown +--- +unknown +--- +unknown +--- +unknown +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv + + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Nam + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 +3 +--- +tv +Series Name + +2 + +--- +tv +Series Name + +2 + +--- +movie +Movie +--- +tv +Pluribus +1 +4 +--- +tv +Seinfeld +3 +5 +--- +tv +Seinfeld +3 +5 +--- +movie +The Matrix ( +--- +movie +The.Matrix +--- +tv +Some.Show +1 +2 +--- +movie +Movie.Name +--- +tv +Breaking Bad +1 +2 +--- +tv +Breaking.Bad +1 +2 +--- +tv +Show Name +5 +12 +--- +movie +My Movie +--- +movie +randomfile +---