Zwei Protokolle statt eines: LiveTVBackend für Sender, Programm und Einschalten, LiveTVRecordingBackend für Aufnahmen. Getrennt, weil Plex das eine kann und das andere nicht — ein as? sagt die Wahrheit, eine Methode, die bei Plex nur wirft, verspricht etwas und bricht es dann. Plex zieht um, ohne umgebaut zu werden. Der Suchlauf, der die Senderliste findet, bleibt Zeile für Zeile, wo er ist; davor liegt jetzt ein Actor, der sich Empfänger und Sender merkt. Nötig, weil PlexBackend ein struct ist und tune() den echten PlexChannel braucht — ohne Gedächtnis liefe der Suchlauf bei jedem Antippen neu. Jellyfin ist neu und deutlich kürzer, weil es dort nichts zu suchen gibt. Die Endpunkte stammen aus der OpenAPI-Spec des Servers. Beim Vormerken wird die Vorlage des Servers roh weitergereicht statt entschlüsselt und neu zusammengesetzt: welche Felder Pflicht sind, sagt die Beschreibung nicht, und ein verlorenes fiele erst an einer Aufnahme auf, die nicht startet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
6.8 KiB
Swift
193 lines
6.8 KiB
Swift
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" }
|
|
}
|