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
+149 -4
View File
@@ -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"
}
}
}
+3 -1
View File
@@ -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 }
}
}
+24 -15
View File
@@ -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
@@ -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)))")
}
+5
View File
@@ -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:
+67 -3
View File
@@ -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<Void, Never>?
private var reportTask: Task<Void, Never>?
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)
}
}
+45 -28
View File
@@ -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"
}
}
}