import Foundation import MediaKit /// Jellyfin als `LiveTVBackend`. /// /// Deutlich kürzer als das Plex-Gegenstück, und das liegt nicht an weniger /// Sorgfalt: Jellyfin hat feste, beschriebene Endpunkte. Es gibt hier nichts /// zu suchen und darum auch nichts zu berichten, wenn die Suche scheitert. /// /// Deshalb bleiben `source` und `probes` in der Senderliste leer. Die /// Oberfläche zeigt den Diagnoseblock dann gar nicht erst — er ist die /// Antwort auf ein Problem, das es hier nicht gibt. extension JellyfinBackend: LiveTVBackend { // MARK: - Ob es etwas zu sehen gibt public func liveTVAvailability() async -> LiveTVAvailability { do { let info = try await client.send( JellyfinRequest(path: "/LiveTv/Info"), as: JellyfinLiveTVInfo.self ) // `IsEnabled` allein genügt nicht: der Server meldet es auch dann, // wenn zwar der Dienst läuft, aber kein Empfänger daran hängt. // Erst die Dienstliste sagt, ob tatsächlich etwas anliegt. guard info.isEnabled == true, !(info.services ?? []).isEmpty else { return .notConfigured } return .available } catch { // Ein Server ohne Live-TV-Unterstützung antwortet hier mit 404. // Das ist kein Fehler, sondern dieselbe Auskunft wie „nichts // eingerichtet" — und die Oberfläche soll sie erklären, nicht // einen Fehlerdialog zeigen. if case .notFound = error { return .notConfigured } return .failed(message: error.probeDescription) } } // MARK: - Sender public func liveChannels() async -> LiveChannelListing { do { let page = try await client.send( JellyfinRequest( path: "/LiveTv/Channels", query: [ URLQueryItem(name: "userId", value: account.userID), // Die laufende Sendung gleich mit. Ohne das bräuchte // die Senderliste einen zweiten Aufruf, nur um zu // zeigen, was gerade läuft. URLQueryItem(name: "addCurrentProgram", value: "true"), URLQueryItem(name: "enableImages", value: "true"), URLQueryItem(name: "enableUserData", value: "false"), ] ), as: JellyfinItemPage.self ) return LiveChannelListing(channels: page.items.map(liveChannel(from:))) } catch { return LiveChannelListing(channels: []) } } // MARK: - Programm public func livePrograms( channelIDs: [String], from: Date, to: Date ) async throws(MediaBackendError) -> [String: [LiveProgram]] { var query = [ URLQueryItem(name: "userId", value: account.userID), URLQueryItem(name: "minEndDate", value: Self.timestamp(from)), URLQueryItem(name: "maxStartDate", value: Self.timestamp(to)), URLQueryItem(name: "enableImages", value: "true"), URLQueryItem(name: "enableUserData", value: "false"), URLQueryItem(name: "sortBy", value: "StartDate"), ] // Leer heißt: alle. Eine leere Liste mitzuschicken hieße dagegen // „keiner", und dann käme nichts zurück. if !channelIDs.isEmpty { query.append(URLQueryItem(name: "channelIds", value: channelIDs.joined(separator: ","))) } let page = try await client.send( JellyfinRequest(path: "/LiveTv/Programs", query: query), as: JellyfinItemPage.self ) // Nach Sender gebündelt, weil das Raster genau so danach fragt. var out: [String: [LiveProgram]] = [:] for item in page.items { guard let program = liveProgram(from: item) else { continue } out[program.channelID, default: []].append(program) } for (channel, programs) in out { out[channel] = programs.sorted { $0.startsAt < $1.startsAt } } return out } // MARK: - Einschalten public func tune( channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int? ) async -> LiveTuneResult { let session = UUID().uuidString // Ein Sender ist für Jellyfin ein Eintrag wie jeder andere: derselbe // Weg wie beim Film, nur dass die Quelle nie endet. Der Server nennt // die Adresse; sie selbst zusammenzubauen wäre eine Vermutung darüber, // was er kopieren kann und was er rechnen muss. do { let info = try await playbackInfo( itemID: channel.id, sourceID: channel.id, transcode: TranscodeSettings(maximumBitrate: maximumBitrate, resolution: nil) ) let source = info.mediaSources?.first if let path = source?.transcodingURL, let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL { return LiveTuneResult( url: url, sessionID: info.playSessionID ?? session, subscriptionKey: info.playSessionID ) } // Kein Transcoder-Weg: dann unverändert. Manche Empfänger liefern // bereits einen Strom, den das Gerät ohne Umrechnung versteht. if let url = client.url( path: "/Videos/\(channel.id)/stream", query: [ URLQueryItem(name: "static", value: "true"), URLQueryItem(name: "mediaSourceId", value: source?.id ?? channel.id), ], includingToken: account.token ) { return LiveTuneResult( url: url, sessionID: info.playSessionID ?? session, subscriptionKey: info.playSessionID ) } return LiveTuneResult( url: nil, sessionID: session, trace: [LiveChannelProbe( path: "/Items/\(channel.id)/PlaybackInfo", result: "Der Server nannte weder eine Transcoder- noch eine Direktadresse" )] ) } catch { return LiveTuneResult( url: nil, sessionID: session, trace: [LiveChannelProbe( path: "/Items/\(channel.id)/PlaybackInfo", result: error.probeDescription )] ) } } public func endLiveSession(subscriptionKey: String) async { // Dieselbe Freigabe wie am Ende eines Films: der Transcoder rechnet // sonst weiter, und beim Live-TV bleibt dabei auch der Empfänger // belegt — davon gibt es weniger als von allem anderen. await endPlayback(sessionID: subscriptionKey) } // MARK: - Übersetzung private func liveChannel(from item: JellyfinItem) -> LiveChannel { LiveChannel( id: item.id, // `Number` steht am Sender, `ChannelNumber` an der Sendung. number: item.number ?? item.channelNumber, title: item.name ?? "—", artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]), currentProgram: item.currentProgram.flatMap { current in guard let start = current.startDate, let end = current.endDate else { return nil } return LiveProgram( id: current.id, channelID: item.id, title: current.name ?? "—", summary: current.overview, startsAt: start, endsAt: end ) } ) } /// Eine Sendung — oder `nil`, wenn sie keine Zeit hat. /// /// Ohne Anfang und Ende lässt sie sich nicht ins Raster legen, und ein /// Block ohne Ausdehnung wäre in der Anzeige ein unsichtbarer Streifen, /// den man nicht treffen kann. private func liveProgram(from item: JellyfinItem) -> LiveProgram? { guard let channelID = item.channelID, let start = item.startDate, let end = item.endDate else { return nil } var kind: LiveProgram.Kind = [] if item.isMovie == true { kind.insert(.movie) } if item.isSeries == true { kind.insert(.series) } if item.isNews == true { kind.insert(.news) } if item.isSports == true { kind.insert(.sports) } if item.isKids == true { kind.insert(.kids) } if item.isLive == true { kind.insert(.live) } return LiveProgram( id: item.id, channelID: channelID, title: item.name ?? "—", // Der Folgentitel steht bei Jellyfin dort, wo bei einer Serie der // Seriename stünde — die Sendung selbst trägt den Reihentitel. subtitle: item.seriesName == nil ? nil : item.name, summary: item.overview, startsAt: start, endsAt: end, artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]), seriesTimerID: item.seriesTimerID, kind: kind ) } /// Ein Zeitpunkt, wie Jellyfin ihn in der Adresse erwartet. static func timestamp(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } } // MARK: - Was der Server über Live-TV sagt /// Die Auskunft unter `/LiveTv/Info`. struct JellyfinLiveTVInfo: Decodable, Sendable { let isEnabled: Bool? /// Die eingerichteten Dienste. Leer heißt: kein Empfänger. let services: [Service]? struct Service: Decodable, Sendable { let name: String? private enum CodingKeys: String, CodingKey { case name = "Name" } } private enum CodingKeys: String, CodingKey { case isEnabled = "IsEnabled" case services = "Services" } } // MARK: - Fehler in der Spur extension MediaBackendError { /// Knapp und technisch — für die Diagnosespur, nicht für den Nutzer. /// /// Bewusst nicht dasselbe wie die Meldung, die in der Oberfläche steht: /// die soll erklären, was zu tun ist, diese soll festhalten, was passiert /// ist. Sie wird nicht übersetzt, weil sie in einem Bericht landet, den /// man weitergibt. var probeDescription: String { switch self { case .unauthorized: "nicht angemeldet" case .notFound: "404" case .noReachableConnection: "keine Verbindung" case .server(let status): "HTTP \(status)" case .transport(let description): description case .decoding(let description): "unerwartete Antwort: \(description)" case .refused(let reason): reason } } }