Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
T
ScarriffleandClaude Opus 5 93f4dfd8e9 Ein frischer JSONDecoder, drei Zeilen neben der Warnung davor
Im Web stand am Abspann „Nächste Folge", in der App nur „Abspann
überspringen". Der Server war es nicht: beide fragen denselben Endpunkt,
und der ordnet nach Staffel und dann nach Nummer — er findet also auch
die erste Folge der nächsten Staffel.

Es war `nachbar`. Die Stelle baute sich einen frischen `JSONDecoder()`,
und der kennt das Datumsformat des Servers nicht. Solange die
Nachbarfolge weder `publishAt` noch `lastPlayedAt` mitbrachte, ging es
gut — mit eingeschalteter Prüfung bringt sie ein `publishAt`. Das `try?`
schluckte den Fehler, und von aussen sah es aus, als gäbe es keine
nächste Folge: kein Knopf, kein Hinweis, nichts.

In `FlaroClient` steht seit je ein eingerichteter Dekoder, ausdrücklich
nicht privat, „damit die Prüfungen denselben Dekoder benutzen" — und
direkt darunter die Warnung, dass ein frischer an genau diesem Format
scheitert. Diese eine Stelle las sie nicht.

Vier Prüfungen dazu, darunter die mit Datumsangaben: mit dem alten
Dekoder ist sie rot, mit dem neuen grün. Gegengeprüft, nicht behauptet.

Die übrigen frischen Dekoder im Quelltext lesen nur, was dieselbe App
vorher selbst geschrieben hat — Zwischenspeicher, Abzüge, Offline-
Angaben. Die laufen rund und bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 08:39:31 +02:00

586 lines
25 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.
// **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<Wire.Series>.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<Wire.Movie>.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<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? {
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<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 {
// **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<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
}
}