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