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" } }