Spec für Jellyfin-Live-TV. Der eigentliche Befund ist nicht, dass Jellyfin fehlt, sondern dass das Feature quer an der Abstraktion vorbei gebaut ist: die Views importieren PlexKit unmittelbar und sprechen über session.server statt über session.backend. Die Jellyfin-Endpunkte stammen aus der OpenAPI-Spec des Servers, nicht aus dem Gedächtnis — in genau dieser Ecke steht, was dreimaliges Raten auf einem echten Server gekostet hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
10 KiB
Markdown
138 lines
10 KiB
Markdown
# Live-TV für Jellyfin
|
|
|
|
## Kontext
|
|
|
|
Live-TV gibt es nur bei Plex — und zwar nicht, weil Jellyfin es nicht könnte, sondern weil das Feature quer an der Backend-Abstraktion vorbeigebaut ist.
|
|
|
|
`LiveTVView`, `ProgramGuide`, `ChannelSelectionSheet` und `PlayerModel` importieren alle unmittelbar `PlexKit` und arbeiten mit `PlexDVR`, `PlexChannel`, `PlexProgram`. Sie sprechen über `session.server` (den Plex-Server), nicht über `session.backend` (das neutrale `MediaBackend`). In `PlayerModel.swift:126` steht es ausdrücklich: „Bei Jellyfin ist er `nil`."
|
|
|
|
Dazu eine Unwahrheit im Bestand: `JellyfinBackend.swift:20` meldet `.liveTV` als Fähigkeit, ohne eine Zeile dahinter. Folgenlos war das bisher nur, weil niemand die Fähigkeit abfragt — `FlixySession.swift:167` sagt `supportsLiveTV { server != nil }`, und bei Jellyfin ist das nil.
|
|
|
|
## Was Jellyfin kann — verifiziert, nicht geraten
|
|
|
|
Aus der OpenAPI-Spec des Servers (`api.jellyfin.org/openapi/jellyfin-openapi-stable.json`), nicht aus dem Gedächtnis. Der Grund steht im Plex-Gegenstück: dort endeten drei geratene Adressen auf einem echten Server alle mit 404.
|
|
|
|
| Zweck | Endpunkt | Antwort |
|
|
|---|---|---|
|
|
| Verfügbarkeit | `GET /LiveTv/Info` | `LiveTvInfo` — `IsEnabled`, `Services`, `EnabledUsers` |
|
|
| Sender | `GET /LiveTv/Channels` | `BaseItemDtoQueryResult` |
|
|
| Programme | `GET /LiveTv/Programs` | `BaseItemDtoQueryResult` |
|
|
| Aufnahmen | `GET /LiveTv/Recordings` | `BaseItemDtoQueryResult` |
|
|
| Aufnahme löschen | `DELETE /LiveTv/Recordings/{recordingId}` | — |
|
|
| Timer | `GET`/`POST` `/LiveTv/Timers`, `DELETE /LiveTv/Timers/{timerId}` | `TimerInfoDtoQueryResult` |
|
|
| Serien-Timer | `GET`/`POST` `/LiveTv/SeriesTimers`, `DELETE /LiveTv/SeriesTimers/{timerId}` | `SeriesTimerInfoDtoQueryResult` |
|
|
| Timer-Vorlage | `GET /LiveTv/Timers/Defaults` | `SeriesTimerInfoDto` |
|
|
|
|
Zwei Funde, die den Bau vereinfachen:
|
|
|
|
1. **Sender und Programme sind beide `BaseItemDto`** — derselbe Typ, den `JellyfinItem` schon abbildet. Es fehlen nur Felder: `ChannelId`, `ChannelName`, `ChannelNumber`, `Number`, `StartDate`, `EndDate`, `CurrentProgram`, `IsLive`, `ProgramId`, `SeriesTimerId` sowie die Kennzeichen `IsMovie`/`IsSeries`/`IsNews`/`IsSports`/`IsKids`.
|
|
2. **`GET /LiveTv/Channels?addCurrentProgram=true`** liefert die laufende Sendung mit. Die Senderliste braucht dafür keinen zweiten Aufruf.
|
|
|
|
Nützliche Parameter, ebenfalls aus der Spec: `/LiveTv/Programs` filtert über `channelIds`, `minStartDate`, `maxEndDate`, `isAiring`; `/LiveTv/Channels` über `type`, `limit`, `startIndex`, `enableImages`.
|
|
|
|
Die Wiedergabe braucht keinen eigenen Weg: ein Sender ist ein `BaseItemDto` und läuft über das vorhandene `preparePlayback`.
|
|
|
|
## Entscheidungen (mit dem Nutzer abgestimmt)
|
|
|
|
| Thema | Entscheidung |
|
|
|---|---|
|
|
| Architektur | **Sauber abstrahieren.** Neutrale Typen in MediaKit, Live-TV als eigene Fähigkeit hinter einem Protokoll. |
|
|
| Umfang | **Parität mit Plex plus Aufnahmen.** Sender, Programmführer, Senderauswahl, Einschalten — dazu Jellyfins Aufnahmen, Timer und Serien-Timer. |
|
|
| Zuschnitt | **Ein Durchgang.** Abstraktion, Parität und Aufnahmen zusammen. Empfohlen war eine Teilung in zwei Stücke; der Nutzer hat sich dagegen entschieden. |
|
|
| Aufnahmen in der Oberfläche | **Dritter Modus in Live-TV**, neben Raster und Senderliste. Bei Plex fehlt der Schalter schlicht. |
|
|
|
|
## Neutrale Typen (MediaKit)
|
|
|
|
```swift
|
|
LiveChannel // id, number, title, artworkPath, currentProgram: LiveProgram?
|
|
LiveProgram // id, channelID, title, summary, start, end, kind-Kennzeichen, seriesTimerID?
|
|
LiveRecording // id, title, channelName, start, end, status, artworkPath
|
|
LiveTimer // id, programID?, channelID, title, start, end, prePadding, postPadding
|
|
LiveSeriesTimer // id, name, channelID?, recordsNewOnly, recordsAnyChannel
|
|
```
|
|
|
|
`LiveTVAvailability` wandert aus PlexKit hoch und **legt dabei die Plex-Typen ab**: heute lautet sie `case available(dvrs: [PlexDVR])`. Neutral bleibt `case available` — welcher Empfänger dahintersteht, ist Sache der Implementierung. Plex merkt sich seinen DVR intern; niemand sonst braucht ihn.
|
|
|
|
`LiveTuneResult` wird ebenfalls neutral: `.success(PlaybackTarget, subscriptionKey: String?)` oder `.failure(message: String, trace: [String])`. Der Spur-Anteil bleibt erhalten — er ist der Grund, warum das Einschalten bei Plex überhaupt zu erklären ist, wenn es scheitert.
|
|
|
|
## Zwei Protokolle, nicht eines
|
|
|
|
```swift
|
|
public protocol LiveTVBackend: Sendable {
|
|
func liveTVAvailability() async -> LiveTVAvailability
|
|
func liveChannels() async throws(MediaBackendError) -> [LiveChannel]
|
|
func livePrograms(channelIDs: [String], from: Date, to: Date)
|
|
async throws(MediaBackendError) -> [String: [LiveProgram]]
|
|
func tune(channel: LiveChannel, program: LiveProgram?, maximumBitrate: Int?) async -> LiveTuneResult
|
|
func endLiveSession(subscriptionKey: String) async
|
|
}
|
|
|
|
public protocol LiveTVRecordingBackend: Sendable {
|
|
func recordings() async throws(MediaBackendError) -> [LiveRecording]
|
|
func deleteRecording(id: String) async throws(MediaBackendError)
|
|
func timers() async throws(MediaBackendError) -> [LiveTimer]
|
|
func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError)
|
|
func cancelTimer(id: String) async throws(MediaBackendError)
|
|
func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer]
|
|
func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError)
|
|
func cancelSeriesTimer(id: String) async throws(MediaBackendError)
|
|
}
|
|
```
|
|
|
|
**Getrennt, weil Plex das eine kann und das andere nicht.** Ein `as? LiveTVRecordingBackend` sagt die Wahrheit; eine Methode, die bei Plex nur wirft, verspricht etwas und bricht es dann. Dazu `BackendCapability.liveTVRecordings`, damit die Oberfläche den dritten Schalter gar nicht erst zeigt.
|
|
|
|
Beide bleiben **außerhalb** des `MediaBackend`-Vertrags — so, wie es dort schon steht: „Alles, was nur ein Server kann … steht nicht hier, sondern als eigene Fähigkeit."
|
|
|
|
## Plex zieht um, ohne umgebaut zu werden
|
|
|
|
Der Rateweg in `PlexServer+LiveTV` — vier Strategien, um die Senderliste zu finden, mit Spur für den Fehlerfall — bleibt **Zeile für Zeile, wo er ist**. `PlexBackend` bekommt nur eine Konformität zu `LiveTVBackend`, die darauf zeigt und die Plex-Typen in die neutralen übersetzt.
|
|
|
|
Das ist der Punkt, an dem diese Änderung schiefgehen könnte: dieser Code hat gekostet, und er wird nicht angefasst, sondern eingepackt.
|
|
|
|
`PlexBackend.capabilities` behält `.liveTV` und bekommt **kein** `.liveTVRecordings`.
|
|
|
|
## Jellyfin bekommt eine neue Implementierung
|
|
|
|
Neu: `JellyfinBackend+LiveTV.swift` und `JellyfinBackend+Recordings.swift`, im Muster von `JellyfinBackend+Playback.swift`. `JellyfinItem` bekommt die oben genannten fehlenden Felder; `JellyfinItem+Media.swift` die Übersetzung nach `LiveChannel` und `LiveProgram`. `TimerInfoDto` und `SeriesTimerInfoDto` brauchen eigene, kleine Modelle — sie sind keine `BaseItemDto`.
|
|
|
|
`JellyfinBackend.capabilities` bekommt `.liveTVRecordings` zusätzlich zum vorhandenen `.liveTV`, das damit erstmals stimmt.
|
|
|
|
## Die Oberfläche verliert ihre Plex-Abhängigkeit
|
|
|
|
- `LiveTVView`, `ProgramGuide`, `ChannelSelectionSheet`: `import PlexKit` fällt weg, `PlexChannel`/`PlexProgram`/`PlexDVR` werden zu `LiveChannel`/`LiveProgram`.
|
|
- `PlayerModel.liveTV` wird von `PlexServer?` zu `(any LiveTVBackend)?`. Die beiden Stellen, die ihn nutzen — `refreshLiveProgrammeIfNeeded` und die Freigabe der Subskription — gehen über das Protokoll.
|
|
- `FlixySession.supportsLiveTV` fragt `backend?.capabilities.contains(.liveTV)` ab statt `server != nil`. Damit taucht Live-TV bei Jellyfin überhaupt erst in der Navigation auf.
|
|
- `ChannelSelection` (die gespeicherte Senderauswahl) arbeitet auf `LiveChannel`.
|
|
|
|
### Die Diagnose der Sendersuche
|
|
|
|
`LiveTVView` hält heute zwei Dinge, die es **nur bei Plex gibt**: `channelSource` (woher die Senderliste kam) und `trace: [ChannelProbe]` (jede abgeklopfte Adresse mit ihrer Antwort). Beide sind die Antwort auf Plex' Rateweg — Jellyfin hat einen festen Endpunkt und braucht nichts davon.
|
|
|
|
Sie fallen deshalb nicht weg, sondern werden optional: Die neutrale Form kennt `channelSource: String?` und `probes: [LiveChannelProbe]`, Jellyfin liefert `nil` und eine leere Liste, und die Oberfläche zeigt den Diagnoseblock nur, wenn etwas da ist. Ihn ganz zu streichen wäre falsch — er ist bei Plex der einzige Weg zu sehen, warum keine Sender kommen.
|
|
|
|
## Aufnahmen in der Oberfläche
|
|
|
|
Ein dritter Modus neben Raster und Senderliste, sichtbar nur bei `.liveTVRecordings`:
|
|
|
|
- **Aufnahmen** — Liste mit Titel, Sender, Zeitraum; Antippen spielt ab, Wischen löscht (mit Rückfrage).
|
|
- **Geplant** — die Timer und Serien-Timer, mit der Möglichkeit abzusagen.
|
|
|
|
Im Programmführer führt langes Drücken auf einen Sendungsblock zu einem Menü: „Ansehen", „Aufnehmen", „Serie aufnehmen". Bei Plex enthält dasselbe Menü nur „Ansehen" — dann bleibt es beim heutigen Antippen, ohne Menü.
|
|
|
|
Der gespeicherte Modus (`@AppStorage("liveTV.showsGuide")`) wird von einem Wahrheitswert zu einer Aufzählung aus drei Werten. Steht ein Server ohne Aufnahmen bereit und war zuletzt „Aufnahmen" gewählt, fällt es auf das Raster zurück.
|
|
|
|
## Risiken
|
|
|
|
1. **Die Plex-Fassade.** Der Rateweg liefert `PlexChannel` mit Feldern, die es bei Jellyfin nicht gibt, und umgekehrt. Wo die neutrale Form zu schmal geschnitten ist, fällt bei Plex etwas weg, das heute funktioniert — insbesondere die Sendernummer am Empfänger, an der das Einschalten hängt.
|
|
2. **Die Zeitachse des Programmführers** rechnet heute mit `PlexProgram`-Zeiten. Jellyfin liefert ISO-8601-Zeitstempel statt Plex' Sekunden seit 1970; die Übersetzung muss die Zeitzone richtig treffen, sonst steht das Raster um Stunden verschoben.
|
|
3. **Kein Jellyfin-Server mit Empfänger zum Prüfen.** Ob der Nutzer einen hat, ist offen. Ohne einen lässt sich der Jellyfin-Weg nicht an echten Daten belegen — dann bleibt es bei „kompiliert und folgt der Spec".
|
|
|
|
## Fertig ist es, wenn
|
|
|
|
- Bei einem Jellyfin-Server mit Empfänger steht Live-TV in der Navigation, zeigt Sender und Programmführer und schaltet ein.
|
|
- Bei einem Jellyfin-Server **ohne** Empfänger erscheint dieselbe erklärende Ansicht wie bei Plex, kein Fehlerdialog.
|
|
- Aufnahmen, Timer und Serien-Timer sind bei Jellyfin zu sehen, anzulegen und abzusagen.
|
|
- Bei Plex funktioniert Live-TV **genau wie vorher** — Senderliste, Raster, Einschalten, Fehlerspur. Der dritte Schalter fehlt dort.
|
|
- Keine Datei unter `Flixy/Views/LiveTV/` importiert noch `PlexKit`.
|
|
- `supportsLiveTV` fragt die Fähigkeit ab, und `JellyfinBackend.capabilities` sagt die Wahrheit.
|