Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag

Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts
abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar
kannte.

Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten,
sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet
wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur
aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder
angesprochen und konnte deshalb mit nichts anderem reden.

`MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der
Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau
dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil
es dafür keinen Fall gab.

Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst
(`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er
kopieren kann und welche er neu rechnen muss.

Drei Dinge, die beim Hinsehen auffielen:

- **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei
  Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und
  der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig
  Minuten Fortschritt landet man bei zweiundvierzig.
- **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint
  der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus,
  weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem
  Öffnen fest. Jetzt tut es für die erste Meldung die des Servers.
- **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem
  Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf;
  bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört.

Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht.
Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet
hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag.

Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der
Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung
an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin
das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst
ein richtiger Server.

257 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 14:00:02 +02:00
co-authored by Claude Opus 5
parent 370f7f94f1
commit fb418c842d
23 changed files with 4090 additions and 3274 deletions
File diff suppressed because it is too large Load Diff
+20
View File
@@ -138,6 +138,26 @@ final class FlixySession {
}
}
/// Wie die Verbindung zum Server zu beschreiben ist.
///
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
/// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
/// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
/// Hälfte aller Ruckler, bevor man weitersucht.
var connectionDescription: String? {
switch kind {
case .plex:
guard let connection = server?.connection else { return nil }
if connection.local { return "Lokal · \(connection.uri)" }
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
return "Direkt von außen · \(connection.uri)"
case .jellyfin:
return serverAddress
case nil:
return nil
}
}
/// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**.
///
/// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der
+2 -8
View File
@@ -1,7 +1,6 @@
import Foundation
import Observation
import FlixyPlayback
import PlexKit
/// Hält fest, was beim letzten Abspielversuch passiert ist.
///
@@ -30,12 +29,12 @@ final class PlaybackDiagnostics {
private(set) var lastAttempt: Attempt?
func begin(title: String, connection: PlexConnection?) {
func begin(title: String, connection: String?) {
lastAttempt = Attempt(
startedAt: Date(),
title: title,
method: "—",
connection: connection.map(Self.describe) ?? "—",
connection: connection ?? "—",
sourceDescription: "—"
)
note("Player geöffnet")
@@ -76,11 +75,6 @@ final class PlaybackDiagnostics {
}
}
private static func describe(_ connection: PlexConnection) -> String {
if connection.local { return "Lokal · \(connection.uri)" }
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
return "Direkt von außen · \(connection.uri)"
}
private static func describe(_ url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+16 -1
View File
@@ -114,8 +114,23 @@ struct DownloadButton: View {
}
/// Nur Stufen, die unter der Auflösung der Datei liegen.
///
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
private var availableQualities: [DownloadQuality] {
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
guard canDownloadCompressed else { return [.original] }
return all
}
private var canDownloadCompressed: Bool {
session.backend?.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: 8_000,
resolution: nil
) != nil
}
/// Die Beschriftung samt Auflösung und geschätzter Größe.
+3
View File
@@ -78,6 +78,9 @@ enum ErrorText {
case .server(let status): String(localized: "error.server \(status)")
case .transport(let description): description
case .decoding: String(localized: "error.unexpectedResponse")
// Die Worte des Servers, nicht unsere: er weiß besser, warum er
// ablehnt, als wir es raten könnten.
case .refused(let reason): reason
}
}
}
+4 -1
View File
@@ -121,12 +121,15 @@ struct RootView: View {
let fresh = PlayerModel(
request: request,
server: session.server,
backend: session.backend,
// Live-TV spricht als Einziges noch unmittelbar mit Plex.
liveTV: session.server,
downloads: downloads,
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
isLocalConnection: session.server?.connection.local ?? false,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
)
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
+120 -79
View File
@@ -44,10 +44,10 @@ final class PlayerModel {
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: PlexMarker?
private(set) var activeMarker: MediaMarker?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: PlexItem?
private(set) var nextEpisode: MediaItem?
private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
@@ -93,12 +93,25 @@ final class PlayerModel {
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
private let request: PlaybackRequest
private let server: PlexServer?
private let backend: (any MediaBackend)?
/// Der Plex-Server — nur noch für Live-TV.
///
/// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar
/// mit Plex spricht. Ein Tuner ist ein Stück Hardware, und die fünfhundert
/// Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen
/// gemeinsamen Vertrag. Bei Jellyfin ist er `nil`.
private let liveTV: PlexServer?
private let downloads: DownloadCoordinator
private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
private let isLocalConnection: Bool
/// Wie die Verbindung in der Diagnose heißt.
///
/// Von außen gereicht: welcher Weg zum Server führt, weiß die Sitzung —
/// und über die Vermittlung von plex.tv ist alles gedrosselt, was die
/// Hälfte aller Ruckler erklärt, bevor man weitersucht.
private let connectionDescription: String?
/// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen
/// ließe.
///
@@ -107,16 +120,23 @@ final class PlayerModel {
/// Server angemeldet ist, weiß die Sitzung, nicht der Player.
private let unavailableMessage: String
private var item: PlexItem?
private var item: MediaItem?
/// 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 currentMedia: MediaVersion?
private var currentPart: MediaFile?
private var eventTask: Task<Void, Never>?
private var reportTask: Task<Void, Never>?
private var transcodeSession: String?
/// Was der Server für diese Wiedergabe angelegt hat.
///
/// Zum Abräumen und zum Melden: Jellyfin ordnet den Fortschritt über
/// dieselbe Kennung zu, über die Plex den Transcoder anhält.
private var playbackSession: String?
/// Welche Fassung gerade läuft — Jellyfin will sie bei jeder Meldung
/// zurückhaben.
private var playbackVersionID: String?
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
/// mehr.
private var programmeEndsAt: Date?
@@ -129,21 +149,25 @@ final class PlayerModel {
init(
request: PlaybackRequest,
server: PlexServer?,
backend: (any MediaBackend)?,
liveTV: PlexServer?,
downloads: DownloadCoordinator,
diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy,
settings: PlaybackSettings,
isLocalConnection: Bool,
connectionDescription: String?,
unavailableMessage: String
) {
self.request = request
self.server = server
self.backend = backend
self.liveTV = liveTV
self.downloads = downloads
self.diagnostics = diagnostics
self.policy = policy
self.settings = settings
self.isLocalConnection = isLocalConnection
self.connectionDescription = connectionDescription
self.unavailableMessage = unavailableMessage
self.title = request.title
}
@@ -151,7 +175,7 @@ final class PlayerModel {
// MARK: - Start
func start() async {
diagnostics.begin(title: request.title, connection: server?.connection)
diagnostics.begin(title: request.title, connection: connectionDescription)
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
@@ -166,15 +190,15 @@ final class PlayerModel {
return
}
guard let server else {
guard let backend else {
phase = .failed(message: unavailableMessage)
return
}
diagnostics.note("Metadaten anfragen")
let item: PlexItem
let item: MediaItem
do {
item = try await server.metadata(ratingKey: request.ratingKey)
item = try await backend.item(id: request.ratingKey)
diagnostics.note("Metadaten da")
} catch {
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
@@ -183,10 +207,10 @@ final class PlayerModel {
}
self.item = item
title = item.asMediaItem.rowTitle
title = item.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item), let part = media.parts.first else {
guard let media = version(of: item), let part = media.files.first else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
@@ -194,10 +218,10 @@ final class PlayerModel {
currentMedia = media
currentPart = part
let decision = PlaybackDecider().decide(profile: media.profile(part: part), policy: policy)
let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy)
self.decision = decision
guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else {
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
@@ -220,7 +244,7 @@ final class PlayerModel {
let isRetry = { if case .failed = phase { return true } else { return false } }()
guard newQuality != quality || isRetry,
!isOffline,
let server, let item, let media = currentMedia, let part = currentPart
let backend, let item, let media = currentMedia, let part = currentPart
else { return }
isChangingQuality = true
@@ -232,9 +256,9 @@ final class PlayerModel {
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Streams gleichzeitig für einen Zuschauer.
if let transcodeSession {
await server.stopTranscode(sessionIdentifier: transcodeSession)
self.transcodeSession = nil
if let playbackSession {
await backend.endPlayback(sessionID: playbackSession)
self.playbackSession = nil
}
eventTask?.cancel()
engine?.teardown()
@@ -243,7 +267,7 @@ final class PlayerModel {
let newPolicy = newQuality.policy(basedOn: policy)
let newDecision = PlaybackDecider().decide(
profile: media.profile(part: part),
profile: media.profile(file: part),
policy: newPolicy
)
decision = newDecision
@@ -253,7 +277,7 @@ final class PlayerModel {
item: item,
media: media,
part: part,
server: server,
backend: backend,
startingAt: resumeAt
) else {
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
@@ -298,7 +322,7 @@ final class PlayerModel {
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
// Server keinen freien mehr.
transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
playbackSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "session" }?.value
// Live kommt immer umgerechnet: der Tuner liefert einen
@@ -360,43 +384,45 @@ final class PlayerModel {
}
}
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream.
/// Lässt den Server die Wiedergabe vorbereiten.
///
/// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist
/// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal
/// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle.
private func source(
for decision: PlaybackDecision,
item: PlexItem,
media: PlexMedia,
part: PlexPart,
server: PlexServer,
item: MediaItem,
media: MediaVersion,
part: MediaFile,
backend: any MediaBackend,
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: start)
let transcode: TranscodeSettings? = switch decision.method {
case .directPlay: nil
case .transcode(let maximumBitrate):
let transcode = TranscodeRequest(
ratingKey: item.ratingKey,
mediaIndex: request.mediaIndex,
maximumBitrate: maximumBitrate,
resolution: quality.resolution,
isLocal: isLocalConnection
)
transcodeSession = transcode.sessionIdentifier
TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution)
}
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier
// schon nein, steht der Grund im Klartext da — statt eines
// schwarzen Bildes nach zwanzig Sekunden Warten.
if let decision = try? await server.transcodeDecision(for: transcode),
!decision.isAllowed {
phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused"))
return nil
}
guard let url = server.transcodeURL(for: transcode) else { return nil }
return PlaybackSource(url: url, origin: .remote, startTime: start)
do {
let target = try await backend.preparePlayback(PlaybackIntent(
itemID: item.id,
versionIndex: request.mediaIndex,
file: part,
transcode: transcode,
startTime: start,
isLocalConnection: isLocalConnection
))
playbackSession = target.sessionID
playbackVersionID = target.versionID
return PlaybackSource(url: target.url, origin: .remote, startTime: start)
} catch {
// Die Worte des Servers stehen auf dem Bildschirm, nicht „keine
// abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige
// brauchbare Spur — und genau sie ging vorher verloren.
phase = .failed(message: ErrorText.describe(error))
return nil
}
}
@@ -432,7 +458,7 @@ final class PlayerModel {
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
/// nichts, was man bei jedem Antippen tun will.
func refreshLiveProgrammeIfNeeded() async {
guard isLive, let server else { return }
guard isLive, let server = liveTV else { return }
if let programmeEndsAt, programmeEndsAt > .now { return }
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
@@ -475,7 +501,7 @@ final class PlayerModel {
func skipActiveMarker() {
guard let marker = activeMarker else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endTimeOffset))
seek(to: .milliseconds(marker.endMilliseconds))
}
func selectAudioTrack(_ track: MediaTrack) {
@@ -520,14 +546,14 @@ final class PlayerModel {
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
// minutenlang weiter, obwohl niemand mehr zusieht.
if let transcodeSession, let server {
await server.stopTranscode(sessionIdentifier: transcodeSession)
if let playbackSession, let backend {
await backend.endPlayback(sessionID: playbackSession)
}
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
if let subscription = request.liveSubscriptionKey, let server {
if let subscription = request.liveSubscriptionKey, let server = liveTV {
await server.endLiveSession(subscriptionKey: subscription)
}
}
@@ -542,6 +568,10 @@ final class PlayerModel {
private func startReporting() {
reportTask?.cancel()
reportTask = Task { [weak self] in
// Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt
// beim Server die Sitzung an, unter der alles Weitere läuft.
await self?.report(state: .started)
while !Task.isCancelled {
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
// selbst. Häufiger brächte nichts, seltener verlöre beim
@@ -554,13 +584,28 @@ final class PlayerModel {
}
private func report(state: PlaybackReportState) async {
guard let server, let item, let duration, currentTime > .zero else { return }
await server.reportProgress(
ratingKey: item.ratingKey,
guard let backend, let item else { return }
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
else { return }
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
guard currentTime > .zero || state == .started else { return }
await backend.reportProgress(PlaybackReport(
itemID: item.id,
sessionID: playbackSession,
versionID: playbackVersionID,
state: state,
time: currentTime,
duration: duration
)
duration: total
))
}
// MARK: - Marken und nächste Folge
@@ -570,9 +615,7 @@ final class PlayerModel {
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else {
return false
}
guard marker.contains(milliseconds) else { return false }
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
@@ -587,9 +630,9 @@ final class PlayerModel {
}
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let server else { return }
guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in
let next = try? await server.nextEpisode(after: item)
let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next
}
}
@@ -599,8 +642,8 @@ final class PlayerModel {
func nextEpisodeRequest() -> PlaybackRequest? {
guard let nextEpisode else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.ratingKey,
title: nextEpisode.asMediaItem.playbackTitle,
ratingKey: nextEpisode.id,
title: nextEpisode.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
@@ -608,11 +651,12 @@ final class PlayerModel {
// MARK: - Innereien
private func version(of item: PlexItem) -> PlexMedia? {
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
return item.media[request.mediaIndex]
private func version(of item: MediaItem) -> MediaVersion? {
guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion }
return item.versions[request.mediaIndex]
}
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
///
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
@@ -697,11 +741,8 @@ final class PlayerModel {
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
// um eine Folge kleiner.
downloads.markWatched(ratingKey: request.ratingKey)
Task { [server, downloads] in
await DownloadRefiller(
backend: server.map(PlexBackend.init),
coordinator: downloads
).refill()
Task { [backend, downloads] in
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
}
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
@@ -727,7 +768,7 @@ final class PlayerModel {
}
}
private func playerSubtitle(for item: PlexItem) -> String? {
private func playerSubtitle(for item: MediaItem) -> String? {
guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"