import Foundation import MediaKit /// Ein Flaro-Server, gesehen durch `MediaBackend`. /// /// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin /// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros /// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine /// Entscheidung über Wiedergabe, keine Zwischenspeicher. public actor FlaroBackend: MediaBackend { private let client: FlaroClient private let account: FlaroAccount /// Die Mediatheken, einmal geholt. /// /// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und /// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen, /// ob sie nach Filmen oder nach Serien fragt. private var libraryMemo: [MediaLibrary]? public init(client: FlaroClient, account: FlaroAccount) { self.client = client self.account = account } // MARK: - Wer dieser Server ist public nonisolated var serverName: String { account.serverName } /// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen, /// und derselbe darf umbenannt werden. Was hier steht, überlebt beides. public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString } public nonisolated var capabilities: Set { [ .favourites, .ratings, .libraryScan, .transcoding, // **Der Grund, aus dem es diesen Server gibt.** Flaros Download // bringt jede Ton- und Textspur mit; die App muss vorher nichts // wählen und kann offline umstellen. .allTracksDownload, ] } // MARK: - Blättern public func libraries() async throws(MediaBackendError) -> [MediaLibrary] { if let libraryMemo { return libraryMemo } let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self) let liste = roh.map { $0.neutral() } libraryMemo = liste return liste } public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { // **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der // eine fertige Startseite liefert, und das ist Absicht: welche Reihen // eine App zeigt, entscheidet die App. Der Server liefert die Zutaten. async let weiter = reihe(pfad: "/api/v1/home/continue") async let naechste = reihe(pfad: "/api/v1/home/next") let mediatheken = (try? await libraries()) ?? [] var reihen: [MediaRowGroup] = [] if let eintraege = try? await weiter, !eintraege.isEmpty { reihen.append(MediaRowGroup( id: "flaro.continue", title: String(localized: "flaro.row.continue", bundle: .module), items: eintraege)) } if let eintraege = try? await naechste, !eintraege.isEmpty { reihen.append(MediaRowGroup( id: "flaro.nextup", title: String(localized: "flaro.row.nextUp", bundle: .module), items: eintraege)) } // **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.** // // Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem // Anfang der Mediathek in der Reihenfolge der Datenbank — also immer // demselben. Drei Dinge waren damit falsch: es war kein Neuzugang, // es warf getrennte Mediatheken wieder zusammen, und die Kennungen // begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei // Einstellungen — „Gesehenes in Neuzugängen ausblenden" und // „Aufmacher nur aus Neuzugängen" —, und beide taten an einem // Flaro-Server deshalb nichts. // **Nebeneinander, nicht nacheinander.** // // Hier stand eine Schleife mit `await` darin: bei vier Mediatheken // vier Rundreisen hintereinander, jede erst nach der vorigen. Die // Weboberfläche holt dieselben Reihen in einem `Promise.all` — und // sie holt sogar mehr davon. Genau das war der Unterschied, den man // der App ansah. // // Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim // Warten wieder frei, so wie die beiden darüber es schon tun. let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in for (platz, mediathek) in mediatheken.enumerated() { gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) } } // **Die Reihenfolge der Mediatheken, nicht die der Antworten.** // Sonst stünden die Reihen nach jedem Start woanders — je // nachdem, welche Abfrage zuerst zurückkam. var gesammelt: [(Int, MediaRowGroup?)] = [] for await ergebnis in gruppe { gesammelt.append(ergebnis) } return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1) } reihen.append(contentsOf: neuzugaenge) return reihen } /// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts. /// /// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die /// Startseite mit drei von vier Reihen ist besser als gar keine. private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? { let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies" let abfrage = [ URLQueryItem(name: "library", value: mediathek.id), URLQueryItem(name: "sort", value: "added"), URLQueryItem(name: "take", value: "24"), ] let titel: [MediaItem] if mediathek.kind == .show { guard let seite = try? await client.send( FlaroRequest(path: pfad, query: abfrage), as: Wire.Page.self) else { return nil } titel = seite.items.map { $0.neutral() } } else { guard let seite = try? await client.send( FlaroRequest(path: pfad, query: abfrage), as: Wire.Page.self) else { return nil } titel = seite.items.map { $0.neutral() } } guard !titel.isEmpty else { return nil } return MediaRowGroup( id: "latest.\(mediathek.id)", title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module), libraryID: mediathek.id, items: titel) } public func availableLanguages(inSeries id: String) async -> AvailableLanguages? { guard let roh = try? await client.send( FlaroRequest(path: "/api/v1/series/\(id)/languages"), as: Wire.Sprachauswahl.self) else { return nil } return AvailableLanguages( audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) }, subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) }, partial: roh.partial) } public func items( inLibrary library: MediaLibrary, query: MediaQuery, offset: Int, limit: Int ) async throws(MediaBackendError) -> MediaPage { var abfrage = [ URLQueryItem(name: "library", value: library.id), URLQueryItem(name: "skip", value: String(offset)), URLQueryItem(name: "take", value: String(limit)), ] if let text = query.searchText, !text.isEmpty { abfrage.append(URLQueryItem(name: "search", value: text)) } if library.kind == .show { let seite = try await client.send( FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page.self) return MediaPage( items: gesiebt(seite.items.map { $0.neutral() }, query), offset: offset, totalCount: seite.total) } let seite = try await client.send( FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page.self) return MediaPage( items: gesiebt(seite.items.map { $0.neutral() }, query), offset: offset, totalCount: seite.total) } public func item(id: String) async throws(MediaBackendError) -> MediaItem { // **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung // durch und weiss nicht, was dahintersteckt — sie hat sie aus einer // Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine. if let film = try? await client.send( FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) { return film.neutral() } if let serie = try? await client.send( FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) { return serie.neutral() } // **Und zuletzt eine Folge.** // // Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem // Server nicht mehr vorhanden" direkt von der Startseite: in // „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die // ist weder ein Film noch eine Serie. if let folge = try? await client.send( FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) { var serie: Wire.Series? if let kennung = folge.seriesId { serie = try? await client.send( FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self) } return folge.neutral(series: serie) } throw .notFound } public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] { try await episodes(inSeries: id) } /// Alle Folgen einer Serie — in einer Anfrage. /// /// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn /// Anfragen. Flaro kann es in einer, also tut es das. public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] { let serie = try? await client.send( FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) let folgen = try await client.send( FlaroRequest(path: "/api/v1/series/\(id)/episodes"), as: Wire.Page.self) return folgen.items.map { $0.neutral(series: serie) } } public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? { try await nachbar(von: item, weg: "next") } public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { try await nachbar(von: item, weg: "previous") } /// Die Folge vor oder nach dieser. /// /// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie — /// oder ihr Anfang. private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? { guard item.kind == .episode else { return nil } let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)")) guard !daten.isEmpty else { return nil } // **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer // kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge // ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit // eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen. // Das `try?` schluckte den Fehler, und von aussen sah es aus, als // gäbe es keine nächste Folge: der Knopf blieb weg. guard let folge = try? FlaroClient.decoder.decode( Wire.Episode.self, from: daten) else { return nil } return folge.neutral() } public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] { let abfrage = [ URLQueryItem(name: "search", value: text), URLQueryItem(name: "take", value: String(limit)), ] async let filme = client.send( FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page.self) async let serien = client.send( FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page.self) var reihen: [MediaRowGroup] = [] if let gefunden = try? await filme, !gefunden.items.isEmpty { reihen.append(MediaRowGroup( id: "flaro.search.movies", title: String(localized: "flaro.row.movies", bundle: .module), items: gefunden.items.map { $0.neutral() })) } if let gefunden = try? await serien, !gefunden.items.isEmpty { reihen.append(MediaRowGroup( id: "flaro.search.series", title: String(localized: "flaro.row.series", bundle: .module), items: gefunden.items.map { $0.neutral() })) } return reihen } /// Flaro kennt noch keine Schlagwörter. /// /// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann /// aus, statt einen Knopf zu zeigen, hinter dem nichts ist. public func filterOptions( inLibrary library: MediaLibrary, field: MediaQuery.Field ) async throws(MediaBackendError) -> [MediaFilterOption] { [] } public func libraryID(for item: MediaItem) async -> String? { nil } // MARK: - Bilder /// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie /// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein /// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet /// Rechenzeit, die auf einem NAS knapper ist. public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { rawImageURL(path: path) } public nonisolated func rawImageURL(path: String?) -> URL? { guard let path, !path.isEmpty else { return nil } return client.url(path: "/api/v1/images/\(path)") } // MARK: - Herunterladen /// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei /// unverändert hinausgeht oder erst umgepackt wird — und packt dabei /// *alle* Ton- und Textspuren mit ein. public nonisolated func downloadURL(for file: MediaFile) -> URL? { client.url(path: "/api/v1/files/\(file.id)/download") } /// **Dieselbe Adresse, und das ist der Punkt.** /// /// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins /// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die /// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb /// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche /// fragt bei `.allTracksDownload` gar nicht erst danach. public nonisolated func remuxDownloadURL( itemID: String, versionIndex: Int, audioStreamIndex: Int?, subtitleStreamIndex: Int? ) -> URL? { nil } /// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3. public nonisolated func compressedDownloadURL( itemID: String, versionIndex: Int, maximumBitrate: Int, resolution: String? ) -> URL? { nil } // MARK: - Abspielen /// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird. /// /// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und /// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die /// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler, /// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt /// Schwarz ohne Begründung. private static let packWartezeit = Duration.seconds(600) public func preparePlayback( _ intent: PlaybackIntent ) async throws(MediaBackendError) -> PlaybackTarget { // **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.** // // Hier stand ausschliesslich der zweite Weg: `/download`, und dann // im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt. // Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz, // während die Weboberfläche nach einer Sekunde lief — die holt sich // eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in // deutlich unter einer Sekunde da. if let strom = try? await client.send( .json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest( height: intent.transcode?.resolution.flatMap(Self.hoehe), // Ohne diese Angabe wählt der Server, und bei einem // umgerechneten Strom kommt danach nur noch eine Spur an. audioStream: intent.audioStreamIndex, startSeconds: max(0, intent.startTime.seconds), // Nur wenn der Spieler es ausdrücklich verlangt: das Profil // entscheidet der Server, und er entscheidet es besser. transcode: intent.transcode?.prefersRemux == false && intent.transcode?.maximumBitrate != nil)), as: Wire.Stromauskunft.self), // **Die Hauptliste, wo es sie gibt.** Nur dort stehen die // Textspuren; die Medienliste trägt bloss Bild und Ton. let adresse = client.url(path: strom.masterUrl ?? strom.url) { return PlaybackTarget( url: adresse, // Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre. sessionID: strom.method == "direct" ? nil : strom.sessionId, versionID: intent.file.id, startOffset: .seconds(strom.startSeconds)) } return try await mitgenommeneDatei(intent) } /// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt. /// /// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne /// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte /// Wiedergabe besser als keine. private func mitgenommeneDatei( _ intent: PlaybackIntent ) async throws(MediaBackendError) -> PlaybackTarget { guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else { throw .transport(description: "Adresse nicht baubar") } var auskunft = try await client.send( FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"), as: Wire.Delivery.self) if let fehler = auskunft.error { throw .refused(reason: fehler) } // **Erst warten, dann die Adresse herausgeben.** // // Muss der Server umpacken, antwortet er auf den Download mit 202 und // fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere // Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner. // // Das Anstossen besorgt der Server beim ersten Abruf selbst; hier // wird nur nachgefragt, bis es so weit ist. if auskunft.method != "directFile", auskunft.packageState != "ready" { let bis = ContinuousClock.now + Self.packWartezeit while ContinuousClock.now < bis { // Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür // herunterzuladen wäre absurd. _ = try? await client.send(FlaroRequest( method: .head, path: "/api/v1/files/\(intent.file.id)/download")) auskunft = try await client.send( FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"), as: Wire.Delivery.self) if let fehler = auskunft.error { throw .refused(reason: fehler) } if auskunft.packageState == "ready" { break } try? await Task.sleep(for: .seconds(1)) } guard auskunft.packageState == "ready" else { throw .refused(reason: String( localized: "flaro.remux.tooLong", bundle: .module)) } } return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id) } /// „1080p" wird zu 1080 — der Server kennt nur Zahlen. private static func hoehe(_ aufloesung: String) -> Int? { Int(aufloesung.filter(\.isNumber)) } /// Schliesst die Sitzung, damit nichts weiterrechnet. /// /// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der /// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet /// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne /// diesen Aufruf den ganzen Film zu Ende schreibt, während längst /// niemand mehr zusieht. public func endPlayback(sessionID: String) async { _ = try? await client.send(FlaroRequest( method: .delete, path: "/api/v1/stream/\(sessionID)")) } public func reportProgress(_ report: PlaybackReport) async -> Bool { // 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. // // Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen // Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden // hiesse, den ersten Bericht wegzuwerfen. do { try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress", Wire.ProgressRequest( positionSeconds: report.time.seconds, runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil))) return true } catch { return false } } // MARK: - Zustand melden public func markWatched(id: String) async throws(MediaBackendError) { try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true))) } public func markUnwatched(id: String) async throws(MediaBackendError) { try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false))) } /// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten. /// /// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die /// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt /// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt, /// löscht es. public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) { try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss")) } public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { try await client.send(.json(.put, "/api/v1/items/\(id)/favourite", Wire.FavouriteRequest(favourite: isFavourite))) } public func rate(id: String, rating: Int) async throws(MediaBackendError) { try await client.send(.json(.put, "/api/v1/items/\(id)/rating", Wire.RatingRequest(rating: rating > 0 ? rating : nil))) } public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) { try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan")) } // MARK: - Gemeinsames private func seite( pfad: String, take: Int, as type: T.Type ) async throws(MediaBackendError) -> Wire.Page { try await client.send( FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]), as: Wire.Page.self) } private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] { let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page.self) return seite.items.map { $0.neutral() } } /// Was der Server noch nicht kann, siebt die App — aber nur das. /// /// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die /// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt /// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein /// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier /// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der /// nichts tut. private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] { var liste = titel if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } } if query.favouritesOnly { liste = liste.filter(\.isFavourite) } return liste } } /// Mit wem die App spricht und als wer. public struct FlaroAccount: Sendable, Equatable { public let baseURL: URL public let serverName: String public let userID: String public let userName: String public let isAdministrator: Bool /// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe. /// /// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es /// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich /// dazu zu äussern. public let avatarImage: String? public init( baseURL: URL, serverName: String, userID: String, userName: String, isAdministrator: Bool, avatarImage: String? = nil ) { self.baseURL = baseURL self.serverName = serverName self.userID = userID self.userName = userName self.isAdministrator = isAdministrator self.avatarImage = avatarImage } }