From 2564019d0c0cd5d9e3169b862b79594b6c851300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Wed, 7 Oct 2026 10:02:08 -0700 Subject: [PATCH 1/4] Add movie and series App Intents entities and link Spotlight items - Movie and series entities with a string query, usable as Shortcuts parameters - Open Movie / Open Series intents on the iOS 27 system open schema - Spotlight items are associated with their entity; identifiers keep the existing :: format and parsing no longer traps on a missing instance - On-screen entity identifiers on the movie and series detail screens --- CHANGELOG.md | 1 + Ruddarr.xcodeproj/project.pbxproj | 1 + Ruddarr/Localizable.xcstrings | 15 ++ Ruddarr/Models/Media.swift | 2 +- Ruddarr/Models/Movies/Movie.swift | 7 +- Ruddarr/Models/Movies/Movies.swift | 2 +- Ruddarr/Models/Series/Series.swift | 7 +- Ruddarr/Models/Series/SeriesModel.swift | 2 +- Ruddarr/Ruddarr.swift | 13 +- Ruddarr/Services/Entities.swift | 296 ++++++++++++++++++++++ Ruddarr/Services/Intents.swift | 29 +++ Ruddarr/Services/Spotlight.swift | 4 +- Ruddarr/Utilities/Identifiers.swift | 68 +++++ Ruddarr/Views/Movies/MovieView.swift | 5 + Ruddarr/Views/Series/SeriesItemView.swift | 5 + TestFlight/WhatToTest.en-US.txt | 1 + Tests/IdentifierFormatTests.swift | 104 ++++++++ 17 files changed, 545 insertions(+), 17 deletions(-) create mode 100644 Ruddarr/Services/Entities.swift create mode 100644 Ruddarr/Utilities/Identifiers.swift create mode 100644 Tests/IdentifierFormatTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b6209e..41c9a95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search +- Open movies and series from Shortcuts and Siri on iOS 27 ## 2.1.2 - 2026-11-02 ### Fixed diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 417b6659..2b8f6cde 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ Utilities/Extensions.swift, Utilities/Formatters.swift, Utilities/Helpers.swift, + Utilities/Identifiers.swift, Utilities/Networking.swift, Utilities/Placeholder.swift, Utilities/Privacy.swift, diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index 0e1c7f7f..a988353f 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -1652,6 +1652,9 @@ } } } + }, + "Downloaded Episodes" : { + }, "Downloading" : { "comment" : "(Short) State of task in queue\nState of task in queue\nStatus of task in queue", @@ -2239,6 +2242,9 @@ } } } + }, + "IMDb ID" : { + }, "Import" : { "comment" : "(Short) Importing a queue task", @@ -5037,6 +5043,9 @@ } } } + }, + "Size on Disk" : { + }, "Slow Instance" : { "localizations" : { @@ -5433,6 +5442,9 @@ } } } + }, + "TMDB ID" : { + }, "To monitor a season or episode, the series itself must be monitored." : { "localizations" : { @@ -5525,6 +5537,9 @@ } } } + }, + "TVDB ID" : { + }, "Type" : { "comment" : "Short version of Series Type", diff --git a/Ruddarr/Models/Media.swift b/Ruddarr/Models/Media.swift index 8a1fb070..73fa30f6 100644 --- a/Ruddarr/Models/Media.swift +++ b/Ruddarr/Models/Media.swift @@ -6,7 +6,7 @@ protocol Media: Identifiable, Sendable where ID: Sendable { var remotePoster: String? { get } var searchableHash: String { get } - func searchableItem(poster: URL?) -> CSSearchableItem + func searchableItem(poster: URL?, instance: Instance) -> CSSearchableItem } struct Tag: Identifiable, Equatable, Codable { diff --git a/Ruddarr/Models/Movies/Movie.swift b/Ruddarr/Models/Movies/Movie.swift index 6c5dffd6..8c44473d 100644 --- a/Ruddarr/Models/Movies/Movie.swift +++ b/Ruddarr/Models/Movies/Movie.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppIntents import CoreSpotlight struct Movie: Media, Identifiable, Equatable, Codable { @@ -238,7 +239,7 @@ struct Movie: Media, Identifiable, Equatable, Codable { } extension Movie { - func searchableItem(poster: URL?) -> CSSearchableItem { + func searchableItem(poster: URL?, instance: Instance) -> CSSearchableItem { let attributes = CSSearchableItemAttributeSet(contentType: UTType.movie) attributes.title = title attributes.genre = genres.first @@ -257,8 +258,10 @@ extension Movie { .filter { $0.title == title } .map { $0.title } + attributes.associateAppEntity(MovieEntity(self, instance)) + return CSSearchableItem( - uniqueIdentifier: "movie:\(id):\(instanceId?.uuidString ?? "")", + uniqueIdentifier: SpotlightItemID(.movie, id, instanceId?.uuidString).rawValue, domainIdentifier: instanceId?.uuidString, attributeSet: attributes ) diff --git a/Ruddarr/Models/Movies/Movies.swift b/Ruddarr/Models/Movies/Movies.swift index baac9bd0..185930d0 100644 --- a/Ruddarr/Models/Movies/Movies.swift +++ b/Ruddarr/Models/Movies/Movies.swift @@ -135,7 +135,7 @@ class Movies { items = try await dependencies.api.radarr.fetch(instance) itemsCount = items.count computeAlternateTitles() - await Spotlight(instance.id).index(items, delay: .seconds(5)) + await Spotlight(instance.id).index(items, from: instance, delay: .seconds(5)) leaveBreadcrumb(.info, category: "movies", message: "Fetched movies", data: ["count": items.count]) diff --git a/Ruddarr/Models/Series/Series.swift b/Ruddarr/Models/Series/Series.swift index a75cddbd..69e8fdee 100644 --- a/Ruddarr/Models/Series/Series.swift +++ b/Ruddarr/Models/Series/Series.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppIntents import CoreSpotlight struct Series: Media, Identifiable, Equatable, Codable { @@ -227,7 +228,7 @@ struct Series: Media, Identifiable, Equatable, Codable { } extension Series { - func searchableItem(poster: URL?) -> CSSearchableItem { + func searchableItem(poster: URL?, instance: Instance) -> CSSearchableItem { let attributes = CSSearchableItemAttributeSet(contentType: UTType.movie) attributes.title = title attributes.genre = genres.first @@ -247,8 +248,10 @@ extension Series { .map { $0.title } } + attributes.associateAppEntity(SeriesEntity(self, instance)) + return CSSearchableItem( - uniqueIdentifier: "series:\(id):\(instanceId?.uuidString ?? "")", + uniqueIdentifier: SpotlightItemID(.series, id, instanceId?.uuidString).rawValue, domainIdentifier: instanceId?.uuidString, attributeSet: attributes ) diff --git a/Ruddarr/Models/Series/SeriesModel.swift b/Ruddarr/Models/Series/SeriesModel.swift index 849ee6ac..df3b3e9e 100644 --- a/Ruddarr/Models/Series/SeriesModel.swift +++ b/Ruddarr/Models/Series/SeriesModel.swift @@ -148,7 +148,7 @@ class SeriesModel { items = try await dependencies.api.sonarr.fetch(instance) itemsCount = items.count computeAlternateTitles() - await Spotlight(instance.id).index(items, delay: .seconds(5)) + await Spotlight(instance.id).index(items, from: instance, delay: .seconds(5)) leaveBreadcrumb(.info, category: "series", message: "Fetched series", data: ["count": items.count]) diff --git a/Ruddarr/Ruddarr.swift b/Ruddarr/Ruddarr.swift index 012dcdd7..b53c1cac 100644 --- a/Ruddarr/Ruddarr.swift +++ b/Ruddarr/Ruddarr.swift @@ -68,16 +68,13 @@ struct Ruddarr: App { func openSearchableItem(_ userActivity: NSUserActivity) { guard let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return } - let parts = identifier.split(separator: ":").map(String.init) // `::` - - let deeplink: QuickActions.Deeplink? = switch parts[0] { - case "movie": Movie.ID(parts[1]).map { .openMovie($0, parts[2]) } - case "series": Series.ID(parts[1]).map { .openSeries($0, parts[2]) } - default: nil + guard let item = SpotlightItemID(rawValue: identifier) else { + return leaveBreadcrumb(.error, category: "spotlight", message: "Invalid identifier", data: ["openSearchableItem": identifier]) } - guard let deeplink else { - return leaveBreadcrumb(.error, category: "spotlight", message: "Invalid identifier", data: ["openSearchableItem": identifier]) + let deeplink: QuickActions.Deeplink = switch item.kind { + case .movie: .openMovie(item.mediaId, item.instance) + case .series: .openSeries(item.mediaId, item.instance) } deeplink() diff --git a/Ruddarr/Services/Entities.swift b/Ruddarr/Services/Entities.swift new file mode 100644 index 00000000..b6bef14e --- /dev/null +++ b/Ruddarr/Services/Entities.swift @@ -0,0 +1,296 @@ +import Foundation +import AppIntents + +struct MovieEntity: IndexedEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Movie") + static let defaultQuery = MovieEntityQuery() + + let id: String + let movieId: Movie.ID + let instanceId: Instance.ID + + @Property(title: "Title") + var title: String + + @Property(title: "Year") + var year: Int + + @Property(title: "Status") + var status: String + + @Property(title: "Monitored") + var monitored: Bool + + @Property(title: "Downloaded") + var downloaded: Bool + + @Property(title: "Size on Disk") + var sizeOnDisk: Measurement? + + @Property(title: "In Cinemas") + var inCinemas: Date? + + @Property(title: "Digital Release") + var digitalRelease: Date? + + @Property(title: "Physical Release") + var physicalRelease: Date? + + @Property(title: "IMDb ID") + var imdbId: String? + + @Property(title: "TMDB ID") + var tmdbId: Int + + @Property(title: "Instance") + var instance: String + + init(_ movie: Movie, _ instance: Instance) { + self.id = MediaEntityID(movie.id, instance.id).rawValue + self.movieId = movie.id + self.instanceId = instance.id + self.title = movie.title + self.year = movie.year + self.status = movie.status.label + self.monitored = movie.monitored + self.downloaded = movie.isDownloaded + self.sizeOnDisk = movie.sizeOnDisk.map { Measurement(value: Double($0), unit: .bytes) } + self.inCinemas = movie.inCinemas + self.digitalRelease = movie.digitalRelease + self.physicalRelease = movie.physicalRelease + self.imdbId = movie.imdbId + self.tmdbId = movie.tmdbId + self.instance = instance.label + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: LocalizedStringResource(stringLiteral: title), + subtitle: year > 0 ? LocalizedStringResource(stringLiteral: String(year)) : nil + ) + } + + static func entityIdentifier(_ movie: Movie) -> EntityIdentifier? { + movie.instanceId.map { + EntityIdentifier(for: Self.self, identifier: MediaEntityID(movie.id, $0).rawValue) + } + } +} + +struct MovieEntityQuery: EntityStringQuery { + func entities(for identifiers: [MovieEntity.ID]) async throws -> [MovieEntity] { + let instances = await radarrInstances() + + return await withTaskGroup(of: MovieEntity?.self, returning: [MovieEntity].self) { group in + for identifier in identifiers { + guard let parsed = MediaEntityID(rawValue: identifier), + let instance = instances.first(where: { $0.id == parsed.instanceId }) + else { + continue + } + + group.addTask { + let movie = try? await dependencies.api.radarr.movie(parsed.mediaId, instance) + + return movie.map { MovieEntity($0, instance) } + } + } + + var entities: [MovieEntity] = [] + + for await entity in group { + if let entity { entities.append(entity) } + } + + return entities + } + } + + func entities(matching string: String) async throws -> [MovieEntity] { + let query = string.trimmed() + + return await library().filter { $0.title.localizedStandardContains(query) } + } + + func suggestedEntities() async throws -> [MovieEntity] { + await library().sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private func library() async -> [MovieEntity] { + let instances = await radarrInstances() + + return await withTaskGroup(of: [MovieEntity].self, returning: [MovieEntity].self) { group in + for instance in instances { + group.addTask { + let movies = (try? await dependencies.api.radarr.fetch(instance)) ?? [] + + return movies.map { MovieEntity($0, instance) } + } + } + + var entities: [MovieEntity] = [] + + for await batch in group { + entities += batch + } + + return entities + } + } + + @MainActor + private func radarrInstances() -> [Instance] { + AppSettings.shared.configuredInstances.filter { $0.type == .radarr } + } +} + +struct SeriesEntity: IndexedEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Series") + static let defaultQuery = SeriesEntityQuery() + + let id: String + let seriesId: Series.ID + let instanceId: Instance.ID + + @Property(title: "Title") + var title: String + + @Property(title: "Year") + var year: Int + + @Property(title: "Status") + var status: String + + @Property(title: "Monitored") + var monitored: Bool + + @Property(title: "Network") + var network: String? + + @Property(title: "Seasons") + var seasons: Int? + + @Property(title: "Episodes") + var episodes: Int? + + @Property(title: "Downloaded Episodes") + var downloadedEpisodes: Int? + + @Property(title: "Size on Disk") + var sizeOnDisk: Measurement? + + @Property(title: "Next Airing") + var nextAiring: Date? + + @Property(title: "Previous Airing") + var previousAiring: Date? + + @Property(title: "IMDb ID") + var imdbId: String? + + @Property(title: "TVDB ID") + var tvdbId: Int + + @Property(title: "Instance") + var instance: String + + init(_ series: Series, _ instance: Instance) { + self.id = MediaEntityID(series.id, instance.id).rawValue + self.seriesId = series.id + self.instanceId = instance.id + self.title = series.title + self.year = series.year + self.status = series.status.label + self.monitored = series.monitored + self.network = series.network + self.seasons = series.statistics?.seasonCount + self.episodes = series.statistics?.episodeCount + self.downloadedEpisodes = series.statistics?.episodeFileCount + self.sizeOnDisk = series.statistics.map { Measurement(value: Double($0.sizeOnDisk), unit: .bytes) } + self.nextAiring = series.nextAiring + self.previousAiring = series.previousAiring + self.imdbId = series.imdbId + self.tvdbId = series.tvdbId + self.instance = instance.label + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: LocalizedStringResource(stringLiteral: title), + subtitle: year > 0 ? LocalizedStringResource(stringLiteral: String(year)) : nil + ) + } + + static func entityIdentifier(_ series: Series) -> EntityIdentifier? { + series.instanceId.map { + EntityIdentifier(for: Self.self, identifier: MediaEntityID(series.id, $0).rawValue) + } + } +} + +struct SeriesEntityQuery: EntityStringQuery { + func entities(for identifiers: [SeriesEntity.ID]) async throws -> [SeriesEntity] { + let instances = await sonarrInstances() + + return await withTaskGroup(of: SeriesEntity?.self, returning: [SeriesEntity].self) { group in + for identifier in identifiers { + guard let parsed = MediaEntityID(rawValue: identifier), + let instance = instances.first(where: { $0.id == parsed.instanceId }) + else { + continue + } + + group.addTask { + let series = try? await dependencies.api.sonarr.series(parsed.mediaId, instance) + + return series.map { SeriesEntity($0, instance) } + } + } + + var entities: [SeriesEntity] = [] + + for await entity in group { + if let entity { entities.append(entity) } + } + + return entities + } + } + + func entities(matching string: String) async throws -> [SeriesEntity] { + let query = string.trimmed() + + return await library().filter { $0.title.localizedStandardContains(query) } + } + + func suggestedEntities() async throws -> [SeriesEntity] { + await library().sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private func library() async -> [SeriesEntity] { + let instances = await sonarrInstances() + + return await withTaskGroup(of: [SeriesEntity].self, returning: [SeriesEntity].self) { group in + for instance in instances { + group.addTask { + let series = (try? await dependencies.api.sonarr.fetch(instance)) ?? [] + + return series.map { SeriesEntity($0, instance) } + } + } + + var entities: [SeriesEntity] = [] + + for await batch in group { + entities += batch + } + + return entities + } + } + + @MainActor + private func sonarrInstances() -> [Instance] { + AppSettings.shared.configuredInstances.filter { $0.type == .sonarr } + } +} diff --git a/Ruddarr/Services/Intents.swift b/Ruddarr/Services/Intents.swift index ee195cfc..e1a1bdf9 100644 --- a/Ruddarr/Services/Intents.swift +++ b/Ruddarr/Services/Intents.swift @@ -88,3 +88,32 @@ struct SearchSeriesIntent: AppIntent { return .result() } } + +// Siri app schemas require the iOS 27 SDK (Swift 6.4) +#if compiler(>=6.4) +@available(iOS 27.0, macOS 27.0, *) +@AppIntent(schema: .system.open) +struct OpenMovieIntent: OpenIntent { + var target: MovieEntity + + @MainActor + func perform() async throws -> some IntentResult { + dependencies.quickActions.openMovie(target.movieId, target.instanceId.uuidString) + + return .result() + } +} + +@available(iOS 27.0, macOS 27.0, *) +@AppIntent(schema: .system.open) +struct OpenSeriesIntent: OpenIntent { + var target: SeriesEntity + + @MainActor + func perform() async throws -> some IntentResult { + dependencies.quickActions.openSeries(target.seriesId, nil, nil, target.instanceId.uuidString) + + return .result() + } +} +#endif diff --git a/Ruddarr/Services/Spotlight.swift b/Ruddarr/Services/Spotlight.swift index 121de05e..047c99d6 100644 --- a/Ruddarr/Services/Spotlight.swift +++ b/Ruddarr/Services/Spotlight.swift @@ -14,7 +14,7 @@ actor Spotlight { "spotlight:\(instanceId)" } - func index(_ entities: [M], delay: Duration? = nil) { + func index(_ entities: [M], from instance: Instance, delay: Duration? = nil) { guard CSSearchableIndex.isIndexingAvailable() else { return } Task(priority: .background) { @@ -47,7 +47,7 @@ actor Spotlight { try await index.indexSearchableItems( entities[start..:`. +/// +/// WARNING: Users' shortcuts persist these identifiers, changing the format breaks them. +struct MediaEntityID: Hashable { + let mediaId: Int + let instanceId: UUID + + init(_ mediaId: Int, _ instanceId: UUID) { + self.mediaId = mediaId + self.instanceId = instanceId + } + + init?(rawValue: String) { + let parts = rawValue.split(separator: ":", omittingEmptySubsequences: false) + + guard parts.count == 2, + let instanceId = UUID(uuidString: String(parts[0])), + let mediaId = Int(parts[1]) + else { + return nil + } + + self.init(mediaId, instanceId) + } + + var rawValue: String { + "\(instanceId.uuidString):\(mediaId)" + } +} + +/// Identifier of a movie or series Spotlight item: `::`. +/// +/// WARNING: Spotlight persists these identifiers, changing the format breaks existing items. +struct SpotlightItemID: Hashable { + enum Kind: String { + case movie + case series + } + + let kind: Kind + let mediaId: Int + let instance: String? + + init(_ kind: Kind, _ mediaId: Int, _ instance: String?) { + self.kind = kind + self.mediaId = mediaId + self.instance = instance + } + + init?(rawValue: String) { + let parts = rawValue.split(separator: ":", omittingEmptySubsequences: false) + + guard parts.count == 3, + let kind = Kind(rawValue: String(parts[0])), + let mediaId = Int(parts[1]) + else { + return nil + } + + self.init(kind, mediaId, parts[2].isEmpty ? nil : String(parts[2])) + } + + var rawValue: String { + "\(kind.rawValue):\(mediaId):\(instance ?? "")" + } +} diff --git a/Ruddarr/Views/Movies/MovieView.swift b/Ruddarr/Views/Movies/MovieView.swift index 2c549416..618a2d74 100644 --- a/Ruddarr/Views/Movies/MovieView.swift +++ b/Ruddarr/Views/Movies/MovieView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppIntents struct MovieView: View { @Binding var movie: Movie @@ -19,6 +20,10 @@ struct MovieView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) + // Unavailable when compiling for Intel Macs + #if arch(arm64) + .appEntityIdentifier(MovieEntity.entityIdentifier(movie)) + #endif } .scrollContainerMargins() .refreshable { diff --git a/Ruddarr/Views/Series/SeriesItemView.swift b/Ruddarr/Views/Series/SeriesItemView.swift index add61d27..9a75e007 100644 --- a/Ruddarr/Views/Series/SeriesItemView.swift +++ b/Ruddarr/Views/Series/SeriesItemView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppIntents struct SeriesDetailView: View { @Binding var series: Series @@ -21,6 +22,10 @@ struct SeriesDetailView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) + // Unavailable when compiling for Intel Macs + #if arch(arm64) + .appEntityIdentifier(SeriesEntity.entityIdentifier(series)) + #endif } .scrollContainerMargins() .refreshable { diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index ccca875f..cb9e9ed3 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -5,3 +5,4 @@ Added - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search +- Open movies and series from Shortcuts and Siri on iOS 27 diff --git a/Tests/IdentifierFormatTests.swift b/Tests/IdentifierFormatTests.swift new file mode 100644 index 00000000..aff72038 --- /dev/null +++ b/Tests/IdentifierFormatTests.swift @@ -0,0 +1,104 @@ +import Testing +import Foundation + +// Locks the persisted identifier formats in `Identifiers.swift`. +// +// `MediaEntityID` strings are stored inside users' shortcuts (App Intents entities) and +// `SpotlightItemID` strings are stored in the on-device Spotlight index. Both outlive app +// updates, so the fixtures below must keep parsing and building exactly as they do today. +struct MediaEntityIDTests { + private let instance = UUID(uuidString: "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8")! + + @Test func buildsShippedFormat() { + #expect(MediaEntityID(42, instance).rawValue == "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:42") + } + + @Test func parsesShippedFormat() { + let id = MediaEntityID(rawValue: "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:42") + + #expect(id?.mediaId == 42) + #expect(id?.instanceId == instance) + } + + @Test func roundTrips() { + for mediaId in [0, 1, 42, 100_123, Int.max] { + let id = MediaEntityID(mediaId, instance) + #expect(MediaEntityID(rawValue: id.rawValue) == id) + } + } + + // `UUID(uuidString:)` accepts lowercase, so identifiers survive a case change. + @Test func parsesLowercaseInstance() { + let id = MediaEntityID(rawValue: "6f1c2a3b-4d5e-4f60-8172-93a4b5c6d7e8:42") + + #expect(id == MediaEntityID(42, instance)) + } + + @Test(arguments: [ + "", + ":", + "42", + "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:", + ":42", + "42:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "not-a-uuid:42", + "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:abc", + "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:42:extra", + "movie:42:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + ]) + func rejectsInvalidInput(_ rawValue: String) { + #expect(MediaEntityID(rawValue: rawValue) == nil) + } +} + +struct SpotlightItemIDTests { + private let instance = "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8" + + @Test func buildsShippedFormat() { + #expect(SpotlightItemID(.movie, 42, instance).rawValue == "movie:42:\(instance)") + #expect(SpotlightItemID(.series, 7, instance).rawValue == "series:7:\(instance)") + } + + // Items of media without an instance were indexed with an empty trailing component. + @Test func buildsShippedFormatWithoutInstance() { + #expect(SpotlightItemID(.movie, 42, nil).rawValue == "movie:42:") + } + + @Test func parsesShippedFormat() { + let movie = SpotlightItemID(rawValue: "movie:42:\(instance)") + let series = SpotlightItemID(rawValue: "series:7:\(instance)") + + #expect(movie == SpotlightItemID(.movie, 42, instance)) + #expect(series == SpotlightItemID(.series, 7, instance)) + } + + @Test func parsesMissingInstanceAsNil() { + #expect(SpotlightItemID(rawValue: "movie:42:") == SpotlightItemID(.movie, 42, nil)) + } + + @Test func roundTrips() { + for item in [ + SpotlightItemID(.movie, 42, instance), + SpotlightItemID(.series, 100_123, instance), + SpotlightItemID(.movie, 1, nil), + ] { + #expect(SpotlightItemID(rawValue: item.rawValue) == item) + } + } + + @Test(arguments: [ + "", + "movie", + "movie:42", + "episode:42:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "Movie:42:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "movie:abc:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "movie::6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8", + "movie:42:6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:extra", + "6F1C2A3B-4D5E-4F60-8172-93A4B5C6D7E8:42", + ]) + func rejectsInvalidInput(_ rawValue: String) { + #expect(SpotlightItemID(rawValue: rawValue) == nil) + } +} From befe62d9e0bb793d0097544ef0d76d9d771982d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Wed, 7 Oct 2026 10:15:01 -0700 Subject: [PATCH 2/4] Annotate detail screens with entity identifiers on all architectures --- Ruddarr/Views/Movies/MovieView.swift | 5 +---- Ruddarr/Views/Series/SeriesItemView.swift | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Ruddarr/Views/Movies/MovieView.swift b/Ruddarr/Views/Movies/MovieView.swift index 618a2d74..225ae135 100644 --- a/Ruddarr/Views/Movies/MovieView.swift +++ b/Ruddarr/Views/Movies/MovieView.swift @@ -20,10 +20,7 @@ struct MovieView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) - // Unavailable when compiling for Intel Macs - #if arch(arm64) - .appEntityIdentifier(MovieEntity.entityIdentifier(movie)) - #endif + .appEntityIdentifier(MovieEntity.entityIdentifier(movie)) } .scrollContainerMargins() .refreshable { diff --git a/Ruddarr/Views/Series/SeriesItemView.swift b/Ruddarr/Views/Series/SeriesItemView.swift index 9a75e007..c12b2a23 100644 --- a/Ruddarr/Views/Series/SeriesItemView.swift +++ b/Ruddarr/Views/Series/SeriesItemView.swift @@ -22,10 +22,7 @@ struct SeriesDetailView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) - // Unavailable when compiling for Intel Macs - #if arch(arm64) - .appEntityIdentifier(SeriesEntity.entityIdentifier(series)) - #endif + .appEntityIdentifier(SeriesEntity.entityIdentifier(series)) } .scrollContainerMargins() .refreshable { From b6c644102c9873003255a10b2f457cde9feb6e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Wed, 4 Nov 2026 10:09:54 -0700 Subject: [PATCH 3/4] Show entity titles without looking them up in the string catalog LocalizedStringResource(stringLiteral:) treats a movie or series title as a catalog key, so a title that matches a key (e.g. "Missing") would show its translation. Interpolating it uses the "%@" key with the title as argument. --- Ruddarr/Localizable.xcstrings | 3 +++ Ruddarr/Services/Entities.swift | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index a988353f..dc21bf66 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -22,6 +22,9 @@ } } } + }, + "%@" : { + }, "%@ at %@" : { "comment" : "(Today/Tomorrow/Yesterday) at (time)", diff --git a/Ruddarr/Services/Entities.swift b/Ruddarr/Services/Entities.swift index b6bef14e..2c7207c8 100644 --- a/Ruddarr/Services/Entities.swift +++ b/Ruddarr/Services/Entities.swift @@ -65,8 +65,8 @@ struct MovieEntity: IndexedEntity { var displayRepresentation: DisplayRepresentation { DisplayRepresentation( - title: LocalizedStringResource(stringLiteral: title), - subtitle: year > 0 ? LocalizedStringResource(stringLiteral: String(year)) : nil + title: "\(title)", + subtitle: year > 0 ? "\(String(year))" : nil ) } @@ -216,8 +216,8 @@ struct SeriesEntity: IndexedEntity { var displayRepresentation: DisplayRepresentation { DisplayRepresentation( - title: LocalizedStringResource(stringLiteral: title), - subtitle: year > 0 ? LocalizedStringResource(stringLiteral: String(year)) : nil + title: "\(title)", + subtitle: year > 0 ? "\(String(year))" : nil ) } From 9bdfe96d9fea8fb13e50051d839e7e3372366005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Wed, 4 Nov 2026 10:18:53 -0700 Subject: [PATCH 4/4] Add the iOS 27 Siri search intent and share on-screen movies and series - One .system.searchInApp intent covers movies and TV: the system allows a single one per app and only passes the search term, so it searches series when the Series tab is selected and movies otherwise, on the same lookup screens as the existing Search intents - Movie and series entities export their title, year and overview as plain text, so Siri can use the movie or series on screen --- CHANGELOG.md | 3 ++- Ruddarr/Services/Entities.swift | 38 +++++++++++++++++++++++++++++++++ Ruddarr/Services/Intents.swift | 21 ++++++++++++++++++ TestFlight/WhatToTest.en-US.txt | 3 ++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c9a95a..04d487ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search -- Open movies and series from Shortcuts and Siri on iOS 27 +- Open and search movies and series from Shortcuts and Siri on iOS 27 +- Ask Siri about the movie or series on screen ## 2.1.2 - 2026-11-02 ### Fixed diff --git a/Ruddarr/Services/Entities.swift b/Ruddarr/Services/Entities.swift index 2c7207c8..6daa2331 100644 --- a/Ruddarr/Services/Entities.swift +++ b/Ruddarr/Services/Entities.swift @@ -1,5 +1,7 @@ import Foundation import AppIntents +import CoreTransferable +import UniformTypeIdentifiers struct MovieEntity: IndexedEntity { static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Movie") @@ -8,6 +10,7 @@ struct MovieEntity: IndexedEntity { let id: String let movieId: Movie.ID let instanceId: Instance.ID + let overview: String? @Property(title: "Title") var title: String @@ -49,6 +52,7 @@ struct MovieEntity: IndexedEntity { self.id = MediaEntityID(movie.id, instance.id).rawValue self.movieId = movie.id self.instanceId = instance.id + self.overview = movie.overview self.title = movie.title self.year = movie.year self.status = movie.status.label @@ -75,6 +79,22 @@ struct MovieEntity: IndexedEntity { EntityIdentifier(for: Self.self, identifier: MediaEntityID(movie.id, $0).rawValue) } } + + var summary: String { + let heading = year > 0 ? "\(title) (\(year))" : title + + guard let overview, !overview.isEmpty else { + return heading + } + + return "\(heading)\n\n\(overview)" + } +} + +extension MovieEntity: Transferable { + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .plainText) { Data($0.summary.utf8) } + } } struct MovieEntityQuery: EntityStringQuery { @@ -151,6 +171,7 @@ struct SeriesEntity: IndexedEntity { let id: String let seriesId: Series.ID let instanceId: Instance.ID + let overview: String? @Property(title: "Title") var title: String @@ -198,6 +219,7 @@ struct SeriesEntity: IndexedEntity { self.id = MediaEntityID(series.id, instance.id).rawValue self.seriesId = series.id self.instanceId = instance.id + self.overview = series.overview self.title = series.title self.year = series.year self.status = series.status.label @@ -226,6 +248,22 @@ struct SeriesEntity: IndexedEntity { EntityIdentifier(for: Self.self, identifier: MediaEntityID(series.id, $0).rawValue) } } + + var summary: String { + let heading = year > 0 ? "\(title) (\(year))" : title + + guard let overview, !overview.isEmpty else { + return heading + } + + return "\(heading)\n\n\(overview)" + } +} + +extension SeriesEntity: Transferable { + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .plainText) { Data($0.summary.utf8) } + } } struct SeriesEntityQuery: EntityStringQuery { diff --git a/Ruddarr/Services/Intents.swift b/Ruddarr/Services/Intents.swift index e1a1bdf9..dbe423e9 100644 --- a/Ruddarr/Services/Intents.swift +++ b/Ruddarr/Services/Intents.swift @@ -116,4 +116,25 @@ struct OpenSeriesIntent: OpenIntent { return .result() } } + +@available(iOS 27.0, macOS 27.0, *) +@AppIntent(schema: .system.searchInApp) +struct SearchInAppIntent: ShowInAppSearchResultsIntent { + static let searchScopes: [StringSearchScope] = [.movies, .tv] + + var criteria: StringSearchCriteria + + @MainActor + func perform() async throws -> some IntentResult { + let query = criteria.term.trimmed() + + if dependencies.router.selectedTab == .series { + dependencies.quickActions.openSeriesSearch(query) + } else { + dependencies.quickActions.openMovieSearch(query) + } + + return .result() + } +} #endif diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index cb9e9ed3..ea05759b 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -5,4 +5,5 @@ Added - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search -- Open movies and series from Shortcuts and Siri on iOS 27 +- Open and search movies and series from Shortcuts and Siri on iOS 27 +- Ask Siri about the movie or series on screen