diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b6209e..04d487ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +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 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.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..dc21bf66 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -22,6 +22,9 @@ } } } + }, + "%@" : { + }, "%@ at %@" : { "comment" : "(Today/Tomorrow/Yesterday) at (time)", @@ -1652,6 +1655,9 @@ } } } + }, + "Downloaded Episodes" : { + }, "Downloading" : { "comment" : "(Short) State of task in queue\nState of task in queue\nStatus of task in queue", @@ -2239,6 +2245,9 @@ } } } + }, + "IMDb ID" : { + }, "Import" : { "comment" : "(Short) Importing a queue task", @@ -5037,6 +5046,9 @@ } } } + }, + "Size on Disk" : { + }, "Slow Instance" : { "localizations" : { @@ -5433,6 +5445,9 @@ } } } + }, + "TMDB ID" : { + }, "To monitor a season or episode, the series itself must be monitored." : { "localizations" : { @@ -5525,6 +5540,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..6daa2331 --- /dev/null +++ b/Ruddarr/Services/Entities.swift @@ -0,0 +1,334 @@ +import Foundation +import AppIntents +import CoreTransferable +import UniformTypeIdentifiers + +struct MovieEntity: IndexedEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Movie") + static let defaultQuery = MovieEntityQuery() + + let id: String + let movieId: Movie.ID + let instanceId: Instance.ID + let overview: String? + + @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.overview = movie.overview + 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: "\(title)", + subtitle: year > 0 ? "\(String(year))" : nil + ) + } + + static func entityIdentifier(_ movie: Movie) -> EntityIdentifier? { + movie.instanceId.map { + 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 { + 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 + let overview: String? + + @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.overview = series.overview + 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: "\(title)", + subtitle: year > 0 ? "\(String(year))" : nil + ) + } + + static func entityIdentifier(_ series: Series) -> EntityIdentifier? { + series.instanceId.map { + 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 { + 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..dbe423e9 100644 --- a/Ruddarr/Services/Intents.swift +++ b/Ruddarr/Services/Intents.swift @@ -88,3 +88,53 @@ 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() + } +} + +@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/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..225ae135 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,7 @@ struct MovieView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) + .appEntityIdentifier(MovieEntity.entityIdentifier(movie)) } .scrollContainerMargins() .refreshable { diff --git a/Ruddarr/Views/Series/SeriesItemView.swift b/Ruddarr/Views/Series/SeriesItemView.swift index add61d27..c12b2a23 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,7 @@ struct SeriesDetailView: View { .padding(.top) .containerHorizontalMargins() .environment(settings) + .appEntityIdentifier(SeriesEntity.entityIdentifier(series)) } .scrollContainerMargins() .refreshable { diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index ccca875f..ea05759b 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -5,3 +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 and search movies and series from Shortcuts and Siri on iOS 27 +- Ask Siri about the movie or series on screen 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) + } +}