Files
Flaro/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift
T
ScarriffleandClaude Opus 5 fb418c842d Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag
Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts
abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar
kannte.

Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten,
sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet
wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur
aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder
angesprochen und konnte deshalb mit nichts anderem reden.

`MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der
Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau
dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil
es dafür keinen Fall gab.

Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst
(`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er
kopieren kann und welche er neu rechnen muss.

Drei Dinge, die beim Hinsehen auffielen:

- **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei
  Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und
  der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig
  Minuten Fortschritt landet man bei zweiundvierzig.
- **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint
  der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus,
  weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem
  Öffnen fest. Jetzt tut es für die erste Meldung die des Servers.
- **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem
  Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf;
  bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört.

Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht.
Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet
hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag.

Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der
Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung
an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin
das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst
ein richtiger Server.

257 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:00:02 +02:00

141 lines
5.5 KiB
Swift

import Foundation
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
///
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
/// der alles umfasst, wäre kein Nenner mehr.
public protocol MediaBackend: Sendable {
/// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get }
/// Was dieser Server darüber hinaus beherrscht.
nonisolated var capabilities: Set<BackendCapability> { get }
// MARK: - Blättern
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage
func item(id: String) async throws(MediaBackendError) -> MediaItem
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption]
/// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
///
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
/// der Adresse selbst.
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
nonisolated func rawImageURL(path: String?) -> URL?
// MARK: - Herunterladen
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
nonisolated func downloadURL(for file: MediaFile) -> URL?
/// Die Adresse für eine verkleinerte Fassung.
///
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL?
// MARK: - Abspielen
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
///
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
///
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
/// niemand mehr zusieht.
func endPlayback(sessionID: String) async
/// Meldet, wo man gerade steht.
///
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
/// wäre die schlechtere Hälfte des Tauschs.
func reportProgress(_ report: PlaybackReport) async
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
func markUnwatched(id: String) async throws(MediaBackendError)
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
func rate(id: String, rating: Int) async throws(MediaBackendError)
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
}
/// Was ein Server über das Nötigste hinaus kann.
///
/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine
/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der
/// Server nicht kann, ist schlimmer als eine, die sie weglässt.
public enum BackendCapability: String, Sendable, Hashable {
case liveTV
case favourites
case ratings
case libraryScan
/// Die Verwaltung des Servers aus der App heraus.
case administration
case transcoding
}
/// Ein Ausschnitt aus einer Mediathek.
///
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
/// Mediathek, in der man stöbert.
public struct MediaPage: Sendable, Equatable {
public let items: [MediaItem]
/// Ab welcher Stelle dieser Ausschnitt beginnt.
public let offset: Int
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
public let totalCount: Int
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
self.items = items
self.offset = offset
self.totalCount = totalCount
}
public var hasMore: Bool { offset + items.count < totalCount }
public var nextOffset: Int { offset + items.count }
}
/// Ein möglicher Wert für einen Filter.
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public init(id: String, title: String) {
self.id = id
self.title = title
}
}