Files
Flaro/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift
T
ScarriffleandClaude Opus 5 46550fc30b Umgepackt wird als HLS, und ein Fehlschlag faellt auf VLC zurueck
Der Versuch mit einer fortlaufenden MP4 ist am Geraet gescheitert:
AVErrorServerIncorrectlyConfigured (-11850), sofort. Der Grund ist
einleuchtend, sobald man ihn kennt — AVPlayer verlangt bei einer
fortlaufenden Datei Bereichsanfragen, er will hineinspringen koennen. Eine
MP4, die ffmpeg gerade erst erzeugt, kann das nicht: sie hat weder Laenge
noch ein Inhaltsverzeichnis am Anfang.

Das war eine Annahme, die ich haette messen sollen, bevor ich darauf baue.

Vom Ziel bleibt damit das Umpacken, nicht das weite Vorausladen — und das
ist immer noch der groessere Teil: ein Matroska-Rip mit HEVC und AC3 landet
bei AVPlayer statt bei VLC, mit Bild-in-Bild und AirPlay, ohne dass ein Bild
neu gerechnet wird. `Container: mp4` statt `ts`, weil HLS als fragmentierte
MP4 auch HEVC traegt; in MPEG-TS waere dafuer eine Neukodierung noetig,
also genau das, was hier vermieden werden soll.

Dazu ein Rueckzugsweg, der vorher gefehlt hat. Ob der Server die Stroeme
wirklich kopieren kann, entscheidet sich erst beim Ausliefern, und manche
Antworten nimmt AVPlayer nicht an. Das vorher zu wissen ist nicht moeglich;
es hinterher aufzufangen schon. Scheitert das Umpacken, laeuft derselbe
Titel einmalig mit VLC an, statt den Nutzer vor eine schwarze Wand mit
einer Fehlernummer zu stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 20:11:49 +02:00

310 lines
14 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)
// Beim Umpacken **keine** Leiter: es gibt nur eine Fassung, nämlich
// die Datei selbst. Und `master.m3u8` wäre ohnehin das Gegenteil
// dessen, worum es geht — eine Abspielliste statt einer Datei.
if transcode.prefersAdaptive, !transcode.prefersRemux {
adresse = Self.adaptive(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 {
var url = url
if url.lastPathComponent == "main.m3u8" {
url = url.deletingLastPathComponent().appending(path: "master.m3u8")
}
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
var items = (components.queryItems ?? []).filter {
$0.name.lowercased() != "enableadaptivebitratestreaming"
}
items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))
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"
}
}