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 } /// Wer dieser Server ist — für alles, was ihn über einen Start hinaus /// wiedererkennen muss. /// /// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe /// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es /// beides überlebt und beim nächsten Start noch gleich lautet. Es ist /// eine Kennung und geht nirgends an die Oberfläche. nonisolated var identity: String { get } /// Was dieser Server darüber hinaus beherrscht. nonisolated var capabilities: Set { 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 /// In welcher Mediathek ein Titel liegt. /// /// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er /// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel, /// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer /// Folge wissen will, muss die Kette hinaufgehen, und das ist eine /// Serveranfrage. /// /// Deshalb steht sie hier als eigene Frage und nicht als Feld an /// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld, /// das meistens `nil` ist, verschweigt genau das. /// /// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg, /// statt zu raten. func libraryID(for item: MediaItem) async -> String? 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] /// Alle Folgen einer Serie, über alle Staffeln hinweg. /// /// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln /// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln /// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel. /// Ein Server, der es in einer Anfrage kann, soll das dürfen. func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] /// 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 /// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`. /// /// Plex kann Sender zeigen und einschalten, aber in dieser App nichts /// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen /// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen, /// der dort nichts tut. case liveTVRecordings 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 } } public extension MediaBackend { /// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen. /// /// Langsam, aber überall gültig — wer es besser kann, überschreibt es. /// /// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von /// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden /// irgendwann auseinanderlaufen. func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] { let staffeln = try await children(of: id) var folgen: [MediaItem] = [] for staffel in staffeln { folgen += (try? await children(of: staffel.id)) ?? [] } return folgen } /// Ohne eigene Antwort keine Mediathek. /// /// Plex beantwortet das heute nicht, und eine falsche Antwort wäre /// schlechter als keine: sie stellte die Sprache einer fremden Mediathek /// über den allgemeinen Wunsch. func libraryID(for item: MediaItem) async -> String? { nil } } /// 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 } }