Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions Lume/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -3285,6 +3315,16 @@
}
}
},
"No providers loaded yet." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Noch keine Anbieter geladen."
}
}
}
},
"No Series" : {
"localizations" : {
"de" : {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -3856,6 +3916,16 @@
}
}
},
"Providers" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Anbieter"
}
}
}
},
"QR code" : {
"localizations" : {
"de" : {
Expand Down Expand Up @@ -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" : {
Expand Down
3 changes: 2 additions & 1 deletion Lume/LumeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Lume/Models/Movie.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down
17 changes: 17 additions & 0 deletions Lume/Models/Series.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down
76 changes: 76 additions & 0 deletions Lume/Models/WatchProvider.swift
Original file line number Diff line number Diff line change
@@ -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<Int>()
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
}
44 changes: 44 additions & 0 deletions Lume/Services/Catalog/WatchProviderSettings.swift
Original file line number Diff line number Diff line change
@@ -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<Int>

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)
}
}
Loading