diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d487ef..4bf3db83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Open the exact movie on Letterboxd instead of a search - Open and search movies and series from Shortcuts and Siri on iOS 27 - Ask Siri about the movie or series on screen +- Add movies and series from Shortcuts and Siri +- Start automatic searches from Shortcuts +- Get upcoming releases from Shortcuts ## 2.1.2 - 2026-11-02 ### Fixed diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 2b8f6cde..6bf84b4a 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ Services/NetworkSnapshot.swift, Services/ResolverRouting.swift, Services/RouteTable.swift, + Utilities/AddDefaults.swift, Utilities/Changelog.swift, Utilities/Decoders.swift, Utilities/Extensions.swift, diff --git a/Ruddarr/AppShortcuts.xcstrings b/Ruddarr/AppShortcuts.xcstrings index 09f4e79a..e11fc80b 100644 --- a/Ruddarr/AppShortcuts.xcstrings +++ b/Ruddarr/AppShortcuts.xcstrings @@ -1,6 +1,33 @@ { "sourceLanguage" : "en", "strings" : { + "Add movie to ${applicationName}" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "Add movie to ${applicationName}" + ] + } + } + } + }, + "Add series to ${applicationName}" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringSet" : { + "state" : "new", + "values" : [ + "Add series to ${applicationName}", + "Add TV series to ${applicationName}" + ] + } + } + } + }, "Open ${applicationName}" : { "extractionState" : "extracted_with_value", "localizations" : { @@ -25,8 +52,7 @@ "stringSet" : { "state" : "new", "values" : [ - "Search for movie in ${applicationName}", - "Add movie to ${applicationName}" + "Search for movie in ${applicationName}" ] } } @@ -40,9 +66,7 @@ "state" : "new", "values" : [ "Search for series in ${applicationName}", - "Search for TV series in ${applicationName}", - "Add series to ${applicationName}", - "Add TV series to ${applicationName}" + "Search for TV series in ${applicationName}" ] } } diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index dc21bf66..65ff01a3 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -57,6 +57,28 @@ } } } + }, + "%@ is already in your library." : { + + }, + "%@ is already in your library. Started searching for releases." : { + + }, + "%@ rejected the API key." : { + + }, + "%@ responded with an error (%lld)." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ responded with an error (%2$lld)." + } + } + } + }, + "%@ returned an unexpected response." : { + }, "%@ years" : { "localizations" : { @@ -405,6 +427,12 @@ } } } + }, + "Add ${query} to Radarr" : { + + }, + "Add ${query} to Sonarr" : { + }, "Add Authentication" : { "comment" : "Add Basic HTTP Authentication to instance", @@ -478,6 +506,18 @@ } } } + }, + "Added %@ and started searching for releases." : { + + }, + "Added %@." : { + + }, + "Adds a movie to Radarr by title or IMDb link, using the last settings used in the app. Asks which one when there are several matches." : { + + }, + "Adds a series to Sonarr by title or IMDb link, using the last settings used in the app. Asks which one when there are several matches." : { + }, "Age" : { "comment" : "Release filter", @@ -881,6 +921,9 @@ } } } + }, + "Automatic Movie Search" : { + }, "Automatic Search" : { "localizations" : { @@ -891,6 +934,9 @@ } } } + }, + "Automatic Series Search" : { + }, "Availability" : { "comment" : "Very short version of Minimum Availability", @@ -1254,6 +1300,9 @@ } } } + }, + "Couldn't reach %@." : { + }, "Custom Format" : { "localizations" : { @@ -1329,6 +1378,9 @@ } } } + }, + "Days" : { + }, "Default" : { "comment" : "Default app icon name", @@ -2089,6 +2141,12 @@ } } } + }, + "Get releases for the next ${days} days" : { + + }, + "Get Upcoming Releases" : { + }, "Grab Release" : { "localizations" : { @@ -2334,6 +2392,9 @@ } } } + }, + "In Library" : { + }, "In Queue" : { "comment" : "(Short) State of task in queue", @@ -2418,6 +2479,9 @@ } } } + }, + "Instance not found." : { + }, "Instance URL is not valid: %@" : { "localizations" : { @@ -2647,6 +2711,9 @@ } } } + }, + "Lists the movies and episodes releasing in the coming days." : { + }, "Load More" : { "localizations" : { @@ -3079,6 +3146,9 @@ } } } + }, + "Movie title, IMDb link, `imdb:` or `tmdb:` identifier." : { + }, "Movie Upgraded" : { "localizations" : { @@ -3192,6 +3262,9 @@ } } } + }, + "No %@ instance configured." : { + }, "No Automatic Search" : { "localizations" : { @@ -3262,6 +3335,9 @@ } } } + }, + "No movie found matching “%@”." : { + }, "No Movies Match" : { "localizations" : { @@ -3352,6 +3428,9 @@ } } } + }, + "No series found matching “%@”." : { + }, "No Series Match" : { "extractionState" : "manual", @@ -3416,6 +3495,9 @@ } } } + }, + "Nothing is releasing in the next %lld days." : { + }, "Notification settings for each instance are shared between devices. To disable notifications for a specific device go to %@." : { "localizations" : { @@ -4583,6 +4665,9 @@ } } } + }, + "Requests to %@ failed." : { + }, "Reset" : { "comment" : "(Preferences) Reset release filters", @@ -4722,16 +4807,6 @@ } } }, - "Search for Movie" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search for Movie" - } - } - } - }, "Search for movie with ${name}" : { "localizations" : { "en" : { @@ -4741,6 +4816,9 @@ } } } + }, + "Search for releases of ${target}" : { + }, "Search for Replacement" : { "localizations" : { @@ -4752,16 +4830,6 @@ } } }, - "Search for TV Series" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search for TV Series" - } - } - } - }, "Search for TV series with ${name}" : { "localizations" : { "en" : { @@ -4781,6 +4849,18 @@ } } } + }, + "Search Movies" : { + + }, + "Search Series" : { + + }, + "Searches all indexers for releases of a movie." : { + + }, + "Searches all indexers for releases of all monitored episodes of a series." : { + }, "Searching..." : { "localizations" : { @@ -4975,6 +5055,9 @@ } } } + }, + "Series title, IMDb link, `imdb:` or `tvdb:` identifier." : { + }, "Series Type" : { "localizations" : { @@ -5079,6 +5162,9 @@ } } } + }, + "Something went wrong with %@." : { + }, "Sort By" : { "localizations" : { @@ -5131,6 +5217,9 @@ } } } + }, + "Started searching for %@." : { + }, "Status" : { "localizations" : { @@ -5372,6 +5461,9 @@ } } } + }, + "The URL of %@ is invalid." : { + }, "The URL used to access the %@ web interface." : { "localizations" : { @@ -5977,6 +6069,21 @@ } } } + }, + "Which episodes to monitor. Defaults to the last option used in the app." : { + + }, + "Which movie do you want to add?" : { + + }, + "Which movie?" : { + + }, + "Which series do you want to add?" : { + + }, + "Which series?" : { + }, "Wrong Instance Type" : { "localizations" : { @@ -6018,6 +6125,9 @@ } } } + }, + "You're not connected to the internet." : { + }, "Your popcorn is getting cold." : { "comment" : "Release search taunt", diff --git a/Ruddarr/Services/Intents+Automations.swift b/Ruddarr/Services/Intents+Automations.swift new file mode 100644 index 00000000..b9e6f686 --- /dev/null +++ b/Ruddarr/Services/Intents+Automations.swift @@ -0,0 +1,309 @@ +import Foundation +import AppIntents + +struct AddMovieIntent: AppIntent { + static let title: LocalizedStringResource = "Add Movie" + static let description: IntentDescription? = IntentDescription( + "Adds a movie to Radarr by title or IMDb link, using the last settings used in the app. Asks which one when there are several matches." + ) + + @Parameter(title: "Title", description: "Movie title, IMDb link, `imdb:` or `tmdb:` identifier.", requestValueDialog: "Which movie?") + var query: String + + @Parameter(title: "Automatic Search", default: false) + var search: Bool + + static var parameterSummary: some ParameterSummary { + Summary("Add \(\.$query) to Radarr") { + \.$search + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let instance = try await preferredInstance(.radarr) + + let results = try await requesting(instance) { + try await dependencies.api.radarr.lookup(instance, lookupTerm(query)) + } + + guard var movie = try await choose(results, { choiceLabel($0.title, $0.year, $0.exists) }, "Which movie do you want to add?") else { + throw AppError(String(localized: "No movie found matching “\(query)”.")) + } + + let alreadyAdded = movie.exists + + if !alreadyAdded { + let defaults = dependencies.store.string(forKey: "movieDefaults") + .flatMap(MovieDefaults.init(rawValue:)) ?? MovieDefaults() + + movie = try await requesting(instance) { [movie] in + let profiles = try await dependencies.api.instance.qualityProfiles(instance) + let folders = try await dependencies.api.instance.rootFolders(instance) + + var candidate = movie + candidate.applyDefaults(defaults, profiles, folders) + + return try await dependencies.api.radarr.add(candidate, instance) + } + } + + if search { + _ = try await requesting(instance) { + try await dependencies.api.instance.command(.search([movie.id]), instance) + } + } + + let message = addedMessage(resultLabel(movie.title, movie.year), alreadyAdded: alreadyAdded, search: search) + + return .result(value: MovieEntity(movie, instance), dialog: IntentDialog(stringLiteral: message)) + } +} + +struct AddSeriesIntent: AppIntent { + static let title: LocalizedStringResource = "Add Series" + static let description: IntentDescription? = IntentDescription( + "Adds a series to Sonarr by title or IMDb link, using the last settings used in the app. Asks which one when there are several matches." + ) + + @Parameter(title: "Title", description: "Series title, IMDb link, `imdb:` or `tvdb:` identifier.", requestValueDialog: "Which series?") + var query: String + + @Parameter(title: "Monitor", description: "Which episodes to monitor. Defaults to the last option used in the app.") + var monitor: SeriesMonitorOption? + + @Parameter(title: "Automatic Search", default: false) + var search: Bool + + static var parameterSummary: some ParameterSummary { + Summary("Add \(\.$query) to Sonarr") { + \.$monitor + \.$search + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let instance = try await preferredInstance(.sonarr) + + let results = try await requesting(instance) { + try await dependencies.api.sonarr.lookup(instance, lookupTerm(query)) + } + + guard var series = try await choose(results, { choiceLabel($0.title, $0.year, $0.exists) }, "Which series do you want to add?") else { + throw AppError(String(localized: "No series found matching “\(query)”.")) + } + + let alreadyAdded = series.exists + + if !alreadyAdded { + let defaults = dependencies.store.string(forKey: "seriesDefaults") + .flatMap(SeriesDefaults.init(rawValue:)) ?? SeriesDefaults() + + let monitorType = monitor?.type ?? defaults.monitor + + series = try await requesting(instance) { [series] in + let profiles = try await dependencies.api.instance.qualityProfiles(instance) + let folders = try await dependencies.api.instance.rootFolders(instance) + + var candidate = series + candidate.applyDefaults(defaults, monitorType, profiles, folders) + + return try await dependencies.api.sonarr.add(candidate, instance) + } + } + + if search { + _ = try await requesting(instance) { + try await dependencies.api.instance.command(.seriesSearch(series.id), instance) + } + } + + let message = addedMessage(resultLabel(series.title, series.year), alreadyAdded: alreadyAdded, search: search) + + return .result(value: SeriesEntity(series, instance), dialog: IntentDialog(stringLiteral: message)) + } +} + +struct MovieAutomaticSearchIntent: AppIntent { + static let title: LocalizedStringResource = "Automatic Movie Search" + static let description: IntentDescription? = IntentDescription("Searches all indexers for releases of a movie.") + + @Parameter(title: "Movie") + var target: MovieEntity + + static var parameterSummary: some ParameterSummary { + Summary("Search for releases of \(\.$target)") + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + let instance = try await instanceById(target.instanceId) + + _ = try await requesting(instance) { + try await dependencies.api.instance.command(.search([target.movieId]), instance) + } + + let message = String(localized: "Started searching for \(target.title).") + + return .result(dialog: IntentDialog(stringLiteral: message)) + } +} + +struct SeriesAutomaticSearchIntent: AppIntent { + static let title: LocalizedStringResource = "Automatic Series Search" + static let description: IntentDescription? = IntentDescription("Searches all indexers for releases of all monitored episodes of a series.") + + @Parameter(title: "Series") + var target: SeriesEntity + + static var parameterSummary: some ParameterSummary { + Summary("Search for releases of \(\.$target)") + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + let instance = try await instanceById(target.instanceId) + + _ = try await requesting(instance) { + try await dependencies.api.instance.command(.seriesSearch(target.seriesId), instance) + } + + let message = String(localized: "Started searching for \(target.title).") + + return .result(dialog: IntentDialog(stringLiteral: message)) + } +} + +enum SeriesMonitorOption: String, CaseIterable, AppEnum { + case all + case future + case missing + case existing + case firstSeason + case lastSeason + case pilot + case recent + case monitorSpecials + case unmonitorSpecials + case none + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Monitor") + + // Mirrors the options and labels of `SeriesForm.monitoringField` + static var caseDisplayRepresentations: [Self: DisplayRepresentation] {[ + .all: "All Episodes", + .future: "Future Episodes", + .missing: "Missing Episodes", + .existing: "Existing Episodes", + .firstSeason: "First Season", + .lastSeason: "Last Season", + .pilot: "Pilot Episode", + .recent: "Recent Episodes", + .monitorSpecials: "Monitor Specials", + .unmonitorSpecials: "Unmonitor Specials", + .none: "None", + ]} + + var type: SeriesMonitorType { + switch self { + case .all: .all + case .future: .future + case .missing: .missing + case .existing: .existing + case .firstSeason: .firstSeason + case .lastSeason: .lastSeason + case .pilot: .pilot + case .recent: .recent + case .monitorSpecials: .monitorSpecials + case .unmonitorSpecials: .unmonitorSpecials + case .none: .none + } + } +} + +private extension AppIntent { + /// Asks which lookup result to use when there is more than one. + func choose(_ results: [Item], _ label: (Item) -> String, _ dialog: IntentDialog) async throws -> Item? { + guard results.count > 1 else { return results.first } + + let candidates = Array(results.prefix(10)) + let options = candidates.map { IntentChoiceOption(title: LocalizedStringResource(stringLiteral: label($0)), style: .default) } + let choice = try await requestChoice(between: options + [.cancel], dialog: dialog) + + return options.firstIndex(of: choice).map { candidates[$0] } + } +} + +private func addedMessage(_ label: String, alreadyAdded: Bool, search: Bool) -> String { + switch (alreadyAdded, search) { + case (false, true): String(localized: "Added \(label) and started searching for releases.") + case (false, false): String(localized: "Added \(label).") + case (true, true): String(localized: "\(label) is already in your library. Started searching for releases.") + case (true, false): String(localized: "\(label) is already in your library.") + } +} + +private func resultLabel(_ title: String, _ year: Int) -> String { + year > 0 ? "\(title) (\(year))" : title +} + +private func choiceLabel(_ title: String, _ year: Int, _ exists: Bool) -> String { + let label = resultLabel(title, year) + + return exists ? label + " · " + String(localized: "In Library") : label +} + +private func lookupTerm(_ query: String) -> String { + extractImdbId(query).map { "imdb:\($0)" } ?? query.trimmed() +} + +@MainActor +private func preferredInstance(_ type: InstanceType) throws -> Instance { + let settings = AppSettings.shared + let instances = settings.configuredInstances.filter { $0.type == type } + let selected = type == .radarr ? settings.radarrInstanceId : settings.sonarrInstanceId + + guard let instance = instances.first(where: { $0.id == selected }) ?? instances.first else { + throw AppError(String(localized: "No \(type.rawValue) instance configured.")) + } + + return instance +} + +@MainActor +private func instanceById(_ id: Instance.ID) throws -> Instance { + guard let instance = AppSettings.shared.instanceById(id) else { + throw AppError(String(localized: "Instance not found.")) + } + + return instance +} + +private extension Movie { + mutating func applyDefaults(_ defaults: MovieDefaults, _ profiles: [InstanceQualityProfile], _ folders: [InstanceRootFolder]) { + let availabilities: [MovieStatus] = [.announced, .inCinemas, .released] + + addOptions = MovieAddOptions(monitor: defaults.monitor) + monitored = defaults.monitor != .none + + minimumAvailability = availabilities.contains(defaults.minimumAvailability) + ? defaults.minimumAvailability + : .announced + + qualityProfileId = AddDefaults.qualityProfile(defaults.qualityProfile, in: profiles.map(\.id)) + rootFolderPath = AddDefaults.rootFolder(defaults.rootFolder, in: folders.map(\.path)) + } +} + +private extension Series { + mutating func applyDefaults( + _ defaults: SeriesDefaults, + _ monitor: SeriesMonitorType, + _ profiles: [InstanceQualityProfile], + _ folders: [InstanceRootFolder] + ) { + addOptions = SeriesAddOptions(monitor: monitor) + monitorNewItems = nil + seasonFolder = defaults.seasonFolder + + qualityProfileId = AddDefaults.qualityProfile(defaults.qualityProfile, in: profiles.map(\.id)) + rootFolderPath = AddDefaults.rootFolder(defaults.rootFolder, in: folders.map(\.path)) + } +} diff --git a/Ruddarr/Services/Intents+Calendar.swift b/Ruddarr/Services/Intents+Calendar.swift new file mode 100644 index 00000000..9da8af03 --- /dev/null +++ b/Ruddarr/Services/Intents+Calendar.swift @@ -0,0 +1,104 @@ +import Foundation +import AppIntents + +struct UpcomingReleasesIntent: AppIntent { + static let title: LocalizedStringResource = "Get Upcoming Releases" + static let description: IntentDescription? = IntentDescription("Lists the movies and episodes releasing in the coming days.") + + @Parameter(title: "Days", default: 7) + var days: Int + + static var parameterSummary: some ParameterSummary { + Summary("Get releases for the next \(\.$days) days") + } + + func perform() async throws -> some IntentResult & ReturnsValue<[String]> & ProvidesDialog { + let span = min(max(days, 1), 90) + let calendar = Calendar.current + let start = calendar.startOfDay(for: .now) + let end = calendar.date(byAdding: .day, value: span, to: start) ?? start + let instances = await MainActor.run { AppSettings.shared.configuredInstances } + + var releases: [UpcomingRelease] = [] + var failures: [InstanceFailure] = [] + + await withTaskGroup(of: (Instance, Result<[UpcomingRelease], any Error>).self) { group in + for instance in instances { + group.addTask { + do { + let items = try await upcomingReleases(instance, start..) async throws -> [UpcomingRelease] { + switch instance.type { + case .radarr: + try await dependencies.api.radarr.calendar(range.lowerBound, range.upperBound, instance) + .flatMap { movieReleases($0, range) } + case .sonarr: + try await dependencies.api.sonarr.calendar(range.lowerBound, range.upperBound, instance) + .compactMap { episodeRelease($0, range) } + } +} + +private func movieReleases(_ movie: Movie, _ range: Range) -> [UpcomingRelease] { + let releases: [(date: Date?, type: String)] = [ + (movie.inCinemas, String(localized: "In Cinemas")), + (movie.digitalRelease, String(localized: "Digital Release")), + (movie.physicalRelease, String(localized: "Physical Release")), + ] + + return releases.compactMap { release in + guard let date = release.date, range.contains(date) else { return nil } + + let day = date.formatted(.dateTime.weekday().month().day()) + + return UpcomingRelease(date: date, text: "\(day) · \(movie.title) (\(release.type))") + } +} + +private func episodeRelease(_ episode: Episode, _ range: Range) -> UpcomingRelease? { + guard let date = episode.airDateUtc, range.contains(date) else { return nil } + + let time = date.formatted(.dateTime.weekday().month().day().hour().minute()) + let parts = [episode.series?.title, episode.episodeLabel, episode.title].compactMap { $0 } + + return UpcomingRelease(date: date, text: "\(time) · \(parts.joined(separator: " · "))") +} diff --git a/Ruddarr/Services/Intents+Errors.swift b/Ruddarr/Services/Intents+Errors.swift new file mode 100644 index 00000000..61168094 --- /dev/null +++ b/Ruddarr/Services/Intents+Errors.swift @@ -0,0 +1,60 @@ +import Foundation + +typealias InstanceFailure = (instance: Instance, error: any Error) + +/// Performs requests against an instance, replacing failures with a short message that names the instance. +func requesting(_ instance: Instance, _ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch let error as CancellationError { + throw error + } catch { + throw AppError(instanceErrorMessage(error, instance)) + } +} + +/// A short, user-facing message for a request that failed on one or more instances. +func instanceErrorMessage(_ failures: [InstanceFailure]) -> String { + if failures.count == 1, let failure = failures.first { + return instanceErrorMessage(failure.error, failure.instance) + } + + let labels = failures.map { instanceLabel($0.instance) }.formatted(.list(type: .and)) + + return String(localized: "Requests to \(labels) failed.") +} + +/// A short, user-facing message for a request that failed on an instance. +func instanceErrorMessage(_ error: any Error, _ instance: Instance) -> String { + let label = instanceLabel(instance) + + guard let error = error as? API.Error else { + return error.localizedDescription + } + + return switch error { + case .notConnectedToInternet: + String(localized: "You're not connected to the internet.") + case .urlError, .timeoutOnPrivateIp: + String(localized: "Couldn't reach \(label).") + case .badStatusCode(code: 401), .badStatusCode(code: 403), + .errorResponse(code: 401, message: _), .errorResponse(code: 403, message: _): + String(localized: "\(label) rejected the API key.") + case .badStatusCode(let code): + String(localized: "\(label) responded with an error (\(code)).") + case .errorResponse(_, let message): + "\(label): \(message)" + case .invalidUrl: + String(localized: "The URL of \(label) is invalid.") + case .decodingError: + String(localized: "\(label) returned an unexpected response.") + case .appError(let error): + error.errorDescription ?? String(localized: "Something went wrong with \(label).") + case .void, .localizedError, .nsError, .error: + String(localized: "Something went wrong with \(label).") + } +} + +private func instanceLabel(_ instance: Instance) -> String { + instance.label.isEmpty ? instance.type.rawValue : instance.label +} diff --git a/Ruddarr/Services/Intents.swift b/Ruddarr/Services/Intents.swift index dbe423e9..d16662e1 100644 --- a/Ruddarr/Services/Intents.swift +++ b/Ruddarr/Services/Intents.swift @@ -24,7 +24,7 @@ struct OpenAppIntent: OpenIntent { } struct SearchMovieIntent: AppIntent { - static let title: LocalizedStringResource = "Search for Movie" + static let title: LocalizedStringResource = "Search Movies" static let openAppWhenRun: Bool = true @Parameter(title: "Title") @@ -57,7 +57,7 @@ struct SearchMovieIntent: AppIntent { } struct SearchSeriesIntent: AppIntent { - static let title: LocalizedStringResource = "Search for TV Series" + static let title: LocalizedStringResource = "Search Series" static let openAppWhenRun: Bool = true @Parameter(title: "Title") diff --git a/Ruddarr/Services/Shortcuts.swift b/Ruddarr/Services/Shortcuts.swift index ab3bb9a1..7c036712 100644 --- a/Ruddarr/Services/Shortcuts.swift +++ b/Ruddarr/Services/Shortcuts.swift @@ -5,9 +5,8 @@ struct Shortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( - intent: SearchMovieIntent(), + intent: AddMovieIntent(), phrases: [ - "Search for movie in \(.applicationName)", "Add movie to \(.applicationName)", ], shortTitle: "Add Movie", @@ -15,10 +14,8 @@ struct Shortcuts: AppShortcutsProvider { ) AppShortcut( - intent: SearchSeriesIntent(), + intent: AddSeriesIntent(), phrases: [ - "Search for series in \(.applicationName)", - "Search for TV series in \(.applicationName)", "Add series to \(.applicationName)", "Add TV series to \(.applicationName)", ], @@ -26,6 +23,25 @@ struct Shortcuts: AppShortcutsProvider { systemImageName: "plus" ) + AppShortcut( + intent: SearchMovieIntent(), + phrases: [ + "Search for movie in \(.applicationName)", + ], + shortTitle: "Search Movies", + systemImageName: "magnifyingglass" + ) + + AppShortcut( + intent: SearchSeriesIntent(), + phrases: [ + "Search for series in \(.applicationName)", + "Search for TV series in \(.applicationName)", + ], + shortTitle: "Search Series", + systemImageName: "magnifyingglass" + ) + AppShortcut( intent: OpenAppIntent(), phrases: [ diff --git a/Ruddarr/Utilities/AddDefaults.swift b/Ruddarr/Utilities/AddDefaults.swift new file mode 100644 index 00000000..1781069b --- /dev/null +++ b/Ruddarr/Utilities/AddDefaults.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Resolves the stored add defaults against what an instance currently offers. +enum AddDefaults { + /// Returns the preferred quality profile if the instance has it, otherwise its first profile. + static func qualityProfile(_ preferred: Int?, in available: [Int]) -> Int { + if let preferred, available.contains(preferred) { + return preferred + } + + return available.first ?? 0 + } + + /// Returns the preferred root folder (without trailing slashes) if the instance has it, otherwise its first root folder. + static func rootFolder(_ preferred: String?, in available: [String?]) -> String? { + let folder = preferred?.untrailingSlashIt + + if let fallback = available.first ?? nil, + !available.contains(where: { $0?.untrailingSlashIt == folder }) { + return fallback + } + + return folder + } +} diff --git a/Ruddarr/Views/Movies/MovieForm.swift b/Ruddarr/Views/Movies/MovieForm.swift index 1be4cd09..7ee008ad 100644 --- a/Ruddarr/Views/Movies/MovieForm.swift +++ b/Ruddarr/Views/Movies/MovieForm.swift @@ -152,20 +152,9 @@ struct MovieForm: View { movie.minimumAvailability = .announced } - if !instance.qualityProfiles.contains(where: { - $0.id == movie.qualityProfileId - }) { - movie.qualityProfileId = instance.qualityProfiles.first?.id ?? 0 - } - - movie.rootFolderPath = movie.rootFolderPath?.untrailingSlashIt + movie.qualityProfileId = AddDefaults.qualityProfile(movie.qualityProfileId, in: instance.qualityProfiles.map(\.id)) - if let fallback = instance.rootFolders.first?.path, - !instance.rootFolders.contains(where: { - $0.path?.untrailingSlashIt == movie.rootFolderPath - }) { - movie.rootFolderPath = fallback - } + movie.rootFolderPath = AddDefaults.rootFolder(movie.rootFolderPath, in: instance.rootFolders.map(\.path)) } func tags() -> Binding> { diff --git a/Ruddarr/Views/Series/SeriesForm.swift b/Ruddarr/Views/Series/SeriesForm.swift index 5e8466b6..8f0215fe 100644 --- a/Ruddarr/Views/Series/SeriesForm.swift +++ b/Ruddarr/Views/Series/SeriesForm.swift @@ -146,21 +146,9 @@ struct SeriesForm: View { series.qualityProfileId = seriesDefaults.qualityProfile } - if !instance.qualityProfiles.contains(where: { - $0.id == series.qualityProfileId - }) { - series.qualityProfileId = instance.qualityProfiles.first?.id ?? 0 - } - - // remove trailing slashes - series.rootFolderPath = series.rootFolderPath?.untrailingSlashIt + series.qualityProfileId = AddDefaults.qualityProfile(series.qualityProfileId, in: instance.qualityProfiles.map(\.id)) - if let fallback = instance.rootFolders.first?.path, - !instance.rootFolders.contains(where: { - $0.path?.untrailingSlashIt == series.rootFolderPath - }) { - series.rootFolderPath = fallback - } + series.rootFolderPath = AddDefaults.rootFolder(series.rootFolderPath, in: instance.rootFolders.map(\.path)) } func tags() -> Binding> { diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index ea05759b..db4a8d8d 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -7,3 +7,6 @@ Added - Open the exact movie on Letterboxd instead of a search - Open and search movies and series from Shortcuts and Siri on iOS 27 - Ask Siri about the movie or series on screen +- Add movies and series from Shortcuts and Siri +- Start automatic searches from Shortcuts +- Get upcoming releases from Shortcuts diff --git a/Tests/AddDefaultsTests.swift b/Tests/AddDefaultsTests.swift new file mode 100644 index 00000000..4af771b0 --- /dev/null +++ b/Tests/AddDefaultsTests.swift @@ -0,0 +1,62 @@ +import Testing +import Foundation + +// Exercises `AddDefaults`, which resolves the stored movie/series add defaults against the +// quality profiles and root folders an instance currently offers. Shared by `MovieForm`, +// `SeriesForm` and the Add Movie / Add Series App Intents. +struct AddDefaultsQualityProfileTests { + @Test func keepsPreferredProfileWhenAvailable() { + #expect(AddDefaults.qualityProfile(4, in: [1, 4, 6]) == 4) + } + + @Test func fallsBackToFirstProfileWhenPreferredIsMissing() { + #expect(AddDefaults.qualityProfile(9, in: [1, 4, 6]) == 1) + } + + // Stored defaults start out as `-1` until something was added. + @Test func fallsBackToFirstProfileForUnsetDefault() { + #expect(AddDefaults.qualityProfile(-1, in: [3, 4]) == 3) + #expect(AddDefaults.qualityProfile(nil, in: [3, 4]) == 3) + } + + @Test func returnsZeroWithoutProfiles() { + #expect(AddDefaults.qualityProfile(4, in: []) == 0) + #expect(AddDefaults.qualityProfile(nil, in: []) == 0) + } +} + +struct AddDefaultsRootFolderTests { + @Test func keepsPreferredFolderWhenAvailable() { + #expect(AddDefaults.rootFolder("/movies/4k", in: ["/movies", "/movies/4k"]) == "/movies/4k") + } + + @Test func stripsTrailingSlashesFromPreferredFolder() { + #expect(AddDefaults.rootFolder("/movies/4k//", in: ["/movies", "/movies/4k"]) == "/movies/4k") + } + + // Radarr/Sonarr may report root folders with a trailing slash. + @Test func matchesAvailableFoldersWithTrailingSlash() { + #expect(AddDefaults.rootFolder("/movies/4k", in: ["/movies/", "/movies/4k/"]) == "/movies/4k") + } + + // The fallback is the first folder exactly as the instance reports it. + @Test func fallsBackToFirstFolderWhenPreferredIsMissing() { + #expect(AddDefaults.rootFolder("/old", in: ["/movies/", "/movies/4k"]) == "/movies/") + } + + // Stored defaults start out as an empty string until something was added. + @Test func fallsBackToFirstFolderForUnsetDefault() { + #expect(AddDefaults.rootFolder("", in: ["/movies"]) == "/movies") + #expect(AddDefaults.rootFolder(nil, in: ["/movies"]) == "/movies") + } + + @Test func keepsPreferredFolderWithoutAvailableFolders() { + #expect(AddDefaults.rootFolder("/movies/", in: []) == "/movies") + #expect(AddDefaults.rootFolder(nil, in: []) == nil) + } + + // A first folder without a path offers no fallback. + @Test func keepsPreferredFolderWhenFirstFolderHasNoPath() { + #expect(AddDefaults.rootFolder("/old", in: [nil, "/movies"]) == "/old") + } +}