import Foundation import MediaKit /// Jellyfin als `LiveTVRecordingBackend`. /// /// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen. /// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und /// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur /// werfen. extension JellyfinBackend: LiveTVRecordingBackend { // MARK: - Aufnahmen public func recordings() async throws(MediaBackendError) -> [LiveRecording] { let page = try await client.send( JellyfinRequest( path: "/LiveTv/Recordings", query: [ URLQueryItem(name: "userId", value: account.userID), URLQueryItem(name: "enableImages", value: "true"), ] ), as: JellyfinItemPage.self ) let now = Date() return page.items.map { item in LiveRecording( id: item.id, title: item.name ?? "—", channelName: item.channelName, summary: item.overview, startsAt: item.startDate, endsAt: item.endDate, artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]), // Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch // bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur // für diese eine Anzeige im Modell stünde. isInProgress: (item.endDate.map { $0 > now }) ?? false ) } } public func deleteRecording(id: String) async throws(MediaBackendError) { _ = try await client.send(JellyfinRequest( method: .delete, path: "/LiveTv/Recordings/\(id)" )) } // MARK: - Vorgemerkt public func timers() async throws(MediaBackendError) -> [LiveTimer] { let page = try await client.send( JellyfinRequest(path: "/LiveTv/Timers"), as: JellyfinTimerPage.self ) return page.items.map { timer in LiveTimer( id: timer.id ?? "", programID: timer.programID, channelID: timer.channelID, channelName: timer.channelName, title: timer.name ?? "—", startsAt: timer.startDate, endsAt: timer.endDate, seriesTimerID: timer.seriesTimerID ) } } public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) { try await schedule(program: program, path: "/LiveTv/Timers") } public func cancelTimer(id: String) async throws(MediaBackendError) { _ = try await client.send(JellyfinRequest( method: .delete, path: "/LiveTv/Timers/\(id)" )) } // MARK: - Ganze Reihen public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] { let page = try await client.send( JellyfinRequest(path: "/LiveTv/SeriesTimers"), as: JellyfinSeriesTimerPage.self ) return page.items.map { timer in LiveSeriesTimer( id: timer.id ?? "", name: timer.name ?? "—", channelID: timer.channelID, channelName: timer.channelName, recordsNewOnly: timer.skipEpisodesInLibrary ?? false, recordsAnyChannel: timer.recordAnyChannel ?? false ) } } public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) { try await schedule(program: program, path: "/LiveTv/SeriesTimers") } public func cancelSeriesTimer(id: String) async throws(MediaBackendError) { _ = try await client.send(JellyfinRequest( method: .delete, path: "/LiveTv/SeriesTimers/\(id)" )) } // MARK: - Der gemeinsame Weg /// Merkt eine Sendung vor — einmalig oder als ganze Reihe. /// /// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert /// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte /// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie /// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung /// nennt kein einziges Feld als erforderlich. /// /// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und /// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei /// durch die Modellierung fiele, wäre eines, das der Server vermisst — /// und man erführe es erst an einer Aufnahme, die nicht startet. private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) { let template = try await client.send(JellyfinRequest( path: "/LiveTv/Timers/Defaults", query: [URLQueryItem(name: "programId", value: program.id)] )) _ = try await client.send(JellyfinRequest( method: .post, path: path, body: template )) } } // MARK: - Was Jellyfin über Vorgemerktes schickt /// Ein vorgemerkter Termin, wie Jellyfin ihn meldet. /// /// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine** /// Einträge, sondern ein eigener Typ mit eigenen Feldern. struct JellyfinTimer: Decodable, Sendable { let id: String? let programID: String? let channelID: String? let channelName: String? let name: String? let startDate: Date? let endDate: Date? let seriesTimerID: String? private enum CodingKeys: String, CodingKey { case id = "Id", programID = "ProgramId", channelID = "ChannelId" case channelName = "ChannelName", name = "Name" case startDate = "StartDate", endDate = "EndDate" case seriesTimerID = "SeriesTimerId" } } struct JellyfinTimerPage: Decodable, Sendable { let items: [JellyfinTimer] private enum CodingKeys: String, CodingKey { case items = "Items" } } /// Eine stehende Aufnahme für alles, was zu einer Reihe gehört. struct JellyfinSeriesTimer: Decodable, Sendable { let id: String? let channelID: String? let channelName: String? let name: String? /// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich /// „nur neue Folgen" nennt. let skipEpisodesInLibrary: Bool? let recordAnyChannel: Bool? private enum CodingKeys: String, CodingKey { case id = "Id", channelID = "ChannelId", channelName = "ChannelName" case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary" case recordAnyChannel = "RecordAnyChannel" } } struct JellyfinSeriesTimerPage: Decodable, Sendable { let items: [JellyfinSeriesTimer] private enum CodingKeys: String, CodingKey { case items = "Items" } }