diff --git a/Lume/Localizable.xcstrings b/Lume/Localizable.xcstrings index 1d6db93..c4895e1 100644 --- a/Lume/Localizable.xcstrings +++ b/Lume/Localizable.xcstrings @@ -708,6 +708,16 @@ } } }, + "Browse by Provider" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach Anbieter" + } + } + } + }, "Cached artwork and metadata are re-downloaded automatically when needed. Your playlists, downloads, watch history and favorites are not affected." : { "localizations" : { "de" : { @@ -2597,6 +2607,16 @@ } } }, + "Loading providers…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbieter werden geladen…" + } + } + } + }, "Lock a category to hide it from child profiles. Drag to reorder. Reset restores the playlist's order and shows everything." : { "localizations" : { "de" : { @@ -3243,6 +3263,16 @@ } } }, + "No movies on this provider yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch keine Filme bei diesem Anbieter" + } + } + } + }, "No Playlist" : { "localizations" : { "de" : { @@ -3285,6 +3315,16 @@ } } }, + "No providers loaded yet." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch keine Anbieter geladen." + } + } + } + }, "No Series" : { "localizations" : { "de" : { @@ -3315,6 +3355,16 @@ } } }, + "No series on this provider yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch keine Serien bei diesem Anbieter" + } + } + } + }, "None" : { "comment" : "A placeholder for the EPG URL of a playlist.", "isCommentAutoGenerated" : true, @@ -3595,6 +3645,16 @@ } } }, + "Pick the streaming services you subscribe to. The Movies and Series tabs add a section for each, grouping titles available on that service in your region." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle die Streaming-Dienste aus, die du abonniert hast. Die Tabs „Filme“ und „Serien“ zeigen für jeden einen Bereich mit den in deiner Region dort verfügbaren Titeln." + } + } + } + }, "Picture in Picture" : { "localizations" : { "de" : { @@ -3856,6 +3916,16 @@ } } }, + "Providers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbieter" + } + } + } + }, "QR code" : { "localizations" : { "de" : { @@ -4666,6 +4736,26 @@ } } }, + "Streaming Providers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming-Anbieter" + } + } + } + }, + "Streaming providers are unavailable because TMDB is not configured in this build." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming-Anbieter sind nicht verfügbar, da TMDB in diesem Build nicht konfiguriert ist." + } + } + } + }, "Streams open in the selected app instead of Lume's player. Downloads always play in Lume, and the built-in player is used when the app is not installed." : { "localizations" : { "de" : { diff --git a/Lume/LumeApp.swift b/Lume/LumeApp.swift index bbfc533..4bd59a7 100644 --- a/Lume/LumeApp.swift +++ b/Lume/LumeApp.swift @@ -65,7 +65,8 @@ struct LumeApp: App { let cloud = makeCloudContainer() let catalogSchema = Schema([ Playlist.self, Category.self, LiveStream.self, Movie.self, - Series.self, Episode.self, CastMember.self, EPGListing.self, EPGSource.self + Series.self, Episode.self, CastMember.self, EPGListing.self, EPGSource.self, + WatchProvider.self ]) // Unnamed → keeps the historical `default.store` path (preserves data). // `cloudKitDatabase: .none` is REQUIRED: the default is `.automatic`, which diff --git a/Lume/Models/Movie.swift b/Lume/Models/Movie.swift index 60d77d8..5a68231 100644 --- a/Lume/Models/Movie.swift +++ b/Lume/Models/Movie.swift @@ -59,6 +59,16 @@ final class Movie { @Relationship(deleteRule: .cascade, inverse: \CastMember.movie) var castMembers: [CastMember] = [] + // MARK: Watch providers (TMDB flatrate streaming services for the user's region) + + /// Pipe-delimited flatrate watch-provider ids (e.g. `|8|337|`), set during + /// TMDB enrichment. Stored as a string so a `localizedStandardContains` + /// predicate can group titles by provider in SQLite. Access through + /// `watchProviderIds`. + var watchProviderIdsRaw: String? + /// When watch providers were last resolved from TMDB; nil means never. + var watchProvidersEnrichedAt: Date? + // MARK: External ratings (OMDb, keyed by IMDb id — lazy-fetched on detail) /// IMDb id (e.g. `tt3896198`), resolved from TMDB's external ids. Required @@ -160,6 +170,13 @@ extension Movie { castMembers.sorted { $0.order < $1.order } } + /// Flatrate watch-provider ids for the user's region, in TMDB display order. + /// Backed by `watchProviderIdsRaw`. + var watchProviderIds: [Int] { + get { WatchProviderIDs.decode(watchProviderIdsRaw) } + set { watchProviderIdsRaw = WatchProviderIDs.encode(newValue) } + } + /// External aggregator ratings, in display order. Backed by /// `externalRatingsData` so SwiftData persists it as a plain `Data` blob. var externalRatings: [ExternalRating] { diff --git a/Lume/Models/Series.swift b/Lume/Models/Series.swift index 232b2a0..179abef 100644 --- a/Lume/Models/Series.swift +++ b/Lume/Models/Series.swift @@ -51,6 +51,16 @@ final class Series { @Relationship(deleteRule: .cascade, inverse: \CastMember.series) var castMembers: [CastMember] = [] + // MARK: Watch providers (TMDB flatrate streaming services for the user's region) + + /// Pipe-delimited flatrate watch-provider ids (e.g. `|8|337|`), set during + /// TMDB enrichment. Stored as a string so a `localizedStandardContains` + /// predicate can group titles by provider in SQLite. Access through + /// `watchProviderIds`. + var watchProviderIdsRaw: String? + /// When watch providers were last resolved from TMDB; nil means never. + var watchProvidersEnrichedAt: Date? + // MARK: External ratings (OMDb, keyed by IMDb id — lazy-fetched on detail) /// IMDb id (e.g. `tt0944947`), resolved from TMDB's external ids. Required @@ -132,6 +142,13 @@ extension Series { castMembers.sorted { $0.order < $1.order } } + /// Flatrate watch-provider ids for the user's region, in TMDB display order. + /// Backed by `watchProviderIdsRaw`. + var watchProviderIds: [Int] { + get { WatchProviderIDs.decode(watchProviderIdsRaw) } + set { watchProviderIdsRaw = WatchProviderIDs.encode(newValue) } + } + /// External aggregator ratings, in display order. Backed by /// `externalRatingsData` so SwiftData persists it as a plain `Data` blob. var externalRatings: [ExternalRating] { diff --git a/Lume/Models/WatchProvider.swift b/Lume/Models/WatchProvider.swift new file mode 100644 index 0000000..da60394 --- /dev/null +++ b/Lume/Models/WatchProvider.swift @@ -0,0 +1,76 @@ +// +// WatchProvider.swift +// Lume +// +// Streaming-service ("watch provider") support. TMDB exposes which subscription +// services a title is available on in a given country; Lume groups the library +// by those services. The per-title `flatrate` provider ids live on Movie/Series +// as a delimited string (see `WatchProviderIDs`); this catalog holds the +// display metadata (name + logo) for the region's providers, used by the +// Settings picker and the browse tiles. +// + +import Foundation +import SwiftData + +/// A streaming service in the local catalog, populated from TMDB's +/// region-scoped watch-provider list. Local-only, like the rest of the catalog. +@Model +final class WatchProvider { + @Attribute(.unique) var id: Int + var name: String + /// TMDB relative logo path (e.g. `/abc.jpg`); render with + /// `TMDBClient.providerLogoURL(_:)`. + var logoPath: String? + /// TMDB display priority for the region; lower sorts first. + var displayPriority: Int + + init(id: Int, name: String, logoPath: String? = nil, displayPriority: Int = .max) { + self.id = id + self.name = name + self.logoPath = logoPath + self.displayPriority = displayPriority + } +} + +/// Encodes/decodes the set of watch-provider ids stored on a title. +/// +/// SwiftData can't query into a stored `[Int]`, so the ids are persisted as a +/// pipe-delimited string with sentinels (`|8|337|`). The sentinels let a +/// `localizedStandardContains("|8|")` predicate narrow rows in SQLite without +/// `|8|` matching `|80|` — the same trick `GenreParser` uses for genre tokens. +enum WatchProviderIDs { + /// `nil` for an empty list so the column stays clean and the + /// `watchProviderIdsRaw != nil` derivation fetch skips un-enriched rows. + static func encode(_ ids: [Int]) -> String? { + var seen = Set() + let ordered = ids.filter { seen.insert($0).inserted } + guard !ordered.isEmpty else { return nil } + return "|" + ordered.map(String.init).joined(separator: "|") + "|" + } + + static func decode(_ raw: String?) -> [Int] { + guard let raw else { return [] } + return raw.split(separator: "|").compactMap { Int($0) } + } + + /// The SQLite-narrowing token for a single provider id (`|8|`). + static func queryToken(for id: Int) -> String { + "|\(id)|" + } + + /// Whether `raw` carries `id` as a whole token. Used to re-filter a + /// `localizedStandardContains` fetch down to exact matches. + static func contains(_ raw: String?, id: Int) -> Bool { + decode(raw).contains(id) + } +} + +/// A watch-provider browse destination, carried as a navigation value so the +/// Movies and Series tabs can each register a `navigationDestination` for it. +/// Mirrors `GenreSelection`. +struct WatchProviderSelection: Hashable { + let providerId: Int + let name: String + let type: CategoryType +} diff --git a/Lume/Services/Catalog/WatchProviderSettings.swift b/Lume/Services/Catalog/WatchProviderSettings.swift new file mode 100644 index 0000000..d991c73 --- /dev/null +++ b/Lume/Services/Catalog/WatchProviderSettings.swift @@ -0,0 +1,44 @@ +// +// WatchProviderSettings.swift +// Lume +// +// The user's chosen streaming services. Only the providers selected here are +// surfaced as "Browse by Provider" sections on the Movies and Series tabs. +// Backed by UserDefaults — a small set of scalar ids, not structured data. +// + +import Foundation +import SwiftUI + +@MainActor +@Observable +final class WatchProviderSettings { + static let shared = WatchProviderSettings() + + private static let storageKey = "content.watchProviders.selected" + + /// The TMDB provider ids the user wants to browse by. + private(set) var selectedIDs: Set + + private init() { + let raw = UserDefaults.standard.string(forKey: Self.storageKey) + selectedIDs = Set(WatchProviderIDs.decode(raw)) + } + + func isSelected(_ id: Int) -> Bool { + selectedIDs.contains(id) + } + + func toggle(_ id: Int) { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + persist() + } + + private func persist() { + UserDefaults.standard.set(WatchProviderIDs.encode(Array(selectedIDs)), forKey: Self.storageKey) + } +} diff --git a/Lume/Services/Network/TMDBClient+WatchProviders.swift b/Lume/Services/Network/TMDBClient+WatchProviders.swift new file mode 100644 index 0000000..edbf8aa --- /dev/null +++ b/Lume/Services/Network/TMDBClient+WatchProviders.swift @@ -0,0 +1,102 @@ +// +// TMDBClient+WatchProviders.swift +// Lume +// +// Watch-provider (streaming service) decoding for TMDB. The per-title flatrate +// offers ride along on the title-detail requests via `append_to_response`; the +// region's full provider list (for the Settings picker) comes from a dedicated +// endpoint. Split out of TMDBClient.swift to keep that file within the size cap. +// + +import Foundation + +// MARK: - Requests + +nonisolated extension TMDBClient { + /// The ISO 3166-1 region code (e.g. `US`, `DE`) for the user's current + /// locale, used as the `watch_region` for watch-provider lookups. Falls + /// back to `US` when the locale carries no region. + static func preferredRegionCode() -> String { + Locale.current.region?.identifier ?? "US" + } + + /// Builds a full watch-provider logo URL from a TMDB relative path. Provider + /// logos are small square marks (e.g. the Netflix "N"), so a modest size + /// keeps the browse tiles crisp without over-fetching. + static func providerLogoURL(_ path: String?, size: String = "w92") -> URL? { + guard let path, !path.isEmpty else { return nil } + return URL(string: imageBaseURL + size + path) + } + + /// The streaming services available in the user's region for the given media + /// type, ordered by TMDB's display priority. Powers the watch-provider + /// picker in Settings and supplies the names/logos the browse tiles render. + func watchProviderList(_ media: MediaType) async throws -> [WatchProviderInfo] { + let response: WatchProviderListResponse = try await get( + "/watch/providers/\(media.rawValue)?watch_region=\(region)" + ) + return response.results + .map { + WatchProviderInfo( + id: $0.providerId, + name: $0.providerName ?? "", + logoPath: $0.logoPath, + displayPriority: $0.displayPriorities?[region] ?? $0.displayPriority ?? .max + ) + } + .filter { !$0.name.isEmpty } + .sorted { $0.displayPriority < $1.displayPriority } + } +} + +// MARK: - Public types + +/// A streaming service from TMDB's watch-providers list, used to populate the +/// local provider catalog the picker and browse tiles read. +nonisolated struct WatchProviderInfo: Hashable { + let id: Int + let name: String + let logoPath: String? + let displayPriority: Int +} + +// MARK: - DTOs + +/// TMDB's appended `watch/providers` payload: per-country offers keyed by ISO +/// 3166-1 region. Only the `flatrate` (subscription) offers are decoded. +nonisolated struct WatchProvidersEntry: Decodable { + let results: [String: WatchProviderCountryOffers] +} + +nonisolated struct WatchProviderCountryOffers: Decodable { + let flatrate: [WatchProviderOffer]? +} + +nonisolated struct WatchProviderOffer: Decodable { + let providerId: Int + let displayPriority: Int? + enum CodingKeys: String, CodingKey { + case providerId = "provider_id" + case displayPriority = "display_priority" + } +} + +/// `/watch/providers/{movie,tv}` — the full provider list for a region. +nonisolated struct WatchProviderListResponse: Decodable { + let results: [WatchProviderListEntry] +} + +nonisolated struct WatchProviderListEntry: Decodable { + let providerId: Int + let providerName: String? + let logoPath: String? + let displayPriority: Int? + let displayPriorities: [String: Int]? + enum CodingKeys: String, CodingKey { + case providerId = "provider_id" + case providerName = "provider_name" + case logoPath = "logo_path" + case displayPriority = "display_priority" + case displayPriorities = "display_priorities" + } +} diff --git a/Lume/Services/Network/TMDBClient.swift b/Lume/Services/Network/TMDBClient.swift index 00a5998..35f47cf 100644 --- a/Lume/Services/Network/TMDBClient.swift +++ b/Lume/Services/Network/TMDBClient.swift @@ -35,7 +35,9 @@ nonisolated struct TMDBClient { } private let baseURL = "https://api.themoviedb.org/3" - private static let imageBaseURL = "https://image.tmdb.org/t/p/" + /// Not `private`: the watch-providers extension (separate file) builds + /// provider-logo URLs from it. + static let imageBaseURL = "https://image.tmdb.org/t/p/" private let session: URLSession private let token: String? @@ -53,14 +55,20 @@ nonisolated struct TMDBClient { String(language.prefix { $0 != "-" }) } + /// ISO 3166-1 region (e.g. `US`, `DE`) for watch-provider lookups, from the + /// user's locale. Not `private`: read by the watch-providers extension. + let region: String + init( session: URLSession = .shared, token: String? = TMDBClient.tokenFromBundle(), - language: String = TMDBClient.preferredLanguageCode() + language: String = TMDBClient.preferredLanguageCode(), + region: String = TMDBClient.preferredRegionCode() ) { self.session = session self.token = token self.language = language + self.region = region } /// Whether a usable token is present. When false the trending section is @@ -185,19 +193,19 @@ nonisolated struct TMDBClient { /// US content rating folded in via `append_to_response`. func movieDetails(_ id: Int) async throws -> TMDBTitleDetails { let response: TitleDetailsResponse = try await get( - "/movie/\(id)?append_to_response=credits,similar,release_dates,images,videos,external_ids" + "/movie/\(id)?append_to_response=credits,similar,release_dates,images,videos,external_ids,watch/providers" + imageAndVideoLanguageQuery ) - return response.normalized(isMovie: true, preferredLanguage: languageCode) + return response.normalized(isMovie: true, preferredLanguage: languageCode, region: region) } /// Full detail payload for a TV series. func tvDetails(_ id: Int) async throws -> TMDBTitleDetails { let response: TitleDetailsResponse = try await get( - "/tv/\(id)?append_to_response=credits,similar,content_ratings,images,videos,external_ids" + "/tv/\(id)?append_to_response=credits,similar,content_ratings,images,videos,external_ids,watch/providers" + imageAndVideoLanguageQuery ) - return response.normalized(isMovie: false, preferredLanguage: languageCode) + return response.normalized(isMovie: false, preferredLanguage: languageCode, region: region) } /// Widens the appended `images`/`videos` to the user's language, English, @@ -251,7 +259,9 @@ nonisolated struct TMDBClient { // MARK: - Networking - private func get(_ path: String) async throws -> T { + /// Not `private`: the watch-providers extension (separate file) issues its + /// list request through it. + func get(_ path: String) async throws -> T { guard isConfigured, let token else { throw TMDBError.missingToken } let localizedPath = TMDBClient.pathWithLanguage(path, language: language) guard let url = URL(string: baseURL + localizedPath) else { throw TMDBError.invalidURL } @@ -306,6 +316,9 @@ nonisolated struct TMDBTitleDetails { var logoPath: String? /// IMDb id (e.g. `tt3896198`), used to query OMDb for aggregator ratings. var imdbId: String? + /// TMDB ids of the `flatrate` streaming services the title is on in the + /// user's region, in display-priority order (rent/buy offers are ignored). + var watchProviderIds: [Int] = [] /// Collection this movie belongs to (only for movies, nil for series). var collectionId: Int? @@ -368,6 +381,7 @@ private nonisolated struct TitleDetailsResponse: Decodable { let images: ImagesEntry? let imdbId: String? // movies expose it top-level let externalIds: ExternalIds? // tv (and movies) expose it under external_ids + let watchProviders: WatchProvidersEntry? // appended under "watch/providers" struct Genre: Decodable { let name: String } struct Credits: Decodable { let cast: [TMDBCastMemberDTO]? } @@ -384,6 +398,7 @@ private nonisolated struct TitleDetailsResponse: Decodable { case belongsToCollection = "belongs_to_collection" case imdbId = "imdb_id" case externalIds = "external_ids" + case watchProviders = "watch/providers" } } @@ -480,7 +495,7 @@ private nonisolated struct CollectionPart: Decodable { } nonisolated extension TitleDetailsResponse { - func normalized(isMovie: Bool, preferredLanguage: String = "en") -> TMDBTitleDetails { + func normalized(isMovie: Bool, preferredLanguage: String = "en", region: String = "US") -> TMDBTitleDetails { let cast = (credits?.cast ?? []) .sorted { ($0.order ?? .max) < ($1.order ?? .max) } .prefix(20) @@ -507,6 +522,8 @@ nonisolated extension TitleDetailsResponse { videos: mappedVideos(), logoPath: bestLogoPath(preferredLanguage: preferredLanguage), imdbId: resolvedIMDbId(), + watchProviderIds: (watchProviders?.results[region]?.flatrate ?? []) + .sorted { ($0.displayPriority ?? .max) < ($1.displayPriority ?? .max) }.map(\.providerId), collectionId: isMovie ? belongsToCollection?.id : nil, collectionName: isMovie ? belongsToCollection?.name : nil, collectionPosterPath: isMovie ? belongsToCollection?.posterPath : nil, diff --git a/Lume/Services/Sync/ContentSyncManager+TMDB.swift b/Lume/Services/Sync/ContentSyncManager+TMDB.swift index bd1ed13..cc6a7a6 100644 --- a/Lume/Services/Sync/ContentSyncManager+TMDB.swift +++ b/Lume/Services/Sync/ContentSyncManager+TMDB.swift @@ -57,6 +57,22 @@ extension ContentSyncManager { guard client.isConfigured else { return [] } return try await client.collectionMovieIDs(collectionId) } + + /// The streaming services available in the user's region, merging the movie + /// and TV provider lists (most services carry both) and de-duplicating by id. + /// Used to populate the local `WatchProvider` catalog for the picker. + func fetchWatchProviderList() async -> [WatchProviderInfo] { + let client = TMDBClient.shared + guard client.isConfigured else { return [] } + async let moviesTask = client.watchProviderList(.movie) + async let showsTask = client.watchProviderList(.tvShow) + let combined = await ((try? moviesTask) ?? []) + ((try? showsTask) ?? []) + var byId: [Int: WatchProviderInfo] = [:] + for provider in combined where byId[provider.id] == nil { + byId[provider.id] = provider + } + return byId.values.sorted { $0.displayPriority < $1.displayPriority } + } } // MARK: - Direct context apply @@ -111,6 +127,13 @@ nonisolated func applyMovieDetails( movie.collectionBackdropPath = details.collectionBackdropPath } + // Set before the cast guard so the background indexer's partial pass still + // populates watch providers across the whole catalog (browse-by-provider + // needs them ahead of any detail open) — no extra API call, they ride along + // on the details fetch the indexer already makes. + movie.watchProviderIds = details.watchProviderIds + movie.watchProvidersEnrichedAt = Date() + guard includeCast else { return } replaceCast(of: movie.castMembers, with: details.cast, ownerId: movie.id, context: context) { castMember in castMember.movie = movie @@ -159,6 +182,12 @@ nonisolated func applySeriesDetails( series.rating = String(format: "%.1f", vote) } + // Set before the cast guard so the background indexer's partial pass still + // populates watch providers across the whole catalog — see + // `applyMovieDetails` for why. + series.watchProviderIds = details.watchProviderIds + series.watchProvidersEnrichedAt = Date() + guard includeCast else { return } replaceCast(of: series.castMembers, with: details.cast, ownerId: series.id, context: context) { castMember in castMember.series = series diff --git a/Lume/Views/Components/WatchProviderBrowse.swift b/Lume/Views/Components/WatchProviderBrowse.swift new file mode 100644 index 0000000..75312c1 --- /dev/null +++ b/Lume/Views/Components/WatchProviderBrowse.swift @@ -0,0 +1,269 @@ +// +// WatchProviderBrowse.swift +// Lume +// +// "Browse by Provider" on the Movies and Series tabs. A title's streaming +// services come from TMDB (the `flatrate` offers for the user's region, stored +// on `Movie`/`Series` as `watchProviderIdsRaw`), so these surfaces cut across +// the provider categories — mirroring the genre browse. Only the services the +// user selected in Settings, and that actually have content, are shown. +// + +import SwiftData +import SwiftUI + +// MARK: - Derivation + +/// Upper bound on the fetch that enumerates which providers appear in a +/// playlist. Matches `GenreDerivation`'s sampling trade-off: the provider +/// vocabulary is tiny, so a bounded sample surfaces every present provider while +/// keeping the derivation cheap on large libraries. +private let providerSampleLimit = 5000 + +enum WatchProviderDerivation { + /// The selected providers that actually have movies in the active playlist, + /// resolved to catalog rows (for name + logo) and ordered by display + /// priority. Empty when nothing is selected or no enriched titles match. + static func movieProviders( + in context: ModelContext, + playlistPrefix: String, + restriction: ContentRestriction, + selected: Set + ) -> [WatchProvider] { + guard !selected.isEmpty else { return [] } + var descriptor = FetchDescriptor(predicate: #Predicate { $0.watchProviderIdsRaw != nil }) + descriptor.fetchLimit = providerSampleLimit + let movies = ((try? context.fetch(descriptor)) ?? []) + .filter { $0.id.hasPrefix(playlistPrefix) } + .excludingRestricted(restriction) + return resolve(present: presentIDs(in: movies.map(\.watchProviderIdsRaw)), selected: selected, in: context) + } + + /// Series counterpart of ``movieProviders(in:playlistPrefix:restriction:selected:)``. + static func seriesProviders( + in context: ModelContext, + playlistPrefix: String, + restriction: ContentRestriction, + selected: Set + ) -> [WatchProvider] { + guard !selected.isEmpty else { return [] } + var descriptor = FetchDescriptor(predicate: #Predicate { $0.watchProviderIdsRaw != nil }) + descriptor.fetchLimit = providerSampleLimit + let series = ((try? context.fetch(descriptor)) ?? []) + .filter { $0.id.hasPrefix(playlistPrefix) } + .excludingRestricted(restriction) + return resolve(present: presentIDs(in: series.map(\.watchProviderIdsRaw)), selected: selected, in: context) + } + + private static func presentIDs(in raws: [String?]) -> Set { + var ids = Set() + for raw in raws { + ids.formUnion(WatchProviderIDs.decode(raw)) + } + return ids + } + + /// The catalog rows for the selected providers present in the library, + /// ordered by TMDB display priority then name. + private static func resolve(present: Set, selected: Set, in context: ModelContext) -> [WatchProvider] { + let show = present.intersection(selected) + guard !show.isEmpty else { return [] } + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.displayPriority), SortDescriptor(\.name)] + ) + return ((try? context.fetch(descriptor)) ?? []).filter { show.contains($0.id) } + } +} + +// MARK: - Browse-by-provider section + +/// A tile grid of the user's selected providers that have content in the active +/// playlist. Each tile shows the provider's logo and navigates to its full grid. +/// +/// Like `GenreGridSection`, the owning view derives the providers and renders +/// this only when non-empty — a view that collapses to nothing never receives +/// `.task`/`.onAppear`, so the derivation must live on the always-present host. +struct WatchProviderGridSection: View { + let providers: [WatchProvider] + let type: CategoryType + + private let columns = [GridItem(.adaptive(minimum: CategoryTileMetrics.minimum), spacing: CategoryTileMetrics.spacing)] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Browse by Provider") + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .padding(.horizontal) + + LazyVGrid(columns: columns, spacing: CategoryTileMetrics.spacing) { + ForEach(providers) { provider in + NavigationLink(value: WatchProviderSelection(providerId: provider.id, name: provider.name, type: type)) { + WatchProviderTile(provider: provider) + } + .posterCardButtonStyle() + } + } + .padding(.horizontal) + } + } +} + +/// A name tile fronted by the provider's logo. Mirrors `CategoryTile`'s flat +/// material look and tvOS focus brighten so it sits cleanly beside the genre +/// and category tiles. +struct WatchProviderTile: View { + let provider: WatchProvider + + #if os(tvOS) + @Environment(\.isFocused) private var isFocused + #endif + + var body: some View { + HStack(spacing: 12) { + CachedAsyncImage(url: TMDBClient.providerLogoURL(provider.logoPath), maxPixelSize: logoSize * 2) { phase in + switch phase { + case let .success(image): + image.resizable().aspectRatio(contentMode: .fit) + default: + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.gray.opacity(0.3)) + .overlay { Image(systemName: "play.tv").foregroundStyle(.secondary) } + } + } + .frame(width: logoSize, height: logoSize) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Text(provider.name) + .font(CategoryTileMetrics.font) + .fontWeight(.semibold) + .lineLimit(2) + .minimumScaleFactor(0.7) + .foregroundStyle(foreground) + + Spacer(minLength: 0) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: CategoryTileMetrics.height) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: CategoryTileMetrics.cornerRadius, style: .continuous)) + #if os(tvOS) + .animation(.easeOut(duration: 0.18), value: isFocused) + #endif + } + + private var logoSize: CGFloat { + #if os(tvOS) + 56 + #else + 36 + #endif + } + + private var foreground: Color { + #if os(tvOS) + isFocused ? .black : .primary + #else + .primary + #endif + } + + @ViewBuilder + private var background: some View { + #if os(tvOS) + Rectangle() + .fill(.ultraThinMaterial) + .overlay(Color.white.opacity(isFocused ? 1 : 0)) + #else + Rectangle().fill(.ultraThinMaterial) + #endif + } +} + +// MARK: - Provider detail grids + +/// The full grid of movies available on a provider. The fetch narrows to +/// candidate rows in SQLite via the sentinel token (`|8|`), then re-filters to +/// exact id matches in memory — the same shape as the genre grids. +struct MovieWatchProviderView: View { + let providerId: Int + let name: String + let playlistPrefix: String + var animationNamespace: Namespace.ID? + @Environment(\.modelContext) private var modelContext + @Environment(\.contentRestriction) private var restriction + + @AppStorage(SortStorageKey.movieContent) private var contentSortRaw: String = ContentSortOption.playlist.rawValue + @State private var movies: [Movie] = [] + + private var contentSort: ContentSortOption { + ContentSortOption(rawValue: contentSortRaw) ?? .playlist + } + + var body: some View { + CategoryContentGrid( + title: name, + items: movies, + animationNamespace: animationNamespace, + emptyTitle: "No Movies", + emptyIcon: "film.stack", + emptyDescription: "No movies on this provider yet", + sortRaw: $contentSortRaw, + card: { MovieCardView(movie: $0) } + ) + .task(id: contentSortRaw) { + let token = WatchProviderIDs.queryToken(for: providerId) + let descriptor = FetchDescriptor( + predicate: #Predicate { ($0.watchProviderIdsRaw?.localizedStandardContains(token)) ?? false }, + sortBy: contentSort.movieDescriptors + ) + let id = providerId + movies = ((try? modelContext.fetch(descriptor)) ?? []) + .filter { $0.id.hasPrefix(playlistPrefix) && WatchProviderIDs.contains($0.watchProviderIdsRaw, id: id) } + .excludingRestricted(restriction) + } + } +} + +/// The full grid of series available on a provider. +struct SeriesWatchProviderView: View { + let providerId: Int + let name: String + let playlistPrefix: String + var animationNamespace: Namespace.ID? + @Environment(\.modelContext) private var modelContext + @Environment(\.contentRestriction) private var restriction + + @AppStorage(SortStorageKey.seriesContent) private var contentSortRaw: String = ContentSortOption.playlist.rawValue + @State private var series: [Series] = [] + + private var contentSort: ContentSortOption { + ContentSortOption(rawValue: contentSortRaw) ?? .playlist + } + + var body: some View { + CategoryContentGrid( + title: name, + items: series, + animationNamespace: animationNamespace, + emptyTitle: "No Series", + emptyIcon: "tv.fill", + emptyDescription: "No series on this provider yet", + sortRaw: $contentSortRaw, + card: { SeriesCardView(series: $0) } + ) + .task(id: contentSortRaw) { + let token = WatchProviderIDs.queryToken(for: providerId) + let descriptor = FetchDescriptor( + predicate: #Predicate { ($0.watchProviderIdsRaw?.localizedStandardContains(token)) ?? false }, + sortBy: contentSort.seriesDescriptors + ) + let id = providerId + series = ((try? modelContext.fetch(descriptor)) ?? []) + .filter { $0.id.hasPrefix(playlistPrefix) && WatchProviderIDs.contains($0.watchProviderIdsRaw, id: id) } + .excludingRestricted(restriction) + } + } +} diff --git a/Lume/Views/Movies/MoviesView.swift b/Lume/Views/Movies/MoviesView.swift index 7a8bb94..623942b 100644 --- a/Lume/Views/Movies/MoviesView.swift +++ b/Lume/Views/Movies/MoviesView.swift @@ -21,6 +21,8 @@ struct MoviesView: View { @State private var showingSync = false @State private var showingSettings = false @State private var genres: [String] = [] + @State private var providers: [WatchProvider] = [] + @State private var providerSettings = WatchProviderSettings.shared @AppStorage(SortStorageKey.movieCategories) private var categorySortRaw: String = CategorySortOption.playlist.rawValue @AppStorage(SortStorageKey.movieContent) private var contentSortRaw: String = ContentSortOption.playlist.rawValue @@ -71,6 +73,10 @@ struct MoviesView: View { .id("\(category.id)-\(contentSort.rawValue)") } + if !providers.isEmpty { + WatchProviderGridSection(providers: providers, type: .vod) + } + if !genres.isEmpty { GenreGridSection(genres: genres, type: .vod) } @@ -85,6 +91,14 @@ struct MoviesView: View { .task(id: playlistPrefix) { genres = GenreDerivation.movieGenres(in: modelContext, playlistPrefix: playlistPrefix, restriction: restriction) } + .task(id: providerTaskID) { + providers = WatchProviderDerivation.movieProviders( + in: modelContext, + playlistPrefix: playlistPrefix, + restriction: restriction, + selected: providerSettings.selectedIDs + ) + } } } .platformNavigationTitle("Movies") @@ -107,6 +121,9 @@ struct MoviesView: View { .navigationDestination(for: GenreSelection.self) { selection in MovieGenreView(genre: selection.genre, playlistPrefix: playlistPrefix, animationNamespace: animationNamespace) } + .navigationDestination(for: WatchProviderSelection.self) { selection in + MovieWatchProviderView(providerId: selection.providerId, name: selection.name, playlistPrefix: playlistPrefix, animationNamespace: animationNamespace) + } .navigationDestination(for: Movie.self) { movie in MovieDetailView(movie: movie, animationNamespace: animationNamespace) #if os(iOS) @@ -128,6 +145,12 @@ struct MoviesView: View { activePlaylist.map { "\($0.id.uuidString)-" } ?? "" } + /// Re-derives the provider browse section when the playlist or the user's + /// provider selection changes. + private var providerTaskID: String { + "\(playlistPrefix)|\(providerSettings.selectedIDs.sorted().map(String.init).joined(separator: ","))" + } + /// Categories scoped to the active playlist. The `@Query` fetches every /// playlist's categories (SwiftData can't parameterize a `@Query` on view /// state), so we isolate by the playlist-prefixed category `id` here. diff --git a/Lume/Views/Series/SeriesView.swift b/Lume/Views/Series/SeriesView.swift index dad4224..f9220b5 100644 --- a/Lume/Views/Series/SeriesView.swift +++ b/Lume/Views/Series/SeriesView.swift @@ -21,6 +21,8 @@ struct SeriesView: View { @State private var showingSync = false @State private var showingSettings = false @State private var genres: [String] = [] + @State private var providers: [WatchProvider] = [] + @State private var providerSettings = WatchProviderSettings.shared @AppStorage(SortStorageKey.seriesCategories) private var categorySortRaw: String = CategorySortOption.playlist.rawValue @AppStorage(SortStorageKey.seriesContent) private var contentSortRaw: String = ContentSortOption.playlist.rawValue @@ -69,6 +71,10 @@ struct SeriesView: View { .id("\(category.id)-\(contentSort.rawValue)") } + if !providers.isEmpty { + WatchProviderGridSection(providers: providers, type: .series) + } + if !genres.isEmpty { GenreGridSection(genres: genres, type: .series) } @@ -83,6 +89,14 @@ struct SeriesView: View { .task(id: playlistPrefix) { genres = GenreDerivation.seriesGenres(in: modelContext, playlistPrefix: playlistPrefix, restriction: restriction) } + .task(id: providerTaskID) { + providers = WatchProviderDerivation.seriesProviders( + in: modelContext, + playlistPrefix: playlistPrefix, + restriction: restriction, + selected: providerSettings.selectedIDs + ) + } } } .platformNavigationTitle("Series") @@ -105,6 +119,9 @@ struct SeriesView: View { .navigationDestination(for: GenreSelection.self) { selection in SeriesGenreView(genre: selection.genre, playlistPrefix: playlistPrefix, animationNamespace: animationNamespace) } + .navigationDestination(for: WatchProviderSelection.self) { selection in + SeriesWatchProviderView(providerId: selection.providerId, name: selection.name, playlistPrefix: playlistPrefix, animationNamespace: animationNamespace) + } .navigationDestination(for: Series.self) { series in SeriesDetailView(series: series, animationNamespace: animationNamespace) #if os(iOS) @@ -126,6 +143,12 @@ struct SeriesView: View { activePlaylist.map { "\($0.id.uuidString)-" } ?? "" } + /// Re-derives the provider browse section when the playlist or the user's + /// provider selection changes. + private var providerTaskID: String { + "\(playlistPrefix)|\(providerSettings.selectedIDs.sorted().map(String.init).joined(separator: ","))" + } + /// Categories scoped to the active playlist. The `@Query` fetches every /// playlist's categories (SwiftData can't parameterize a `@Query` on view /// state), so we isolate by the playlist-prefixed category `id` here. diff --git a/Lume/Views/Settings/SettingsView+TVComponents.swift b/Lume/Views/Settings/SettingsView+TVComponents.swift index ddec721..e7d688f 100644 --- a/Lume/Views/Settings/SettingsView+TVComponents.swift +++ b/Lume/Views/Settings/SettingsView+TVComponents.swift @@ -15,7 +15,7 @@ import SwiftUI /// The top-level settings categories shown in the tvOS sidebar. enum SettingsCategory: String, CaseIterable, Identifiable { - case premium, playlists, profiles, content, epg, search, integrations, player, storage, about + case premium, playlists, profiles, content, providers, epg, search, integrations, player, storage, about var id: String { rawValue @@ -27,6 +27,7 @@ import SwiftUI case .playlists: "Playlists" case .profiles: "Profiles" case .content: "Content" + case .providers: "Providers" case .epg: "TV Guide" case .search: "Search" case .storage: "Storage" diff --git a/Lume/Views/Settings/SettingsView.swift b/Lume/Views/Settings/SettingsView.swift index 8eb7b0e..6d1abbb 100644 --- a/Lume/Views/Settings/SettingsView.swift +++ b/Lume/Views/Settings/SettingsView.swift @@ -202,6 +202,14 @@ struct SettingsView: View { Label("Content Management", systemImage: "slider.horizontal.3") } .disabled(playlists.isEmpty) + + if TMDBClient.shared.isConfigured { + NavigationLink { + WatchProviderSettingsView() + } label: { + Label("Streaming Providers", systemImage: "play.tv") + } + } } header: { Text("Library") } footer: { @@ -403,9 +411,12 @@ struct SettingsView: View { } /// The sidebar categories. Integrations is hidden unless the build has - /// Trakt credentials configured. + /// Trakt credentials configured; Providers unless TMDB is configured. private var availableCategories: [SettingsCategory] { - SettingsCategory.allCases.filter { $0 != .integrations || trakt.isConfigured } + SettingsCategory.allCases.filter { + ($0 != .integrations || trakt.isConfigured) + && ($0 != .providers || TMDBClient.shared.isConfigured) + } } /// Content Management brings its own scroll/background, so it replaces the @@ -436,6 +447,7 @@ struct SettingsView: View { tvPlaylistsDetail } case .profiles: TVProfilesSettingsView() + case .providers: WatchProviderSettingsView() case .epg: EPGSettingsView() case .search: tvSearchDetail case .storage: StorageManagementView() diff --git a/Lume/Views/Settings/WatchProviderSettingsView.swift b/Lume/Views/Settings/WatchProviderSettingsView.swift new file mode 100644 index 0000000..b1509fd --- /dev/null +++ b/Lume/Views/Settings/WatchProviderSettingsView.swift @@ -0,0 +1,218 @@ +// +// WatchProviderSettingsView.swift +// Lume +// +// Lets the user pick which streaming services ("watch providers") the Movies +// and Series tabs group content by. The provider catalog is fetched from TMDB +// for the user's region and cached locally; selection is held by +// `WatchProviderSettings`. +// + +import SwiftData +import SwiftUI + +struct WatchProviderSettingsView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \WatchProvider.displayPriority) private var providers: [WatchProvider] + @State private var settings = WatchProviderSettings.shared + @State private var isRefreshing = false + + private var isConfigured: Bool { + TMDBClient.shared.isConfigured + } + + var body: some View { + #if os(tvOS) + tvBody + #else + standardBody + #endif + } + + // MARK: - Catalog refresh + + /// Pulls the region's provider list from TMDB and upserts it into the local + /// catalog. Existing rows are updated in place so selection ids stay valid. + private func refresh() async { + guard isConfigured, !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + let manager = ContentSyncManager(modelContainer: modelContext.container) + let fetched = await manager.fetchWatchProviderList() + guard !fetched.isEmpty else { return } + + let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] + var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + for info in fetched { + if let provider = byId[info.id] { + provider.name = info.name + provider.logoPath = info.logoPath + provider.displayPriority = info.displayPriority + } else { + let provider = WatchProvider( + id: info.id, + name: info.name, + logoPath: info.logoPath, + displayPriority: info.displayPriority + ) + modelContext.insert(provider) + byId[info.id] = provider + } + } + try? modelContext.save() + } + + // MARK: - iOS / macOS + + #if !os(tvOS) + private var standardBody: some View { + List { + Section { + if !isConfigured { + Text("Streaming providers are unavailable because TMDB is not configured in this build.") + .font(.caption) + .foregroundStyle(.secondary) + } else if providers.isEmpty { + HStack { + Spacer() + if isRefreshing { + ProgressView() + } else { + Text("No providers loaded yet.") + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 12) + } else { + ForEach(providers) { provider in + Button { + settings.toggle(provider.id) + } label: { + providerRow(provider) + } + .buttonStyle(.plain) + } + } + } header: { + Text("Providers") + } footer: { + Text("Pick the streaming services you subscribe to. The Movies and Series tabs add a section for each, grouping titles available on that service in your region.") + } + } + .navigationTitle("Streaming Providers") + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + Task { await refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(!isConfigured || isRefreshing) + } + } + .task { + if providers.isEmpty { await refresh() } + } + } + + private func providerRow(_ provider: WatchProvider) -> some View { + HStack(spacing: 12) { + providerLogo(provider) + Text(provider.name) + Spacer() + Image(systemName: settings.isSelected(provider.id) ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(settings.isSelected(provider.id) ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) + } + .contentShape(Rectangle()) + } + #endif + + // MARK: - tvOS (settings detail pane) + + #if os(tvOS) + private var tvBody: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + TVSettingsSectionLabel("Streaming Providers") + Spacer() + Button { + Task { await refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(TVSettingsActionButtonStyle()) + .disabled(!isConfigured || isRefreshing) + } + + Text("Pick the streaming services you subscribe to. The Movies and Series tabs add a section for each, grouping titles available on that service in your region.") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + .padding(.horizontal, TVSettingsMetrics.rowHPadding) + .padding(.bottom, 8) + + if !isConfigured { + Text("Streaming providers are unavailable because TMDB is not configured in this build.") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + .padding(.horizontal, TVSettingsMetrics.rowHPadding) + } else if providers.isEmpty { + Text(isRefreshing ? "Loading providers…" : "No providers loaded yet.") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + .padding(.horizontal, TVSettingsMetrics.rowHPadding) + } else { + VStack(spacing: 2) { + ForEach(providers) { provider in + Button { + settings.toggle(provider.id) + } label: { + HStack(spacing: 16) { + providerLogo(provider) + Text(provider.name) + Spacer(minLength: 0) + Text(settings.isSelected(provider.id) ? "On" : "Off") + .foregroundStyle(.secondary) + } + } + .buttonStyle(TVSettingsRowButtonStyle()) + } + } + } + } + .task { + if providers.isEmpty { await refresh() } + } + } + #endif + + // MARK: - Shared + + private func providerLogo(_ provider: WatchProvider) -> some View { + CachedAsyncImage(url: TMDBClient.providerLogoURL(provider.logoPath), maxPixelSize: logoSize * 2) { phase in + switch phase { + case let .success(image): + image.resizable().aspectRatio(contentMode: .fit) + default: + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.gray.opacity(0.3)) + .overlay { + Image(systemName: "play.tv") + .foregroundStyle(.secondary) + } + } + } + .frame(width: logoSize, height: logoSize) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var logoSize: CGFloat { + #if os(tvOS) + 48 + #else + 40 + #endif + } +} diff --git a/LumeTests/Helpers/TestHelpers.swift b/LumeTests/Helpers/TestHelpers.swift index a50c537..84e3c26 100644 --- a/LumeTests/Helpers/TestHelpers.swift +++ b/LumeTests/Helpers/TestHelpers.swift @@ -12,7 +12,8 @@ func makeTestContainer() throws -> ModelContainer { Episode.self, CastMember.self, EPGListing.self, - EPGSource.self + EPGSource.self, + WatchProvider.self ]) // `cloudKitDatabase: .none` is required: the catalog uses `@Attribute(.unique)`, // which CloudKit forbids. The default `.automatic` mirrors to CloudKit on a diff --git a/LumeTests/Models/WatchProviderTests.swift b/LumeTests/Models/WatchProviderTests.swift new file mode 100644 index 0000000..5a9bdc9 --- /dev/null +++ b/LumeTests/Models/WatchProviderTests.swift @@ -0,0 +1,121 @@ +import Foundation +@testable import Lume +import SwiftData +import Testing + +struct WatchProviderIDsTests { + @Test func `encodes ids with sentinels and de-dupes preserving order`() { + #expect(WatchProviderIDs.encode([8, 337, 8]) == "|8|337|") + } + + @Test func `encodes an empty list as nil`() { + #expect(WatchProviderIDs.encode([]) == nil) + } + + @Test func `decodes a sentinel string back to ids`() { + #expect(WatchProviderIDs.decode("|8|337|") == [8, 337]) + #expect(WatchProviderIDs.decode(nil) == []) + #expect(WatchProviderIDs.decode("") == []) + } + + @Test func `round-trips through encode and decode`() { + let ids = [8, 337, 9] + #expect(WatchProviderIDs.decode(WatchProviderIDs.encode(ids)) == ids) + } + + @Test func `contains matches whole ids only, never substrings`() { + let raw = WatchProviderIDs.encode([8, 337]) + #expect(WatchProviderIDs.contains(raw, id: 8)) + #expect(WatchProviderIDs.contains(raw, id: 337)) + // 80 / 3 must not match 8 / 337 — the sentinel guards against substring hits. + #expect(!WatchProviderIDs.contains(raw, id: 80)) + #expect(!WatchProviderIDs.contains(raw, id: 3)) + } + + @Test func `query token wraps the id in sentinels`() { + #expect(WatchProviderIDs.queryToken(for: 8) == "|8|") + } +} + +@MainActor +struct WatchProviderDerivationTests { + private let prefix = "11111111-1111-1111-1111-111111111111-" + private let otherPrefix = "22222222-2222-2222-2222-222222222222-" + + private func makeMovie(streamId: Int, providerIDs: [Int], prefix: String) -> Movie { + let movie = Movie(id: "\(prefix)movie-\(streamId)", streamId: streamId, name: "Movie \(streamId)") + movie.watchProviderIds = providerIDs + return movie + } + + private func catalog(_ context: ModelContext) { + context.insert(WatchProvider(id: 8, name: "Netflix", displayPriority: 0)) + context.insert(WatchProvider(id: 337, name: "Disney Plus", displayPriority: 1)) + context.insert(WatchProvider(id: 9, name: "Prime Video", displayPriority: 2)) + } + + @Test func `excludes providers that only appear in a different playlist`() throws { + let container = try makeTestContainer() + let context = container.mainContext + catalog(context) + context.insert(makeMovie(streamId: 1, providerIDs: [8], prefix: prefix)) + context.insert(makeMovie(streamId: 2, providerIDs: [9], prefix: prefix)) + // Disney (337) appears only in the other playlist's title. + context.insert(makeMovie(streamId: 3, providerIDs: [337], prefix: otherPrefix)) + + // User subscribes to Netflix (8) and Disney (337) but not Prime (9). + let result = WatchProviderDerivation.movieProviders( + in: context, + playlistPrefix: prefix, + restriction: ContentRestriction(), + selected: [8, 337] + ) + // 337 is only in the other playlist, so only Netflix survives here. + #expect(result.map(\.id) == [8]) + } + + @Test func `returns selected providers present in the playlist in priority order`() throws { + let container = try makeTestContainer() + let context = container.mainContext + catalog(context) + context.insert(makeMovie(streamId: 1, providerIDs: [337, 8], prefix: prefix)) + + let result = WatchProviderDerivation.movieProviders( + in: context, + playlistPrefix: prefix, + restriction: ContentRestriction(), + selected: [8, 337] + ) + // Both present and selected — ordered by catalog display priority (8 before 337). + #expect(result.map(\.id) == [8, 337]) + } + + @Test func `intersects present and selected, dropping unselected providers`() throws { + let container = try makeTestContainer() + let context = container.mainContext + catalog(context) + context.insert(makeMovie(streamId: 1, providerIDs: [8, 9], prefix: prefix)) + + let result = WatchProviderDerivation.movieProviders( + in: context, + playlistPrefix: prefix, + restriction: ContentRestriction(), + selected: [8] // Prime (9) is present but not selected. + ) + #expect(result.map(\.id) == [8]) + } + + @Test func `is empty when nothing is selected`() throws { + let container = try makeTestContainer() + let context = container.mainContext + catalog(context) + context.insert(makeMovie(streamId: 1, providerIDs: [8], prefix: prefix)) + + #expect(WatchProviderDerivation.movieProviders( + in: context, + playlistPrefix: prefix, + restriction: ContentRestriction(), + selected: [] + ).isEmpty) + } +}