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. for mediathek in mediatheken { 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 { continue } titel = seite.items.map { $0.neutral() } } else { guard let seite = try? await client.send( FlaroRequest(path: pfad, query: abfrage), as: Wire.Page.self) else { continue } titel = seite.items.map { $0.neutral() } } guard !titel.isEmpty else { continue } reihen.append(MediaRowGroup( id: "latest.\(mediathek.id)", title: String( localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module), libraryID: mediathek.id, items: titel)) } return reihen } 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? { guard item.kind == .episode else { return nil } // 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger. let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next")) guard !daten.isEmpty else { return nil } guard let folge = try? JSONDecoder().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 { 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. // Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird // wiederhergestellt", 0:00, für immer. // // 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)) } } // **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das // hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder // liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was // weiterrechnet, wenn niemand mehr zusieht. return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id) } public func endPlayback(sessionID: String) async {} public func reportProgress(_ report: PlaybackReport) async { // 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. _ = 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))) } // 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 public init( baseURL: URL, serverName: String, userID: String, userName: String, isAdministrator: Bool ) { self.baseURL = baseURL self.serverName = serverName self.userID = userID self.userName = userName self.isAdministrator = isAdministrator } }