Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug nichts — und dahinter lagen die Dateien, die man genau für diesen Fall heruntergeladen hatte. Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät, die Mediathekenliste auch, und die Startseite kommt neuerdings aus demselben Speicher. Oben die Downloads, darunter der Stand vom letzten Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf bleibt an, weil die Datei ja da ist. Und was man offline geschaut hat, zählt: Fortschritt, den der Server nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
16 KiB
Swift
348 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.
|
|
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 -> Bool {
|
|
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 false }
|
|
do {
|
|
try await client.send(JellyfinRequest(
|
|
method: .post,
|
|
path: path,
|
|
body: body,
|
|
timeout: .seconds(10)
|
|
))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|