Vier Punkte aus dem Praxistest

Download-Knopf tat nichts
  DownloadService.plan gab still nil zurück, wenn es keinen Auftrag
  bauen konnte, und download() reichte das als nil weiter. Der Knopf sah
  aus, als wäre er kaputt, dabei war der Grund jedes Mal bekannt. Es
  gibt jetzt einen eigenen Ablehnungsgrund mit vier unterschiedlichen
  Begründungen — keine Verbindung, keine Datei, kein abrufbarer Teil,
  keine Adresse. Eine Funktion, die kommentarlos nichts tut, ist selbst
  der Fehler.

Live-TV galt als „nicht eingerichtet"
  Plex legt die Empfängerliste unter dem Schlüssel `Dvr` ab, die Hülle
  kannte aber nur Metadata, Directory und Hub. Sie las eine leere Liste
  und zog daraus die falsche Schlussfolgerung — auf einem Server, auf
  dem ein Empfänger eingerichtet war. Dasselbe galt für die
  Anbieterliste unter `MediaProvider`, ohne die es keinen
  Programmführer gibt.

  Beim Umbau wäre beinahe die Fehlerdiagnose verloren gegangen: mit
  `try?` über alle Schlüssel 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". Jetzt wird erst geprüft, ob
  der Schlüssel da ist, und dann ohne try? dekodiert. Fünf Tests halten
  beides fest.

Qualität im Player wählbar
  Automatisch, Original, 1080p, 720p, 480p. Der Eingriff wird genau dann
  gebraucht, wenn es ruckelt — dafür will niemand den Film verlassen.
  Ein Wechsel baut den Strom neu auf (die Stufe steckt in der Adresse),
  merkt sich die Stelle und fährt sie wieder an. Der alte Transcoder
  wird vorher angehalten, sonst rechnet der Server zwei Ströme für einen
  Zuschauer.

AVPlayer-Anzeige entfernt
  Sie war eine Prüfhilfe aus Phase 1. Dieselbe Angabe steht weiterhin
  unter Einstellungen → Diagnose, wo sie hingehört.

137 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 21:44:20 +02:00
co-authored by Claude Opus 5
parent a2b3f901ed
commit bf0a63a924
11 changed files with 492 additions and 74 deletions
@@ -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.
@@ -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
)
}
}
}
@@ -179,35 +179,55 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: 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 = []
}
}
@@ -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<PlexDVR>.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<PlexMediaProvider>.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<PlexItem>.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<PlexItem>.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<PlexItem>.self, from: Data(json.utf8))
}
}
}