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] /// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl. func availableLanguages(inSeries id: String) async -> AvailableLanguages? /// 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? /// Die Adresse für dieselbe Datei in einer **anderen Schachtel**. /// /// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle, /// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst /// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der /// nachträglich helfen könnte. /// /// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original, /// und der Titel ist offline nur so gut wie der Systemplayer. /// /// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server /// legt in die neue Hülle genau eine Tonspur und höchstens einen /// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das /// nicht nachholen. nonisolated func remuxDownloadURL( itemID: String, versionIndex: Int, audioStreamIndex: Int?, subtitleStreamIndex: Int? ) -> 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) /// Nimmt einen Titel aus „Weiterschauen", ohne ihn als gesehen zu melden. /// /// **Der Fall, für den es sonst nichts gibt.** Nach einer halben Stunde /// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll /// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür /// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt. /// /// **Der ganze Titel, nicht nur seine Kennung.** Jellyfin kennt dafür /// keinen eigenen Weg; dort wird das Benutzerdatenblatt geschrieben, und /// darin stehen auch „gesehen" und „Favorit". Wer sie nicht mitschickt, /// löscht sie — und der Aufrufer hat sie ohnehin zur Hand. func removeFromContinueWatching(_ item: MediaItem) 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 Download bringt **alle** Ton- und Textspuren mit. /// /// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins /// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was /// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline /// weg. Einmal hat die App dabei still die Audiodeskription genommen — /// drei Gigabyte Mobilfunk für eine unbrauchbare Datei. /// /// Kann der Server es besser, soll die App nicht so tun, als müsste /// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl /// im Sheet entfällt, und der gewöhnliche Download liefert alles. case allTracksDownload } /// 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 { /// Vorgabe: der Server kann es nicht. /// /// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht /// gibt — und wer es anbietet, sagt es ausdrücklich. /// /// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server /// legt in die neue Hülle genau eine Tonspur und höchstens einen /// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das /// nicht nachholen. nonisolated func remuxDownloadURL( itemID: String, versionIndex: Int, audioStreamIndex: Int?, subtitleStreamIndex: Int? ) -> URL? { nil } /// Welche Sprachen eine Serie durchgehend führt. /// /// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der /// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine /// zu lange. func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil } /// 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 } } /// Eine Sprache, wie sie in einem Menü steht. public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable { public let code: String public let name: String public var id: String { code } public init(code: String, name: String) { self.code = code self.name = name } } /// Welche Sprachen ein Titel durchgehend führt. /// /// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die /// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in /// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2 /// stillschweigend etwas anderes tut. public struct AvailableLanguages: Sendable, Equatable { public let audio: [LanguageOption] public let subtitles: [LanguageOption] /// Wie viele Sprachen es nur in einem Teil der Folgen gibt. /// /// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht /// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die /// er in Staffel 1 gesehen hat, hält es für einen Fehler. public let partial: Int public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) { self.audio = audio self.subtitles = subtitles self.partial = partial } }