Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
T
ScarriffleandClaude Opus 5 e30158b84c Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall:
`/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem
widersprüchlich — die **Beschriftung**.

Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein
daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel
damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug
einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen
entfernen" an.

Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt
sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die
einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach
einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der
Balken unter der Kachel ist weg.

Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt
wie früher die blosse Stelle.

**Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit,
`/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt
zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es
kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:30:38 +02:00

469 lines
20 KiB
Swift

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<BackendCapability> {
[
.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<Wire.Series>.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<Wire.Movie>.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<Wire.Series>.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<Wire.Movie>.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<Wire.Episode>.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<Wire.Movie>.self)
async let serien = client.send(
FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page<Wire.Series>.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<T: Decodable & Sendable>(
pfad: String, take: Int, as type: T.Type
) async throws(MediaBackendError) -> Wire.Page<T> {
try await client.send(
FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]),
as: Wire.Page<T>.self)
}
private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] {
let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page<Wire.Started>.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
}
}