Sechs Reparaturversuche haben nichts bewirkt, weil jeder an einer Stelle
ansetzte, die gar nicht die Ursache war. Eine systematische Untersuchung —
45 Hypothesen, jede von drei Skeptikern gegengeprüft — hat fünf voneinander
unabhängige Defekte auf demselben Weg gefunden. Jeder einzelne genügt.
**1. Es wurde eine Playlist bestellt, die keine Untertitelspur tragen kann.**
Jellyfins TranscodingUrl lautet `main.m3u8` — eine Media Playlist, also eine
Liste von Schnipseln. `#EXT-X-MEDIA`, die Zeile, mit der ein Server eine
Untertitelspur ankündigt, darf nach RFC 8216 nur in einer Master Playlist
stehen. Der Wechsel main→master hing aber an der Leiter, und die wird beim
Umpacken und bei jeder von Hand gewählten Stufe nicht bestellt. Ein Matroska
geht immer über das Umpacken — die halbe Sammlung konnte strukturell keine
Untertitel haben.
Der Kommentar daneben war überholt und hat den Fehler verdeckt: er stammte
aus der Zeit, als Umpacken eine fortlaufende MP4 war. Seit es über HLS läuft,
ist die Master-Liste genau richtig. Pfadwechsel und Leiter sind jetzt zwei
Fragen, nicht eine.
**2. Der lokale Server etikettierte jeden Schnipsel als video/mp4.** Auch die
WebVTT-Schnipsel der Untertitelspur. So angekündigt gehen sie bei
AVFoundation an den falschen Parser, finden keine Box, und es entsteht
lautlos keine Einblendung — die Spur steht im Blatt, ist ausgewählt, bleibt
leer. Der Vorrat behält jetzt die Sorte der Gegenstelle; die Endung ist nur
noch der Notnagel.
**3. Nach dem ersten Untertitel wählte niemand mehr etwas aus.**
`selectedSubtitleTrack` wurde beim Neuaufbau nie zurückgesetzt, und beide
Wähler prüfen genau darauf. Eine frische Engine hat aber nichts ausgewählt —
also blieb sie für den Rest des Films stumm, während das Symbol „ein" zeigte.
Auch das Nachfassen im Sekundentakt war damit stillgelegt. Die Bestellung
(`selectedSubtitleStream`) bleibt, die Merker fallen.
**4. Die Schutzmarke war nach 250 ms tot.** `restart()` setzte sie als
Allererstes — und wartete danach auf einen echten Netzaufruf mit fünf
Sekunden Zeitgrenze, bevor es den alten Ereignisstrom abbestellte. In diesem
Fenster meldete der alte Player weiter seine Stelle, und die Prüfung „sind
wir angekommen" traf sofort zu. Die Marke war gelöscht, bevor der neue Stream
bestellt war; danach setzte jede Meldung des anlaufenden Streams die Stelle
auf null, und der nächste Neuaufbau merkte sich den Filmanfang. Das war der
Sprung — und der Grund, warum mein Fix von 88ba1a5 wirkungslos blieb.
**5. Der Sprung wurde einmal versucht und nie geprüft.** Der Rückgabewert von
`seek` wurde verworfen, die gemerkte Stelle verbraucht, bevor gesprungen
wurde, und framegenaue Toleranz verlangt — in einen Stream, den der Server
gerade erst erzeugt. Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt
ist, die Toleranz liegt bei einer halben Sekunde, und jedes Wachsen des
springbaren Bereichs ist ein neuer Anlass.
Belegt statt vermutet: ein neuer Test startet den lokalen Server gegen einen
eigenen Herkunftsserver und liest die Antworten Byte für Byte — ohne Gerät,
ohne Simulator, in 0,03 Sekunden. Er hat Fehler 2 vor der Reparatur
reproduziert und danach bestätigt. Fünf weitere Tests halten Fehler 1 fest.
Zwei alte Tests hielten das fehlerhafte Verhalten fest und waren damit
mitschuldig; beide sind mitgeändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
16 KiB
Swift
343 lines
16 KiB
Swift
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,
|
|
audioStreamIndex: intent.audioStreamIndex,
|
|
subtitleStreamIndex: intent.subtitleStreamIndex,
|
|
remux: transcode.prefersRemux
|
|
)
|
|
|
|
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
|
|
))
|
|
}
|
|
|
|
var adresse = Self.limiting(height: transcode.resolution, in: url)
|
|
|
|
// **Immer die Master-Liste, auch ohne Leiter.**
|
|
//
|
|
// Der Kommentar, der hier stand, war überholt und hat einen Fehler
|
|
// verdeckt: er stammte aus der Zeit, als Umpacken eine fortlaufende
|
|
// MP4 war — da wäre eine Abspielliste tatsächlich verkehrt gewesen.
|
|
// Seit das Umpacken über HLS läuft, ist sie genau richtig.
|
|
//
|
|
// Und sie ist nicht nur richtig, sie ist notwendig: eine
|
|
// Untertitelspur wird mit `#EXT-X-MEDIA` angekündigt, und das steht
|
|
// ausschliesslich in einer Master-Liste. Auf `main.m3u8` kann es
|
|
// strukturell keine Untertitel geben.
|
|
//
|
|
// Die **Leiter** bleibt davon unberührt. Sie ist eine eigene Frage
|
|
// und wird weiterhin nur bei „automatisch" und ohne Umpacken
|
|
// bestellt — beim Umpacken gibt es nur eine Fassung, nämlich die
|
|
// Datei selbst.
|
|
if transcode.prefersAdaptive, !transcode.prefersRemux {
|
|
adresse = Self.adaptive(adresse)
|
|
} else {
|
|
adresse = Self.masterPlaylist(adresse)
|
|
}
|
|
|
|
return PlaybackTarget(
|
|
url: adresse,
|
|
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
|
|
|
|
/// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben
|
|
/// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und
|
|
/// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft.
|
|
func playbackInfo(
|
|
itemID: String,
|
|
sourceID: String,
|
|
transcode: TranscodeSettings,
|
|
audioStreamIndex: Int? = nil,
|
|
subtitleStreamIndex: Int? = nil,
|
|
remux: Bool = false
|
|
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
|
// Jellyfin rechnet in Bit, die App in Kilobit.
|
|
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
|
|
|
// **Umpacken: dieselben Ströme, andere Schachtel.**
|
|
//
|
|
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
|
|
// kann: trifft der Server eine Übereinstimmung, **kopiert** er den
|
|
// Strom, statt ihn neu zu rechnen. Genau dafür ist der Weg da — ein
|
|
// Matroska-Rip mit HEVC und AC3 kommt so bei AVPlayer an, mit
|
|
// Bild-in-Bild und AirPlay, ohne dass ein Bild angefasst wurde.
|
|
//
|
|
// **Und doch als HLS, obwohl eine fortlaufende Datei schöner wäre.**
|
|
// Der Versuch mit `Protocol: http` ist am Gerät gescheitert:
|
|
// AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen —
|
|
// er will hineinspringen können. Eine MP4, die ffmpeg gerade erst
|
|
// erzeugt, kann das nicht: sie hat weder Länge noch ein
|
|
// Inhaltsverzeichnis am Anfang. Die Antwort war
|
|
// `AVErrorServerIncorrectlyConfigured` (-11850), und zwar sofort.
|
|
//
|
|
// Damit bleibt vom ursprünglichen Ziel das Umpacken, nicht das weite
|
|
// Vorausladen. Das ist immer noch der grössere Teil des Gewinns.
|
|
//
|
|
// `Container: mp4` statt `ts`: als fragmentierte MP4 trägt HLS auch
|
|
// HEVC. In MPEG-TS wäre dafür eine Neukodierung nötig — also genau
|
|
// das, was hier vermieden werden soll.
|
|
let umpacken: [String: Any] = [
|
|
"Type": "Video",
|
|
"Container": "mp4",
|
|
"Protocol": "hls",
|
|
"VideoCodec": "h264,hevc",
|
|
"AudioCodec": "aac,ac3,eac3,mp3",
|
|
"Context": "Streaming",
|
|
"MaxAudioChannels": "8",
|
|
"MinSegments": 1,
|
|
]
|
|
|
|
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": remux ? [umpacken] : [[
|
|
"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
|
|
// Stream 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.
|
|
// **Die Tonspur ausdrücklich nennen.** Ohne sie wählt
|
|
// der Server, und bei einem umgerechneten Stream kommt
|
|
// danach nur noch diese eine an — dann ist im Player
|
|
// nichts mehr umzustellen. Die Sprachwahl muss hier
|
|
// stattfinden oder gar nicht.
|
|
URLQueryItem(name: "enableDirectPlay", value: "false"),
|
|
URLQueryItem(name: "enableDirectStream", value: "false"),
|
|
URLQueryItem(name: "enableTranscoding", value: "true"),
|
|
] + (audioStreamIndex.map {
|
|
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
|
|
} ?? []) + (subtitleStreamIndex.map {
|
|
[URLQueryItem(name: "subtitleStreamIndex", value: String($0))]
|
|
} ?? []),
|
|
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
|
|
}
|
|
|
|
/// Bestellt eine **Leiter** statt einer einzelnen Stufe.
|
|
///
|
|
/// Zwei Handgriffe, und beide sind nötig:
|
|
///
|
|
/// **`main.m3u8` wird zu `master.m3u8`.** Die eine ist die Liste der
|
|
/// Schnipsel *einer* Stufe, die andere die Liste der Stufen. Nur auf der
|
|
/// zweiten kann der Parameter überhaupt wirken — auf der ersten gibt es
|
|
/// keine Varianten, über die zu entscheiden wäre. Heißt die Adresse schon
|
|
/// `master.m3u8`, bleibt sie.
|
|
///
|
|
/// **Der Parameter wird gesetzt, nicht danebengestellt.** Die
|
|
/// Voreinstellung im Server ist `false`; ohne ihn kommt in jedem Fall nur
|
|
/// eine Stufe. Und bei zwei gleichnamigen Werten gilt beim Server der
|
|
/// erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben.
|
|
///
|
|
/// **Was das nicht kann.** Jellyfin verweigert die Leiter im lokalen Netz
|
|
/// grundsätzlich, und zwar mit Absicht: „Within the local network this
|
|
/// will likely do more harm than good." Ein schwaches WLAN zählt dort mit.
|
|
/// Ebenso fällt sie weg, wenn Bild **oder** Ton nur kopiert statt neu
|
|
/// gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht
|
|
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
|
|
/// das ist die einzige belastbare Auskunft darüber.
|
|
static func adaptive(_ url: URL) -> URL {
|
|
guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
|
|
else { return masterPlaylist(url) }
|
|
var items = (components.queryItems ?? []).filter {
|
|
$0.name.lowercased() != "enableadaptivebitratestreaming"
|
|
}
|
|
items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))
|
|
components.queryItems = items
|
|
return components.url ?? url
|
|
}
|
|
|
|
/// Von der Liste **einer** Stufe zur Liste **aller** Spuren.
|
|
///
|
|
/// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine
|
|
/// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8`
|
|
/// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein
|
|
/// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine
|
|
/// **Untertitelspur** ankündigt.
|
|
///
|
|
/// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel
|
|
/// geben, ganz gleich was bestellt wurde und was der Server liefern
|
|
/// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand
|
|
/// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse
|
|
/// `main.m3u8`, weil der Wechsel an der Leiter hing.
|
|
///
|
|
/// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die
|
|
/// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage
|
|
/// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine
|
|
/// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung.
|
|
static func masterPlaylist(_ url: URL) -> URL {
|
|
guard url.lastPathComponent == "main.m3u8" else { return url }
|
|
return url.deletingLastPathComponent().appending(path: "master.m3u8")
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|
|
}
|