import Foundation import MediaKit /// Plex' Live-TV mit Gedächtnis. /// /// Ein Actor und kein Werttyp, und das hat einen einzigen Grund: `PlexBackend` /// ist ein `struct` und kann sich zwischen zwei Aufrufen nichts merken. Zum /// Einschalten braucht Plex aber den **echten** `PlexChannel` und den /// **echten** `PlexDVR` — an letzterem hängt die Sendernummer am Empfänger, /// und an der hängt, ob überhaupt ein Bild kommt. /// /// Ohne Gedächtnis müsste das Einschalten die Senderliste neu laden. Das ist /// bei Plex kein Abruf, sondern ein Suchlauf über vier Strategien — der /// mühsamste Teil dieser ganzen Ecke. Er soll einmal laufen, nicht bei jedem /// Antippen. /// /// Der Actor liegt als `let` in `PlexBackend`. Ein Actor ist ein Referenztyp; /// Kopien des Struct teilen sich denselben und damit dasselbe Gedächtnis. public actor PlexLiveTVSession { private let server: PlexServer /// Der Empfänger, über den eingeschaltet wird. private var dvr: PlexDVR? /// Die zuletzt geladenen Sender nach Kennung — der Weg vom neutralen /// `LiveChannel` zurück zum `PlexChannel`, den `tune` braucht. private var known: [String: PlexChannel] = [:] /// Das zuletzt geladene Programm. /// /// Plex liefert Sender und Programm in **einem** Aufruf. Es hier /// festzuhalten spart den zweiten, den der neutrale Vertrag sonst nach /// sich zöge: erst `liveChannels()`, dann `livePrograms()`. private var guide: [String: [PlexProgram]] = [:] public init(server: PlexServer) { self.server = server } // MARK: - Verfügbarkeit func availability() async -> LiveTVAvailability { switch await server.liveTVAvailability() { case .available(let dvrs): // Den ersten merken: alles Weitere — Sender, Programm, // Einschalten — hängt an ihm. dvr = dvrs.first return dvrs.isEmpty ? .notConfigured : .available case .notConfigured: return .notConfigured case .failed(let message): return .failed(message: message) } } // MARK: - Sender und Programm func channels() async -> LiveChannelListing { guard let dvr = await resolvedDVR() else { return LiveChannelListing(channels: [], source: nil, probes: []) } let result = await server.liveTVGuide(dvr: dvr, hours: 6) known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) guide = result.programs return LiveChannelListing( channels: result.channels.map { $0.asLiveChannel(running: running(on: $0)) }, source: result.source, probes: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) } ) } func programs(channelIDs: [String], from: Date, to: Date) async -> [String: [LiveProgram]] { // Ohne gemerktes Programm erst eines holen. Das passiert nur, wenn // jemand nach dem Programm fragt, ohne vorher die Sender geladen zu // haben — in der Oberfläche kommt das nicht vor, über den Vertrag // könnte es. if guide.isEmpty, let dvr = await resolvedDVR() { let result = await server.liveTVGuide(dvr: dvr, hours: 6) known = Dictionary(result.channels.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) guide = result.programs } let wanted = Set(channelIDs) var out: [String: [LiveProgram]] = [:] for (channelID, programs) in guide where wanted.isEmpty || wanted.contains(channelID) { let inRange = programs.filter { $0.endsAt > from && $0.startsAt < to } if !inRange.isEmpty { out[channelID] = inRange.map { $0.asLiveProgram(channelID: channelID) } } } return out } // MARK: - Einschalten func tune(channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int?) async -> LiveTuneResult { // Zurück zum echten Sender. Ohne ihn fehlen Rasterschlüssel und // Sendernummer, und ohne die schaltet nichts ein. guard let plexChannel = known[channel.id] else { return LiveTuneResult( url: nil, sessionID: UUID().uuidString, trace: [LiveChannelProbe( path: channel.id, result: "Sender nicht im geladenen Bestand — erst die Senderliste laden" )] ) } let plexProgram = program.flatMap { wanted in guide[channel.id]?.first { $0.id == wanted.id } } let result = await server.tune( channel: plexChannel, program: plexProgram, dvr: await resolvedDVR(), maximumBitrate: maximumBitrate ) return LiveTuneResult( url: result.url, sessionID: result.sessionIdentifier, subscriptionKey: result.subscriptionKey, trace: result.trace.map { LiveChannelProbe(path: $0.path, result: $0.result) } ) } func endSession(subscriptionKey: String) async { await server.endLiveSession(subscriptionKey: subscriptionKey) } // MARK: - Hilfen /// Der Empfänger — gemerkt, sonst erfragt. private func resolvedDVR() async -> PlexDVR? { if let dvr { return dvr } _ = await availability() return dvr } /// Was auf diesem Sender gerade läuft. private func running(on channel: PlexChannel) -> LiveProgram? { let now = Date() guard let match = guide[channel.id]?.first(where: { $0.startsAt <= now && $0.endsAt > now }) else { return nil } return match.asLiveProgram(channelID: channel.id) } } // MARK: - Übersetzung extension PlexChannel { /// Der Sender in neutraler Form. /// /// Ohne `tuning`: bei Plex merkt sich die Sitzung den echten Sender, und /// ein Griff, der nur wiederholt, was ohnehin dort liegt, wäre eine /// zweite Wahrheit. func asLiveChannel(running: LiveProgram?) -> LiveChannel { LiveChannel( id: id, number: channelIdentifier, title: title, artworkPath: thumb, currentProgram: running ) } } extension PlexProgram { /// Die Sendung in neutraler Form. /// /// Die Senderkennung kommt von außen: `PlexProgram` trägt sie optional, /// und im Programmraster steht sie ohnehin schon als Schlüssel darüber. func asLiveProgram(channelID: String) -> LiveProgram { LiveProgram( id: id, channelID: self.channelID ?? channelID, title: title, subtitle: subtitle, summary: summary, startsAt: startsAt, endsAt: endsAt, artworkPath: thumb ) } } // MARK: - Der Vertrag /// Plex als `LiveTVBackend`. /// /// Reine Weiterleitung an die Sitzung. Der Suchlauf, der die Senderliste /// findet, und alles, was das Einschalten kostet, bleiben unangetastet in /// `PlexServer+LiveTV` — hier wird nur übersetzt. /// /// **Kein** `LiveTVRecordingBackend`: Plex kann in dieser App nichts /// aufnehmen, und das mit werfenden Methoden zu behaupten wäre schlechter, /// als es gar nicht erst zu versprechen. extension PlexBackend: LiveTVBackend { public func liveTVAvailability() async -> LiveTVAvailability { await live.availability() } public func liveChannels() async -> LiveChannelListing { await live.channels() } public func livePrograms( channelIDs: [String], from: Date, to: Date ) async throws(MediaBackendError) -> [String: [LiveProgram]] { await live.programs(channelIDs: channelIDs, from: from, to: to) } public func tune( channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int? ) async -> LiveTuneResult { await live.tune(channel: channel, program: program, maximumBitrate: maximumBitrate) } public func endLiveSession(subscriptionKey: String) async { await live.endSession(subscriptionKey: subscriptionKey) } }