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 ) 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 )) } return PlaybackTarget( url: Self.limiting(height: transcode.resolution, in: url), 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 ) 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 // Strom 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. URLQueryItem(name: "enableDirectPlay", value: "false"), URLQueryItem(name: "enableDirectStream", value: "false"), URLQueryItem(name: "enableTranscoding", value: "true"), ], 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 } } /// 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" } }