diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 7c01aee..c49f8c8 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2991,6 +2991,24 @@ } } }, + "signIn.timedOut": { + "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in wasn't completed. Please try again." + } + } + } + }, "signIn.windowFailed": { "extractionState": "stale", "localizations": { @@ -3414,19 +3432,146 @@ } } }, - "signIn.timedOut": { - "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", + "downloads.refused.noServer": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal." + "value": "Keine Verbindung zum Server." } }, "en": { "stringUnit": { "state": "translated", - "value": "Sign-in wasn't completed. Please try again." + "value": "No connection to the server." + } + } + } + }, + "downloads.refused.noMedia": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu diesem Titel liegt keine Datei auf dem Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "There's no file for this title on the server." + } + } + } + }, + "downloads.refused.noPart": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datei auf dem Server hat keinen abrufbaren Teil. Möglicherweise wurde sie noch nicht analysiert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The file on the server has no retrievable part. It may not have been analyzed yet." + } + } + } + }, + "downloads.refused.noURL": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Download-Adresse ließ sich nicht bilden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The download address couldn't be built." + } + } + } + }, + "player.quality": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Qualität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quality" + } + } + } + }, + "quality.automatic": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + } + } + }, + "quality.fullHD": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Full HD · 1080p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full HD · 1080p" + } + } + } + }, + "quality.hd": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "HD · 720p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "HD · 720p" + } + } + } + }, + "quality.lowRes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sparsam · 480p" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Data saver · 480p" } } } diff --git a/Flixy/Session/DownloadRefiller.swift b/Flixy/Session/DownloadRefiller.swift index 3881380..cc69841 100644 --- a/Flixy/Session/DownloadRefiller.swift +++ b/Flixy/Session/DownloadRefiller.swift @@ -39,7 +39,9 @@ struct DownloadRefiller { // ließe sich weder das Speicherlimit prüfen noch ein // Fortschritt anzeigen. let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode - guard let plan = service.plan(for: detailed) else { continue } + // Folgen ohne abrufbare Datei still überspringen: das Nachladen + // läuft im Hintergrund, da hilft keine Fehlermeldung. + guard let plan = try? service.plan(for: detailed) else { continue } if (try? coordinator.enqueue(plan)) != nil { added += 1 } } } diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index 1a21628..fadefd2 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -14,16 +14,26 @@ struct DownloadService { /// Baut den Auftrag für einen einzelnen Titel. /// - /// `nil`, wenn es keine abspielbare Datei gibt — bei einem Titel, den der - /// Server noch nicht analysiert hat, etwa. - func plan(for item: PlexItem, mediaIndex: Int = 0) -> DownloadPlan? { - guard let server else { return nil } + /// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied + /// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er + /// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server + /// noch nicht analysiert hat. + func plan(for item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadPlan { + guard let server else { + throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer")) + } let media = item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia - guard let media, let part = media.parts.first, - let url = server.downloadURL(part: part) - else { return nil } + guard let media else { + throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia")) + } + guard let part = media.parts.first else { + throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart")) + } + guard let url = server.downloadURL(part: part) else { + throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL")) + } return DownloadPlan( ratingKey: item.ratingKey, @@ -43,9 +53,8 @@ struct DownloadService { /// Lädt einen einzelnen Titel. @discardableResult - func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem? { - guard let plan = plan(for: item, mediaIndex: mediaIndex) else { return nil } - return try coordinator.enqueue(plan) + func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem { + try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex)) } /// Lädt alle Folgen einer Staffel oder Serie. @@ -78,14 +87,14 @@ struct DownloadService { } let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode - guard let plan = plan(for: detailed) else { - unavailable += 1 - continue - } do { - _ = try coordinator.enqueue(plan) + _ = try coordinator.enqueue(try plan(for: detailed)) accepted += 1 + } catch DownloadRefusal.noDownloadableFile { + // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie + // werden am Ende nur gezählt. + unavailable += 1 } catch { refused.append((episode, error)) // Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 7322780..47c5fcf 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -158,6 +158,8 @@ extension DownloadRefusal { String(localized: "downloads.refused.exists") case .storageLimitReached(let limit, let used): String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))") + case .noDownloadableFile(let reason): + reason case .insufficientDeviceSpace(let required, let available): String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))") } diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index b4a3f18..4f60b81 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -41,10 +41,15 @@ struct LiveTVView: View { } case .available where channels.isEmpty: + // Empfänger gefunden, aber keine Sender. Ein anderer + // Zustand als „nicht eingerichtet", und die Unterscheidung + // zählt: sie sagt, ob es am Server liegt oder an der App. ContentUnavailableView { Label("liveTV.noChannels.title", systemImage: "tv") } description: { Text("liveTV.noChannels.description") + } actions: { + Button("action.retry") { Task { await load() } } } case .available: diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index fad3199..7986796 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -45,6 +45,10 @@ final class PlayerModel { /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Fortschritt zu melden und keine nächste Folge nachzuschlagen. private(set) var isOffline = false + /// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen. + private(set) var quality: PlayerQuality = .automatic + /// Ob gerade auf eine andere Stufe umgestellt wird. + private(set) var isChangingQuality = false private let request: PlaybackRequest private let server: PlexServer? @@ -55,6 +59,12 @@ final class PlayerModel { private let isLocalConnection: Bool private var item: PlexItem? + /// Die gerade gespielte Fassung und Datei. + /// + /// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann, + /// ohne die Metadaten erneut zu holen. + private var currentMedia: PlexMedia? + private var currentPart: PlexPart? private var eventTask: Task? private var reportTask: Task? private var transcodeSession: String? @@ -120,6 +130,9 @@ final class PlayerModel { return } + currentMedia = media + currentPart = part + let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) self.decision = decision @@ -132,6 +145,53 @@ final class PlayerModel { preloadNextEpisode() } + /// Stellt auf eine andere Qualitätsstufe um. + /// + /// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse + /// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe. + /// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der + /// Film bei jedem Wechsel von vorn. + func select(quality newQuality: PlayerQuality) async { + guard newQuality != quality, !isOffline, + let server, let item, let media = currentMedia, let part = currentPart + else { return } + + isChangingQuality = true + defer { isChangingQuality = false } + + let resumeAt = currentTime + quality = newQuality + + // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet + // der Server zwei Ströme gleichzeitig für einen Zuschauer. + if let transcodeSession { + await server.stopTranscode(sessionIdentifier: transcodeSession) + self.transcodeSession = nil + } + eventTask?.cancel() + engine?.teardown() + engine = nil + phase = .preparing + + let newPolicy = newQuality.policy(basedOn: policy) + let newDecision = PlaybackDecider().decide(media: media, part: part, policy: newPolicy) + decision = newDecision + + guard let source = await source( + for: newDecision, + item: item, + media: media, + part: part, + server: server, + startingAt: resumeAt + ) else { + phase = .failed(message: String(localized: "player.noPlayableFile")) + return + } + + await load(source, decision: newDecision) + } + /// Wiedergabe aus dem Download-Ordner. /// /// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf @@ -206,18 +266,22 @@ final class PlayerModel { item: PlexItem, media: PlexMedia, part: PlexPart, - server: PlexServer + server: PlexServer, + startingAt: Duration? = nil ) async -> PlaybackSource? { + let start = startingAt ?? request.startTime + switch decision.method { case .directPlay: guard let url = server.directPlayURL(part: part) else { return nil } - return PlaybackSource(url: url, origin: .remote, startTime: request.startTime) + return PlaybackSource(url: url, origin: .remote, startTime: start) case .transcode(let maximumBitrate): let transcode = TranscodeRequest( ratingKey: item.ratingKey, mediaIndex: request.mediaIndex, maximumBitrate: maximumBitrate, + resolution: quality.resolution, isLocal: isLocalConnection ) transcodeSession = transcode.sessionIdentifier @@ -232,7 +296,7 @@ final class PlayerModel { } guard let url = server.transcodeURL(for: transcode) else { return nil } - return PlaybackSource(url: url, origin: .remote, startTime: request.startTime) + return PlaybackSource(url: url, origin: .remote, startTime: start) } } diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 4000190..b987fbe 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -153,13 +153,44 @@ struct PlayerView: View { .accessibilityLabel(Text("player.tracks")) } - if let decision = model.decision { - PlaybackMethodBadge(decision: decision) + if !model.isOffline { + qualityMenu(for: model) } } .foregroundStyle(.white) } + /// Die Qualitätsstufe für diesen Film. + /// + /// Im Player und nicht nur in den Einstellungen, weil der Eingriff genau + /// dann gebraucht wird, wenn es gerade ruckelt — und dafür will niemand + /// den Film verlassen. + private func qualityMenu(for model: PlayerModel) -> some View { + Menu { + Picker("player.quality", selection: Binding( + get: { model.quality }, + set: { selected in + Task { await model.select(quality: selected) } + scheduleControlsHide() + } + )) { + ForEach(PlayerQuality.allCases) { option in + Text(option.label).tag(option) + } + } + } label: { + if model.isChangingQuality { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: "slider.horizontal.3") + .font(.title3) + .padding(FlixySpacing.snug) + } + } + .disabled(model.isChangingQuality) + .accessibilityLabel(Text("player.quality")) + } + private func transport(for model: PlayerModel) -> some View { HStack(spacing: FlixySpacing.section * 2) { Button { @@ -337,32 +368,6 @@ struct PlayerView: View { } } -/// Zeigt an, auf welchem Weg gerade abgespielt wird. -struct PlaybackMethodBadge: View { - let decision: PlaybackDecision - - var body: some View { - Text(label) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.white.opacity(0.75)) - .padding(.horizontal, FlixySpacing.snug) - .padding(.vertical, FlixySpacing.tight) - .background(.white.opacity(0.15), in: .capsule) - } - - private var label: String { - switch decision.method { - case .directPlay(let engine): - switch engine { - case .avPlayer: "AVPlayer" - case .vlc: "VLC" - } - case .transcode: - "Transcode" - } - } -} - extension Duration { /// Als „1:23:45" bzw. „23:45". var timecode: String { @@ -376,3 +381,15 @@ extension Duration { return String(format: "%d:%02d", minutes, seconds) } } + +extension PlayerQuality { + var label: LocalizedStringKey { + switch self { + case .automatic: "quality.automatic" + case .original: "quality.original" + case .fullHD: "quality.fullHD" + case .hd: "quality.hd" + case .low: "quality.lowRes" + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 5cf5b25..48a8ff6 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -49,6 +49,12 @@ public struct DownloadPlan: Sendable { /// Warum ein Download nicht angenommen wurde. public enum DownloadRefusal: Error, Equatable, Sendable { case alreadyExists + /// Zu diesem Titel gibt es keine Datei, die sich herunterladen ließe. + /// + /// Eigener Fall statt eines stillen `nil`: eine Funktion, die kommentarlos + /// nichts tut, ist selbst der Fehler — der Knopf sah aus, als wäre er + /// kaputt, dabei fehlte nur die Angabe, woran es liegt. + case noDownloadableFile(reason: String) /// Das eingestellte Speicherlimit wäre überschritten. case storageLimitReached(limitBytes: Int64, usedBytes: Int64) /// Auf dem Gerät ist zu wenig Platz. diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift new file mode 100644 index 0000000..5c83646 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Eine im Player wählbare Qualitätsstufe. +/// +/// Getrennt von den Einstellungen unter „Qualität": dort steht, was +/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil +/// es gerade ruckelt oder weil man ausnahmsweise das Original will. +public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable { + /// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist. + case automatic + /// Die Datei unverändert, ohne Umrechnung. + case original + case fullHD + case hd + case low + + public var id: String { rawValue } + + /// Obergrenze in Kilobit. `nil` bei `automatic` und `original`. + public var maximumBitrate: Int? { + switch self { + case .automatic, .original: nil + case .fullHD: 8_000 + case .hd: 4_000 + case .low: 2_000 + } + } + + public var resolution: String? { + switch self { + case .automatic, .original: nil + case .fullHD: "1920x1080" + case .hd: "1280x720" + case .low: "854x480" + } + } + + /// Ob diese Stufe eine Umrechnung erzwingt. + /// + /// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die + /// Einstellungen eine Grenze vorsehen. + public var forcesTranscode: Bool { + switch self { + case .automatic, .original: false + case .fullHD, .hd, .low: true + } + } + + /// Die Richtlinie für diese Stufe. + /// + /// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben + /// sie — das ist der Zweck eines Eingriffs im Player. + public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy { + switch self { + case .automatic: + base + case .original: + // Keine Grenze und kein Umrechnen: ausdrücklich das Original. + PlaybackPolicy(allowsTranscoding: false, allowsFallbackEngine: true, maximumBitrate: nil) + case .fullHD, .hd, .low: + // Ohne Ersatzmotor, damit die Entscheidung zwangsläufig beim + // Transcoder landet — sonst spielte VLC die Datei direkt ab und + // die gewählte Stufe bliebe wirkungslos. + PlaybackPolicy( + allowsTranscoding: true, + allowsFallbackEngine: false, + maximumBitrate: maximumBitrate + ) + } + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift index 39d6403..0c702b0 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift @@ -179,35 +179,55 @@ public struct PlexMediaContainer: Sendable, Decod public let title2: String? public let content: [Content] - private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" } - private enum InnerKeys: String, CodingKey { - case size, totalSize, offset, title1, title2 - case metadata = "Metadata" - case directory = "Directory" - case hub = "Hub" + /// Unter welchen Namen Plex seine Sammlungen ablegt. + /// + /// Es gibt keinen einheitlichen: Titel stehen unter `Metadata`, + /// Mediatheken unter `Directory`, die Reihen der Startseite unter `Hub`, + /// Fernsehempfänger unter `Dvr`, die Anbieterliste unter `MediaProvider`. + /// + /// Genau daran war Live-TV gescheitert: die Hülle kannte `Dvr` nicht, las + /// eine leere Liste und meldete „kein Empfänger eingerichtet" — bei einem + /// Server, auf dem einer eingerichtet war. + /// + /// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der + /// Reihe nach zu probieren. + private static var contentKeys: [String] { + ["Metadata", "Directory", "Hub", "Dvr", "MediaProvider", "Channel", "ChannelMapping", "Device"] } + private struct DynamicKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + init(_ value: String) { stringValue = value } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { nil } + } + + private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" } + public init(from decoder: any Decoder) throws { let outer = try decoder.container(keyedBy: OuterKeys.self) - let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer) + let inner = try outer.nestedContainer(keyedBy: DynamicKey.self, forKey: .mediaContainer) - self.size = try inner.decodeIfPresent(Int.self, forKey: .size) - self.totalSize = try inner.decodeIfPresent(Int.self, forKey: .totalSize) - self.offset = try inner.decodeIfPresent(Int.self, forKey: .offset) - self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1) - self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2) + self.size = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("size")) + self.totalSize = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("totalSize")) + self.offset = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("offset")) + self.title1 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title1")) + self.title2 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title2")) - // `Metadata` bei Titeln, `Directory` bei Mediatheken, `Hub` bei den - // Reihen der Startseite. Eine leere Mediathek liefert gar kein Feld — - // das ist kein Fehler, sondern bedeutet schlicht null Einträge. - if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) { - self.content = metadata - } else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) { - self.content = directory - } else if let hub = try inner.decodeIfPresent([Content].self, forKey: .hub) { - self.content = hub - } else { - self.content = [] + // Erst nachsehen, ob der Schlüssel überhaupt da ist, und dann ohne + // `try?` dekodieren. + // + // Mit `try?` verschwände auch ein echter Dekodierfehler und ergäbe + // stillschweigend eine leere Liste — aus „ein Feld heißt jetzt anders" + // würde „die Mediathek ist leer". Genau diese Verwechslung hat bei + // Live-TV schon einmal auf die falsche Fährte geführt. + for name in Self.contentKeys where inner.contains(DynamicKey(name)) { + self.content = try inner.decode([Content].self, forKey: DynamicKey(name)) + return } + // Kein Schlüssel vorhanden: eine leere Mediathek liefert gar kein Feld. + // Das ist kein Fehler, sondern bedeutet null Einträge. + self.content = [] } } diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift new file mode 100644 index 0000000..8f07ad3 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/ContainerKeysTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import PlexKit + +/// Unter welchen Namen Plex seine Sammlungen ablegt. +/// +/// Es gibt keinen einheitlichen, und das ist keine Kleinigkeit: Live-TV galt +/// als „nicht eingerichtet", weil die Hülle `Dvr` nicht kannte, eine leere +/// Liste las und daraus die falsche Schlussfolgerung zog — auf einem Server, +/// auf dem ein Empfänger eingerichtet war. +@Suite("Sammlungsnamen in der Hülle") +struct ContainerKeysTests { + + @Test("Fernsehempfänger stehen unter Dvr") + func readsDvrKey() throws { + let json = """ + {"MediaContainer":{"size":1,"Dvr":[ + {"key":"1","uuid":"abc","lineupTitle":"Kabel","language":"deu"} + ]}} + """ + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.count == 1) + #expect(container.content.first?.key == "1") + } + + @Test("Die Anbieterliste steht unter MediaProvider") + func readsMediaProviderKey() throws { + // Darüber wird die Kennung des Programmführers gefunden. Ohne sie gibt + // es kein Programm, auch wenn die Sender da sind. + let json = """ + {"MediaContainer":{"size":2,"MediaProvider":[ + {"identifier":"com.plexapp.plugins.library","title":"Mediathek"}, + {"identifier":"tv.plex.providers.epg.cloud:2","title":"Programm"} + ]}} + """ + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.count == 2) + #expect(container.content.contains { $0.identifier.contains("epg") }) + } + + @Test("Titel stehen weiterhin unter Metadata") + func stillReadsMetadata() throws { + let json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"1","type":"movie","title":"Film"}]}}"# + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.first?.title == "Film") + } + + @Test("Eine fehlende Sammlung ist kein Fehler") + func missingCollectionIsEmpty() throws { + // Eine leere Mediathek liefert gar kein Feld. + let json = #"{"MediaContainer":{"size":0}}"# + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.isEmpty) + } + + @Test("Eine vorhandene, aber kaputte Sammlung meldet den Fehler") + func malformedCollectionThrows() { + // Der Unterschied, der beim Umbau beinahe verloren ging: aus „ein Feld + // heißt jetzt anders" darf nicht „die Mediathek ist leer" werden. + let json = #"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"# + #expect(throws: PlexError.self) { + try PlexResponse.decode(PlexMediaContainer.self, from: Data(json.utf8)) + } + } +}