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
+25 -27
View File
@@ -1,10 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@ GB": {
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated": true
},
"%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true,
@@ -17,8 +13,8 @@
}
}
},
"1080p": {
"comment": "A label for a 1080p video.",
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"4K" : {
@@ -29,8 +25,8 @@
"comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true
},
"SD": {
"comment": "A label for a 720p movie.",
"1080p" : {
"comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true
},
"action.addFavourite" : {
@@ -1995,7 +1991,6 @@
}
},
"library.scan" : {
"extractionState": "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2768,23 +2763,6 @@
}
}
},
"player.subtitleTracks": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale",
@@ -2803,6 +2781,23 @@
}
}
},
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : {
"localizations" : {
"de" : {
@@ -3142,6 +3137,10 @@
}
}
},
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : {
"localizations" : {
"de" : {
@@ -3598,7 +3597,6 @@
}
},
"settings.libraries" : {
"extractionState": "stale",
"localizations" : {
"de" : {
"stringUnit" : {
+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
+119 -78
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
// 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
TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution)
}
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)"
@@ -0,0 +1,211 @@
import Foundation
import MediaKit
extension JellyfinBackend {
// MARK: - Abspielen
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
// Jellyfin trennt Fassung und Datei nicht: was die neutrale Form
// „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem
// weiteren Schritt wieder verlangt.
let sourceID = intent.file.key ?? intent.itemID
guard let transcode = intent.transcode else {
// `static=true` schaltet jede Umrechnung ab: die Datei kommt so,
// wie sie auf der Platte liegt. Ob das Gerät sie überhaupt
// abspielen kann, ist vorher entschieden worden — und wenn nicht
// der Systemplayer, dann VLC.
guard let url = client.url(
path: "/Videos/\(intent.itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: sourceID),
],
includingToken: account.token
) else { throw .notFound }
// Auch hier eine Sitzungskennung, und zwar eine selbst vergebene:
// ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner
// Wiedergabe zu, und der Titel bliebe für den Server ungesehen.
return PlaybackTarget(
url: url,
sessionID: UUID().uuidString,
versionID: sourceID
)
}
let info = try await playbackInfo(
itemID: intent.itemID,
sourceID: sourceID,
transcode: transcode
)
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
// Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren
// er kopieren kann und welche er neu rechnen muss; eine selbst
// zusammengebaute Adresse wäre eine Vermutung darüber.
guard let path = source?.transcodingURL,
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL
else {
throw .refused(reason: String(
localized: "player.transcodeUnavailable",
bundle: .module
))
}
return PlaybackTarget(
url: Self.limiting(height: transcode.resolution, in: url),
sessionID: info.playSessionID,
versionID: source?.id ?? sourceID
)
}
public func endPlayback(sessionID: String) async {
// Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von
// selbst merkt, dass niemand mehr Segmente abholt. Bei direkter
// Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins
// Leere, und das ist billiger als sich zu merken, welcher Fall es war.
_ = try? await client.send(JellyfinRequest(
method: .delete,
path: "/Videos/ActiveEncodings",
query: [
URLQueryItem(name: "deviceId", value: client.deviceID),
URLQueryItem(name: "playSessionId", value: sessionID),
],
timeout: .seconds(5)
))
}
public func reportProgress(_ report: PlaybackReport) async {
var fields: [String: Any] = [
"ItemId": report.itemID,
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
"IsPaused": report.state == .paused,
"CanSeek": true,
]
if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID }
if let versionID = report.versionID { fields["MediaSourceId"] = versionID }
// Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den
// ersten erscheint der Titel nirgends als „läuft gerade"; ohne den
// letzten bleibt er für immer dort stehen. Ein Fortschritt mit
// derselben Zahl täte weder das eine noch das andere.
let path = switch report.state {
case .started: "/Sessions/Playing"
case .stopped: "/Sessions/Playing/Stopped"
default: "/Sessions/Playing/Progress"
}
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
_ = try? await client.send(JellyfinRequest(
method: .post,
path: path,
body: body,
timeout: .seconds(10)
))
}
// MARK: - Hilfen
private func playbackInfo(
itemID: String,
sourceID: String,
transcode: TranscodeSettings
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
// Jellyfin rechnet in Bit, die App in Kilobit.
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
let profile: [String: Any] = [
"MaxStreamingBitrate": bitsPerSecond,
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
// zu lassen hieße, die Entscheidung zweimal zu treffen.
"DirectPlayProfiles": [],
"TranscodingProfiles": [[
"Type": "Video",
"Container": "ts",
"Protocol": "hls",
"VideoCodec": "h264",
"AudioCodec": "aac,mp3",
"Context": "Streaming",
"MaxAudioChannels": "2",
"MinSegments": 1,
"BreakOnNonKeyFrames": true,
]],
"SubtitleProfiles": [
["Format": "vtt", "Method": "Hls"],
["Format": "srt", "Method": "External"],
],
]
let body: Data
do {
body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile])
} catch {
throw .decoding(description: String(describing: error))
}
return try await client.send(
JellyfinRequest(
method: .post,
path: "/Items/\(itemID)/PlaybackInfo",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "mediaSourceId", value: sourceID),
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
// über den Startpunkt entscheiden: der Server ließe den
// Strom dort beginnen, und der Player spränge anschließend
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
// Minuten Fortschritt landete man bei zweiundvierzig.
// Gesprungen wird an genau einer Stelle, und das ist der
// Player.
URLQueryItem(name: "enableDirectPlay", value: "false"),
URLQueryItem(name: "enableDirectStream", value: "false"),
URLQueryItem(name: "enableTranscoding", value: "true"),
],
body: body
),
as: JellyfinPlaybackInfo.self
)
}
/// Hängt die gewünschte Bildhöhe an die Adresse des Servers.
///
/// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht
/// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits
/// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei
/// gleichnamigen Werten gilt beim Server der erste, und das wäre dann
/// gerade nicht der gewählte.
///
/// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite
/// Zahl.
static func limiting(height resolution: String?, in url: URL) -> URL {
guard let resolution,
let height = resolution.split(separator: "x").last.flatMap({ Int($0) }),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return url }
var items = (components.queryItems ?? []).filter {
let name = $0.name.lowercased()
return name != "maxheight" && name != "maxwidth"
}
items.append(URLQueryItem(name: "maxHeight", value: String(height)))
components.queryItems = items
return components.url ?? url
}
}
/// Was der Server für eine Wiedergabe vorbereitet hat.
public struct JellyfinPlaybackInfo: Decodable, Sendable {
public let mediaSources: [JellyfinMediaSource]?
public let playSessionID: String?
private enum CodingKeys: String, CodingKey {
case mediaSources = "MediaSources"
case playSessionID = "PlaySessionId"
}
}
@@ -3,8 +3,8 @@ import MediaKit
/// Jellyfin als `MediaBackend`.
public struct JellyfinBackend: MediaBackend {
private let client: JellyfinClient
private let account: JellyfinAccount
let client: JellyfinClient
let account: JellyfinAccount
private let images = JellyfinImagePaths()
public let serverName: String
@@ -251,12 +251,15 @@ public struct JellyfinBackend: MediaBackend {
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
guard let sourceID = file.key else { return nil }
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
// die Kennungen sind dann dieselbe — und bei zweien sofort.
guard let itemID = file.itemID ?? file.key else { return nil }
return client.url(
path: "/Videos/\(sourceID)/stream",
path: "/Videos/\(itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: sourceID),
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
],
includingToken: account.token
)
@@ -60,6 +60,12 @@ public actor JellyfinClient {
self.token = token
}
/// Unter welcher Kennung der Server dieses Gerät führt.
///
/// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät
/// **und** Sitzung angehalten, nicht über die Sitzung allein.
public nonisolated var deviceID: String { identity.deviceID }
public nonisolated func authorization(token: String?) -> String {
identity.authorizationHeader(token: token)
}
@@ -118,7 +118,8 @@ extension JellyfinMediaSource {
// trennt — hier ist die zweite eben einelementig.
MediaFile(
id: id ?? itemID,
key: id,
key: id ?? itemID,
itemID: itemID,
container: container,
size: size,
duration: runTimeTicks?.millisecondsFromTicks,
@@ -2,6 +2,22 @@
"sourceLanguage": "en",
"version": "1.0",
"strings": {
"player.transcodeUnavailable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat keine umgerechnete Fassung angeboten."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server didn't offer a transcoded version."
}
}
}
},
"row.continueWatching": {
"localizations": {
"de": {
@@ -50,6 +66,22 @@
}
}
},
"search.episodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Folgen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episodes"
}
}
}
},
"search.movies": {
"localizations": {
"de": {
@@ -81,22 +113,6 @@
}
}
}
},
"search.episodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Folgen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episodes"
}
}
}
}
}
}
@@ -59,6 +59,28 @@ public protocol MediaBackend: Sendable {
resolution: String?
) -> URL?
// MARK: - Abspielen
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
///
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
///
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
/// niemand mehr zusieht.
func endPlayback(sessionID: String) async
/// Meldet, wo man gerade steht.
///
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
/// wäre die schlechtere Hälfte des Tauschs.
func reportProgress(_ report: PlaybackReport) async
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
@@ -2,7 +2,7 @@ import Foundation
/// Was beim Sprechen mit einem Server schiefgehen kann.
///
/// Dieselben sechs Fälle galten schon für Plex — sie sind nicht serverbezogen,
/// Sechs der sieben Fälle galten schon für Plex — sie sind nicht serverbezogen,
/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits
@@ -19,4 +19,11 @@ public enum MediaBackendError: Error, Equatable, Sendable {
case transport(description: String)
/// Der Server hat geantwortet, aber anders als erwartet.
case decoding(description: String)
/// Der Server hat verstanden und abgelehnt — mit eigener Begründung.
///
/// Nicht `.server(status:)`: dort steht eine Zahl, hier ein Satz, den der
/// Server selbst formuliert hat. Beim Live-TV war genau dieser Satz die
/// einzige brauchbare Spur — und er ging verloren, solange es diesen Fall
/// nicht gab.
case refused(reason: String)
}
@@ -218,7 +218,7 @@ public struct MediaPerson: Sendable, Equatable, Identifiable {
}
/// Ein Abschnitt, den man überspringen möchte.
public struct MediaMarker: Sendable, Equatable {
public struct MediaMarker: Sendable, Equatable, Identifiable {
public enum Kind: String, Sendable {
case intro, credits, commercial, unknown
}
@@ -236,6 +236,17 @@ public struct MediaMarker: Sendable, Equatable {
public func contains(_ milliseconds: Int) -> Bool {
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
}
/// Der Anfang taugt als Kennung.
///
/// Zwei Marken derselben Sendung können nicht an derselben Stelle
/// beginnen, und der Player braucht die Kennung nur, um sich zu merken,
/// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein
/// Rücksprung ins Intro es sofort wieder weg.
public var id: Int { startMilliseconds }
public var isIntro: Bool { kind == .intro }
public var isCredits: Bool { kind == .credits }
}
/// Ein zusätzliches Bild zu einem Titel.
@@ -0,0 +1,117 @@
import Foundation
/// Was abgespielt werden soll und wie.
///
/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im
/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der
/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher
/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und
/// konnte deshalb mit keinem anderen Server reden.
public struct PlaybackIntent: Sendable, Equatable {
public let itemID: String
/// Welche Fassung — ein Film kann mehrfach vorliegen.
public let versionIndex: Int
/// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin
/// Quellen; beide finden sich hierüber wieder.
public let file: MediaFile
/// `nil` heißt: unverändert übertragen.
public let transcode: TranscodeSettings?
public let startTime: Duration
/// Ob der Server im selben Netz steht. Plex rechnet dann anders.
public let isLocalConnection: Bool
public init(
itemID: String,
versionIndex: Int,
file: MediaFile,
transcode: TranscodeSettings? = nil,
startTime: Duration = .zero,
isLocalConnection: Bool = false
) {
self.itemID = itemID
self.versionIndex = versionIndex
self.file = file
self.transcode = transcode
self.startTime = startTime
self.isLocalConnection = isLocalConnection
}
}
/// Wie weit heruntergerechnet werden soll.
public struct TranscodeSettings: Sendable, Equatable {
/// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich".
public let maximumBitrate: Int?
/// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist.
public let resolution: String?
public init(maximumBitrate: Int?, resolution: String?) {
self.maximumBitrate = maximumBitrate
self.resolution = resolution
}
}
/// Was der Server für diese Wiedergabe bereitgestellt hat.
public struct PlaybackTarget: Sendable, Equatable {
public let url: URL
/// Die Sitzung, die der Server dafür angelegt hat.
///
/// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter,
/// wenn längst niemand mehr zusieht.
public let sessionID: String?
/// Welche Fassung gespielt wird.
///
/// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst
/// ordnet es die Meldung keiner Wiedergabe zu.
public let versionID: String?
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
self.url = url
self.sessionID = sessionID
self.versionID = versionID
}
}
/// In welchem Zustand sich die Wiedergabe befindet.
public enum PlaybackReportState: String, Sendable {
/// Der erste Bericht einer Wiedergabe.
///
/// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn
/// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter
/// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex
/// unterscheidet das nicht und behandelt ihn wie „spielt".
case started
case playing
case paused
/// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem
/// Server aus „wird gerade abgespielt" heraus.
case stopped
case buffering
}
/// Wo man gerade steht.
public struct PlaybackReport: Sendable, Equatable {
public let itemID: String
public let sessionID: String?
public let versionID: String?
public let state: PlaybackReportState
public let time: Duration
public let duration: Duration
public init(
itemID: String,
sessionID: String? = nil,
versionID: String? = nil,
state: PlaybackReportState,
time: Duration,
duration: Duration
) {
self.itemID = itemID
self.sessionID = sessionID
self.versionID = versionID
self.state = state
self.time = time
self.duration = duration
}
}
extension PlaybackReportState: Equatable {}
@@ -107,6 +107,14 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
public let id: String
/// Der Pfad, über den die Datei abgerufen wird.
public let key: String?
/// Zu welchem Titel diese Datei gehört.
///
/// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin
/// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne
/// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel
/// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem
/// gemacht hätte, den man erst spät findet.
public let itemID: String?
public let container: String?
/// Größe in Bytes.
///
@@ -121,6 +129,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
public init(
id: String,
key: String? = nil,
itemID: String? = nil,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
@@ -129,6 +138,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
) {
self.id = id
self.key = key
self.itemID = itemID
self.container = container
self.size = size
self.duration = duration
@@ -181,7 +181,11 @@ extension PlexServer {
query: [
URLQueryItem(name: "ratingKey", value: ratingKey),
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
URLQueryItem(name: "state", value: state.rawValue),
// Plex kennt keinen eigenen Zustand für den Beginn.
URLQueryItem(
name: "state",
value: state == .started ? "playing" : state.rawValue
),
URLQueryItem(name: "time", value: String(time.milliseconds)),
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
],
@@ -227,15 +231,6 @@ extension PlexServer {
}
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
public enum PlaybackReportState: String, Sendable {
case playing
case paused
/// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus
/// „wird gerade abgespielt" entfernen.
case stopped
case buffering
}
extension PlexServer {
/// Die nächste Folge nach der angegebenen.
///
@@ -122,6 +122,53 @@ public struct PlexBackend: MediaBackend {
)
}
// MARK: - Abspielen
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
guard let transcode = intent.transcode else {
// Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt
// nichts anzuhalten, was der Server angelegt hätte.
guard let key = intent.file.key,
let url = server.directPlayURL(part: PlexPart(id: 0, key: key))
else { throw .notFound }
return PlaybackTarget(url: url)
}
let request = TranscodeRequest(
ratingKey: intent.itemID,
mediaIndex: intent.versionIndex,
maximumBitrate: transcode.maximumBitrate,
resolution: transcode.resolution,
isLocal: intent.isLocalConnection
)
// 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.
let decision = try await mapping { try await server.transcodeDecision(for: request) }
guard decision.isAllowed else {
throw .refused(reason: decision.explanation ?? "")
}
guard let url = server.transcodeURL(for: request) else { throw .notFound }
return PlaybackTarget(url: url, sessionID: request.sessionIdentifier)
}
public func endPlayback(sessionID: String) async {
await server.stopTranscode(sessionIdentifier: sessionID)
}
public func reportProgress(_ report: PlaybackReport) async {
await server.reportProgress(
ratingKey: report.itemID,
state: report.state,
time: report.time,
duration: report.duration
)
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
@@ -0,0 +1,208 @@
import Foundation
import Testing
import MediaKit
@testable import JellyfinKit
/// Was die App dem Server zum Abspielen schickt — und was sie daraus macht.
///
/// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb
/// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren:
/// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt.
@Suite("Wiedergabe bei Jellyfin")
struct PlaybackTests {
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
let client = JellyfinClient(
baseURL: URL(string: "http://server:8096")!,
identity: JellyfinIdentity(
client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1"
),
token: "tok",
transport: transport
)
return JellyfinBackend(
client: client,
account: JellyfinAccount(
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
),
serverName: "Attrappe"
)
}
private var file: MediaFile {
MediaFile(id: "src-1", key: "src-1", container: "mp4")
}
@Test("Unverändert: die Datei selbst, mit Token in der Adresse")
func buildsDirectPlayURL() async throws {
let target = try await backend(RecordingTransport())
.preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file))
let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false))
#expect(url.path == "/Videos/film-1/stream")
// Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht
// in der Adresse, antwortet der Server mit „nicht angemeldet".
#expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true)
#expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true)
#expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true)
// Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den
// Fortschritt keiner Wiedergabe zu.
#expect(target.sessionID != nil)
#expect(target.versionID == "src-1")
}
@Test("Umgerechnet: die Adresse kommt vom Server")
func usesServerTranscodingURL() async throws {
let transport = RecordingTransport(response: """
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
"PlaySessionId": "ps-7"}
""")
let target = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1",
versionIndex: 0,
file: file,
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
))
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8"))
#expect(target.sessionID == "ps-7")
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
// wenig, und aus vier Megabit werden vier Kilobit.
let asked = try #require(transport.lastRequest)
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
#expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000")))
#expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false")))
#expect(asked.httpMethod == "POST")
}
@Test("Lehnt der Server ab, steht der Grund im Fehler")
func refusesWithoutTranscodingURL() async throws {
let transport = RecordingTransport(response: """
{"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"}
""")
await #expect(throws: MediaBackendError.self) {
try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1",
versionIndex: 0,
file: file,
transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil)
))
}
}
@Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen")
func replacesExistingHeight() {
let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")!
let limited = JellyfinBackend.limiting(height: "1280x720", in: url)
let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? []
// Bei zwei gleichnamigen Werten gilt beim Server der erste — und das
// wäre dann gerade nicht der gewählte.
#expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1)
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
#expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264")))
}
@Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist")
func leavesURLAloneWithoutResolution() {
let url = URL(string: "http://s/main.m3u8?videoCodec=h264")!
#expect(JellyfinBackend.limiting(height: nil, in: url) == url)
}
@Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus")
func reportsProgressInTicks() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1",
sessionID: "ps-7",
versionID: "src-1",
state: .playing,
time: .seconds(90),
duration: .seconds(600)
))
let asked = try #require(transport.lastRequest)
#expect(asked.url?.path == "/Sessions/Playing/Progress")
let body = try #require(asked.httpBody)
let fields = try #require(
try JSONSerialization.jsonObject(with: body) as? [String: Any]
)
// 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde.
#expect(fields["PositionTicks"] as? Int == 900_000_000)
#expect(fields["PlaySessionId"] as? String == "ps-7")
#expect(fields["MediaSourceId"] as? String == "src-1")
#expect(fields["IsPaused"] as? Bool == false)
}
@Test("Der erste Bericht legt die Sitzung an")
func reportsStartSeparately() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .started, time: .zero, duration: .seconds(600)
))
// Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade",
// und der Server kennt die Sitzung nicht, unter der alles Weitere
// gemeldet wird.
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing")
}
@Test("Der letzte Bericht geht an einen eigenen Endpunkt")
func reportsStopSeparately() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600)
))
// „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus
// „wird gerade abgespielt" heraus.
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped")
}
@Test("Abräumen nennt Gerät und Sitzung")
func endsEncodingWithDeviceAndSession() async throws {
let transport = RecordingTransport()
await backend(transport).endPlayback(sessionID: "ps-7")
let asked = try #require(transport.lastRequest)
#expect(asked.httpMethod == "DELETE")
#expect(asked.url?.path == "/Videos/ActiveEncodings")
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
// Über die Sitzung allein hält Jellyfin nichts an.
#expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1")))
#expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7")))
}
}
/// Ein Transport, der nichts verschickt und alles behält.
private final class RecordingTransport: JellyfinTransport, @unchecked Sendable {
private let response: String
private let lock = NSLock()
private var recorded: URLRequest?
init(response: String = "{}") {
self.response = response
}
var lastRequest: URLRequest? {
lock.withLock { recorded }
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lock.withLock { recorded = request }
return (
Data(response.utf8),
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}
+12
View File
@@ -18,5 +18,17 @@ entsteht. Das ist Absicht: so lässt sich auf einen Blick erkennen, ob der
Aufmacher tatsächlich weiterwandert und ob eine Kachel das Bild zeigt, das zu
ihr gehört.
Abgespielt wird `sample.mp4` — zehn Sekunden Farbverlauf, mit einem kleinen
Swift-Programm erzeugt. Der Server beantwortet Bereichsanfragen, weil der
Systemplayer eine Datei nie am Stück holt.
Was hereinkommt, steht unter `http://127.0.0.1:8096/__reports`: jede
Wiedergabemeldung mit ihrem Anfragekörper. Damit lässt sich prüfen, ob die App
den Beginn meldet, ob der Fortschritt in Ticks ankommt und ob am Ende
abgeräumt wird.
Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
wie bei einem Server, der eine Datei noch nicht untersucht hat.
Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über
die Leitung.
Binary file not shown.
+78 -2
View File
@@ -1,11 +1,18 @@
"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und
stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt."""
import json, struct, zlib, hashlib
import json, os, struct, zlib, hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
USER_ID = "user-1"
TOKEN = "stub-token"
SAMPLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample.mp4")
# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App
# den Fortschritt tatsächlich meldet.
REPORTS = []
# Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
# auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen.
UNANALYSED = "--unanalysed" in os.sys.argv
TITLES = [
("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."),
@@ -27,6 +34,22 @@ def item(key, name, kind, overview, *, resume=None, series=None):
"BackdropImageTags": ["b-" + key],
"Genres": ["Drama"],
"UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0},
"MediaSources": [{
"Id": "src-" + key,
"Container": "mp4",
"Size": 28135,
"Bitrate": 1_200_000,
"RunTimeTicks": 10 * 10_000_000,
"SupportsDirectPlay": True,
"SupportsDirectStream": True,
"SupportsTranscoding": True,
"MediaStreams": [] if UNANALYSED else [
{"Index": 0, "Type": "Video", "Codec": "h264",
"Width": 640, "Height": 360},
{"Index": 1, "Type": "Audio", "Codec": "aac", "Channels": 2,
"Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True},
],
}],
}
if resume:
d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume)
@@ -106,6 +129,14 @@ class Handler(BaseHTTPRequestHandler):
item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.",
series=("s1", "Küstenwache", 4)),
]))
if p.startswith("/Videos/") and p.endswith("/stream"):
return self.serve_file(SAMPLE, "video/mp4")
if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4:
key = p.rsplit("/", 1)[-1]
match = next((t for t in TITLES if t[0] == key), None)
if match:
return self.reply(item(*match))
return self.reply(item(key, "Unbekannt", "Movie", "Aus dem Nichts."))
if p.startswith("/Items/") and "/Images/" in p:
key = p.split("/")[2]
return self.reply(png(colour_for(key)), "image/png")
@@ -114,20 +145,65 @@ class Handler(BaseHTTPRequestHandler):
kind = "Series" if parent == "lib-shows" else "Movie"
chosen = [t for t in TITLES if t[2] == kind] or TITLES
return self.reply(page([item(*t) for t in chosen]))
if p == "/__reports":
return self.reply(REPORTS)
if p == "/Genres":
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
return self.reply(page([]))
def serve_file(self, path, content_type):
"""Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück."""
with open(path, "rb") as handle:
data = handle.read()
rng = self.headers.get("Range")
start, end = 0, len(data) - 1
status = 200
if rng and rng.startswith("bytes="):
piece = rng[6:].split("-")
start = int(piece[0] or 0)
if len(piece) > 1 and piece[1]:
end = min(int(piece[1]), len(data) - 1)
status = 206
body = data[start:end + 1]
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(body)))
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
REPORTS.append(("encoding-stopped", self.path))
self.reply({})
def do_POST(self):
url = urlparse(self.path)
length = int(self.headers.get("Content-Length") or 0)
self.rfile.read(length)
body = self.rfile.read(length)
if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"):
return self.reply({
"AccessToken": TOKEN,
"User": {"Id": USER_ID, "Name": "Testkonto",
"Policy": {"IsAdministrator": True}},
})
if url.path.startswith("/Sessions/Playing"):
REPORTS.append((url.path, body.decode() if body else ""))
return self.reply({})
if url.path.startswith("/Items/") and url.path.endswith("/PlaybackInfo"):
key = url.path.split("/")[2]
# Keine echte Umrechnung — die Adresse zeigt auf dieselbe Datei.
# Geprüft wird hier der Weg, nicht der Transcoder.
return self.reply({
"MediaSources": [{
"Id": "src-" + key,
"Container": "mp4",
"SupportsTranscoding": True,
"TranscodingUrl": f"/Videos/{key}/stream?static=true&ApiKey={TOKEN}",
}],
"PlaySessionId": "ps-" + key,
})
if url.path == "/QuickConnect/Initiate":
POLLS["count"] = 0
return self.reply({"Secret": "s3cr3t", "Code": "428193"})