Der Befund, der die ganze Umsetzung bestimmt: `AudioStreamIndex` kam in der PlaybackInfo-Anfrage **nicht vor**. Der Server wählte, wir nahmen was kam — und bei einem umgerechneten Stream kommt danach nur noch diese eine Tonspur an. `selectAudioTrack` schob seither ins Leere, weil im Player eine Liste mit einem Eintrag stand. Damit ist auch erklärt, warum die Spracheinstellung beim ZDF-Fall nichts ausrichten konnte: sie lief auf einer Auswahl, die es gar nicht gab. `PlaybackIntent` trägt die Spurnummer jetzt mit, und die Aushandlung nennt sie ausdrücklich. Zwei Tests halten fest, dass sie ankommt — und dass sie ohne Wunsch weggelassen wird, statt eine Null zu schicken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
11 KiB
Swift
266 lines
11 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
|
|
)
|
|
|
|
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)
|
|
if transcode.prefersAdaptive { 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
|
|
) 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
|
|
// 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))]
|
|
} ?? []),
|
|
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"
|
|
}
|
|
}
|