diff --git a/osub/Client/Client.swift b/osub/Client/Client.swift index 4d6378b..9dacbd7 100644 --- a/osub/Client/Client.swift +++ b/osub/Client/Client.swift @@ -64,12 +64,31 @@ public final class Client: ClientProtocol { public func url(path: String, with queryItems: [URLQueryItem]) throws -> URL { var url = try url(path: path) + guard !queryItems.isEmpty else { return url.absoluteURL } + + let queryItems: [URLQueryItem] = + [ + // Crutch. Some items cannot be the first, so it is necessary to insert + // a dummy item at the beginning. + URLQueryItem(name: "&", value: nil) + ] + + queryItems.compactMap { queryItem in + guard let value = queryItem.value else { + return nil + } + return URLQueryItem( + name: queryItem.name, + value: value.replacingOccurrences(of: " ", with: "+") + ) + } + guard url.append2(queryItems: queryItems) else { throw ClientError.cannotCreateURL } + return url.absoluteURL } diff --git a/osub/Command/AuthenticationCommand.swift b/osub/Command/AuthenticationCommand.swift index e185bfe..1c50699 100644 --- a/osub/Command/AuthenticationCommand.swift +++ b/osub/Command/AuthenticationCommand.swift @@ -256,6 +256,15 @@ extension AuthenticationStatusCommand { .remainingDownloads ] } + + var text: String { + switch self { + case .remainingDownloads: + return "remaining_downloads" + case .userID: + return "user_id" + } + } } } diff --git a/osub/Command/Command.swift b/osub/Command/Command.swift index 03b33b2..9979826 100644 --- a/osub/Command/Command.swift +++ b/osub/Command/Command.swift @@ -32,3 +32,40 @@ extension ClientProtocol { ) } } + +indirect enum ValueName { + case array(ValueName) + case `enum` + case int + case path + case string + + var rawValue: String { + switch self { + case .array(let valueName): + return "[\(valueName.rawValue)]" + case .enum: + return "enum" + case .int: + return "int" + case .path: + return "path" + case .string: + return "string" + } + } +} + +extension ArgumentHelp { + init( + _ abstract: String = "", + discussion: String = "", + valueName: ValueName? = nil + ) { + self.init( + abstract, + discussion: discussion, + valueName: valueName?.rawValue + ) + } +} diff --git a/osub/Command/DownloadCommand.swift b/osub/Command/DownloadCommand.swift index 2c70cf1..f611966 100644 --- a/osub/Command/DownloadCommand.swift +++ b/osub/Command/DownloadCommand.swift @@ -15,7 +15,7 @@ struct DownloadCommand: AsyncParsableCommand { name: .shortAndLong, help: ArgumentHelp( "The file ID from subtitles search results.", - valueName: "int" + valueName: .int ) ) var fileID: Int diff --git a/osub/Command/Formatting.swift b/osub/Command/Formatting.swift index cac7a50..fc4fba9 100644 --- a/osub/Command/Formatting.swift +++ b/osub/Command/Formatting.swift @@ -3,6 +3,7 @@ import TablePrinter protocol FormattingField: RawRepresentable, CaseIterable, ExpressibleByArgument { static var defaultValues: [Self] { get } + var text: String { get } } struct FormattingOptions: ParsableArguments where Field: FormattingField { @@ -10,7 +11,7 @@ struct FormattingOptions: ParsableArguments where Field: FormattingField parsing: .upToNextOption, help: ArgumentHelp( "Space-separated list of fields to print.", - discussion: "The list of available fields: \(Field.allValueStrings.joined(separator: ", "))." + valueName: .array(.enum) ) ) var fields = Field.defaultValues @@ -18,10 +19,7 @@ struct FormattingOptions: ParsableArguments where Field: FormattingField func printer() -> TablePrinter { var printer = TablePrinter() fields.forEach { field in - let header = field - .rawValue - .replacingOccurrences(of: "_", with: " ") - .uppercased() + let header = field.text.uppercased() printer.append(header) } printer.next() diff --git a/osub/Command/LanguagesCommand.swift b/osub/Command/LanguagesCommand.swift index fc8bdbf..591254f 100644 --- a/osub/Command/LanguagesCommand.swift +++ b/osub/Command/LanguagesCommand.swift @@ -61,5 +61,9 @@ extension LanguagesCommand { .name ] } + + var text: String { + rawValue + } } } diff --git a/osub/Command/SearchCommand.swift b/osub/Command/SearchCommand.swift index d7275ee..62934d1 100644 --- a/osub/Command/SearchCommand.swift +++ b/osub/Command/SearchCommand.swift @@ -21,23 +21,11 @@ struct SearchSubtitlesCommand: AsyncParsableCommand { abstract: "Search for subtitles." ) - @Option( - name: .shortAndLong, - help: ArgumentHelp( - "The path to the file that needs subtitles.", - valueName: "path" - ) - ) - var file: String? + @OptionGroup(title: "Query Options") + var query: QueryOptions - @Option( - name: .shortAndLong, - help: ArgumentHelp( - "Comma-separated list of subtag languages for subtitles.", - valueName: "string" - ) - ) - var languages: String? + @OptionGroup(title: "Utility Options") + var utility: UtilityOptions @OptionGroup(title: "Formatting Options") var formatting: FormattingOptions @@ -46,6 +34,15 @@ struct SearchSubtitlesCommand: AsyncParsableCommand { var stateManager: StateManagerProtocol = StateManager.shared var client: ClientProtocol = Client.shared + func validate() throws { + if + query.moviehash != nil, + utility.file != nil + { + throw ValidationError("The movehash and file options cannot be used together.") + } + } + mutating func run() async throws { try configure() try await action() @@ -58,116 +55,480 @@ struct SearchSubtitlesCommand: AsyncParsableCommand { } mutating func action() async throws { - var hash: String? - if let file { - hash = try Hash.hash(of: file) - } + let languages = query.languages.isEmpty + ? nil + : query.languages.joined(separator: ",") + let moviehash: String? = try { + if let moviehash = query.moviehash { + return moviehash + } + if let file = utility.file { + return try Hash.hash(of: file) + } + return nil + }() + let subtitles = try await client.search.subtitles( - aiTranslated: nil, - episodeNumber: nil, - foreignPartsOnly: nil, - hearingImpaired: nil, - id: nil, - imdbID: nil, + aiTranslated: query.aiTranslated, + episodeNumber: query.episodeNumber, + foreignPartsOnly: query.foreignPartsOnly, + hearingImpaired: query.hearingImpaired, + id: query.id, + imdbID: query.imdbID, languages: languages, - machineTranslated: nil, - moviehashMatch: nil, - moviehash: hash, - orderBy: nil, - orderDirection: nil, - page: nil, - parentFeatureID: nil, - parentIMDBID: nil, - parentTMDBID: nil, - query: nil, - seasonNumber: nil, - tmdbID: nil, - trustedSources: nil, - type: nil, - userID: nil, - year: nil + machineTranslated: query.machineTranslated, + moviehashMatch: query.moviehashMatch, + moviehash: moviehash, + orderBy: query.orderBy, + orderDirection: query.orderDirection, + page: query.page, + parentFeatureID: query.parentFeatureID, + parentIMDBID: query.parentIMDBID, + parentTMDBID: query.parentTMDBID, + query: query.query, + seasonNumber: query.seasonNumber, + tmdbID: query.tmdbID, + trustedSources: query.trustedSources, + type: query.type, + userID: query.userID, + year: query.year ) var printer = formatting.printer() + + // swiftlint:disable:next cyclomatic_complexity + func resolve(entity: AttributedEntity, file: File? = nil) { + formatting.fields.forEach { field in + switch field { + case .aiTranslated: + printer.append(entity.attributes.aiTranslated) + case .downloadCount: + printer.append(entity.attributes.downloadCount) + case .fileID: + printer.append(file?.fileID) + case .fileName: + printer.append(file?.fileName) + case .foreignPartsOnly: + printer.append(entity.attributes.foreignPartsOnly) + case .fps: + printer.append(entity.attributes.fps) + case .fromTrusted: + printer.append(entity.attributes.fromTrusted) + case .hd: + printer.append(entity.attributes.hd) + case .hearingImpaired: + printer.append(entity.attributes.hearingImpaired) + case .id: + printer.append(entity.id) + case .language: + printer.append(entity.attributes.language) + case .machineTranslated: + printer.append(entity.attributes.machineTranslated) + case .ratings: + printer.append(entity.attributes.ratings) + case .release: + printer.append(entity.attributes.release) + case .uploadDate: + printer.append(entity.attributes.uploadDate) + case .votes: + printer.append(entity.attributes.votes) + } + } + } + subtitles.data.forEach { entity in if entity.attributes.files.isEmpty { - formatting.fields.forEach { field in - switch field { - case .downloads: - printer.append(entity.attributes.downloadCount) - case .fileID: - printer.append("?") - case .fileName: - printer.append("?") - case .language: - printer.append(entity.attributes.language) - case .release: - printer.append(entity.attributes.release) - case .subtitlesID: - printer.append(entity.id) - case .uploaded: - printer.append(entity.attributes.uploadDate) - } - } + resolve(entity: entity) printer.next() return } - entity.attributes.files.forEach { file in - formatting.fields.forEach { field in - switch field { - case .downloads: - printer.append(entity.attributes.downloadCount) - case .fileID: - printer.append(file.fileID) - case .fileName: - printer.append(file.fileName) - case .language: - printer.append(entity.attributes.language) - case .release: - printer.append(entity.attributes.release) - case .subtitlesID: - printer.append(entity.id) - case .uploaded: - printer.append(entity.attributes.uploadDate) - } - } + resolve(entity: entity, file: file) printer.next() } } + let total = subtitles.totalCount + // The documentation says 60, but in reality it's 50. + let perPage = 50 + let page = query.page ?? 1 + let pages = (Double(total) / Double(perPage)).rounded(.awayFromZero) + print() - print("Printing \(subtitles.data.count) of \(subtitles.totalCount) subtitles.") + print("Printing \(page) page of \(pages) for \(total) subtitles.") print() printer.print() } } extension SearchSubtitlesCommand { - enum CodingKeys: String, CodingKey { - case file - case languages + enum CodingKeys: CodingKey { + case query + case utility case formatting } - enum Field: String, FormattingField { - case downloads - case fileID = "file_id" - case fileName = "file_name" - case language - case release - case subtitlesID = "subtitles_id" - case uploaded + struct QueryOptions: ParsableArguments { + @Option( + help: ArgumentHelp( + "Restrict search to AI-translated subtitles.", + valueName: .enum + ) + ) + var aiTranslated: SearchSubtitlesAITranslated? + + @Option( + help: ArgumentHelp( + "Search by TV Show episode number.", + valueName: .int + ) + ) + var episodeNumber: Int? + + @Option( + help: ArgumentHelp( + "Restrict search to Foreign Parts Only (FPO) subtitles.", + valueName: .enum + ) + ) + var foreignPartsOnly: SearchSubtitlesForeignPartsOnly? + + @Option( + help: ArgumentHelp( + "Restrict search to subtitles for the hearing impaired.", + valueName: .enum + ) + ) + var hearingImpaired: SearchSubtitlesHearingImpaired? + + @Option( + help: ArgumentHelp( + "Search by feature ID from the features search results.", + valueName: .int + ) + ) + var id: Int? + + @Option( + help: ArgumentHelp( + "Search by feature IMDB ID.", + valueName: .int + ) + ) + var imdbID: Int? + + @Option( + parsing: .upToNextOption, + help: ArgumentHelp( + "Search on space-separated list of subtag languages.", + valueName: .array(.string) + ) + ) + var languages: [String] = [] + + @Option( + help: ArgumentHelp( + "Restrict search to machine-translated subtitles.", + valueName: .enum + ) + ) + var machineTranslated: SearchSubtitlesMachineTranslated? + + @Option( + help: ArgumentHelp( + "Restrict search to subtitles with feature hash match.", + valueName: .enum + ) + ) + var moviehashMatch: SearchSubtitlesMoviehashMatch? + + @Option( + help: ArgumentHelp( + "Search by feature hash.", + valueName: .string + ) + ) + var moviehash: String? + + @Option( + help: ArgumentHelp( + "Order of returned results by field.", + valueName: .enum + ) + ) + var orderBy: SearchSubtitlesOrderBy? + + @Option( + help: ArgumentHelp( + "Order of returned results by direction.", + valueName: .enum + ) + ) + var orderDirection: SearchSubtitlesOrderDirection? + + @Option( + help: ArgumentHelp( + "Search on the page.", + valueName: .int + ) + ) + var page: Int? + + @Option( + help: ArgumentHelp( + "Search for the TV Show by parent feature ID from the features search results.", + valueName: .int + ) + ) + var parentFeatureID: Int? + + @Option( + name: .customLong("parent-imdb-id"), + help: ArgumentHelp( + "Search for the TV Show by parent IMDB ID.", + valueName: .int + ) + ) + var parentIMDBID: Int? + + @Option( + name: .customLong("parent-tmdb-id"), + help: ArgumentHelp( + "Search for the TV Show by parent TMDB ID.", + valueName: .int + ) + ) + var parentTMDBID: Int? + @Option( + help: ArgumentHelp( + "Search by file name or string query.", + valueName: .string + ) + ) + var query: String? + + @Option( + help: ArgumentHelp( + "Search for the TV Show by season number.", + valueName: .int + ) + ) + var seasonNumber: Int? + + @Option( + help: ArgumentHelp( + "Search by feature TMDB ID.", + valueName: .int + ) + ) + var tmdbID: Int? + + @Option( + help: ArgumentHelp( + "Restrict search to trusted sources.", + valueName: .enum + ) + ) + var trustedSources: SearchSubtitlesTrustedSources? + + @Option( + help: ArgumentHelp( + "Restrict search to feature type.", + valueName: .enum + ) + ) + var type: SearchSubtitlesFeatureType? + + @Option( + help: ArgumentHelp( + "Search for uploaded subtitles by user ID.", + valueName: .int + ) + ) + var userID: Int? + + @Option( + help: ArgumentHelp( + "Search by year.", + valueName: .int + ) + ) + var year: Int? + } + + struct UtilityOptions: ParsableArguments { + @Option( + help: ArgumentHelp( + "The path to the file that needs subtitles.", + valueName: .path + ) + ) + var file: String? + } + + enum Field: String, FormattingField { static var defaultValues: [Self] { [ .fileID, .fileName, .language, - .uploaded, - .downloads, - .subtitlesID + .uploadDate, + .downloadCount, + .id ] } + + case aiTranslated = "ai_translated" + case downloadCount = "download_count" + case fileID = "file_id" + case fileName = "file_name" + case foreignPartsOnly = "foreign_parts_only" + case fps + case fromTrusted = "from_trusted" + case hd + case hearingImpaired = "hearing_impaired" + case id + case language + case machineTranslated = "machine_translated" + case ratings + case release + case uploadDate = "upload_date" + case votes + + var text: String { + switch self { + case .aiTranslated: + return "AI-translated" + case .downloadCount: + return "downloads" + case .fileID: + return "file id" + case .fileName: + return "file name" + case .foreignPartsOnly: + return "FPO" + case .fps: + return rawValue + case .fromTrusted: + return "trusted" + case .hd: + return rawValue + case .hearingImpaired: + return "hearing impaired" + case .id: + return "subtitles id" + case .language: + return rawValue + case .machineTranslated: + return "machine-translated" + case .ratings: + return rawValue + case .release: + return rawValue + case .uploadDate: + return "uploaded" + case .votes: + return rawValue + } + } + } +} + +// MARK: Extensions + +extension SearchSubtitlesAITranslated: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesAITranslated] { + [ + .exclude, + .include + ] + } +} + +extension SearchSubtitlesForeignPartsOnly: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesForeignPartsOnly] { + [ + .exclude, + .include, + .only + ] + } +} + +extension SearchSubtitlesHearingImpaired: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesHearingImpaired] { + [ + .exclude, + .include, + .only + ] + } +} + +extension SearchSubtitlesMachineTranslated: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesMachineTranslated] { + [ + .exclude, + .include + ] + } +} + +extension SearchSubtitlesMoviehashMatch: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesMoviehashMatch] { + [ + .include, + .only + ] + } +} + +extension SearchSubtitlesOrderBy: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesOrderBy] { + [ + .aiTranslated, + .downloadCount, + .foreignPartsOnly, + .fps, + .fromTrusted, + .hd, + .hearingImpaired, + .language, + .machineTranslated, + .points, + .ratings, + .release, + .uploadDate, + .votes + ] + } +} + +extension SearchSubtitlesOrderDirection: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesOrderDirection] { + [ + .asc, + .desc + ] + } +} + +extension SearchSubtitlesTrustedSources: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesTrustedSources] { + [ + .include, + .only + ] + } +} + +extension SearchSubtitlesFeatureType: CaseIterable, ExpressibleByArgument { + public static var allCases: [SearchSubtitlesFeatureType] { + [ + .episode, + .movie, + .tvshow + ] } }