Die Weboberfläche holt alles, was auf der Startseite steht, in einem
einzigen `Promise.all` — und sie holt sogar mehr davon: Vorschlag und
saisonale Reihe kennt die App gar nicht. Trotzdem war sie spürbar
langsamer. Der Grund stand in einer Schleife:
for mediathek in mediatheken {
... await client.send(...)
}
Ein `await` im Schleifenrumpf heisst: Rundreise, warten, nächste
Rundreise. Bei vier Mediatheken viermal hintereinander, nachdem
„Fortsetzen" und „Als Nächstes" längst da waren. Dieselbe Schleife stand
in beiden Hintergründen, für Flaro wie für Jellyfin.
Jetzt laufen sie nebeneinander, in einer `TaskGroup`. Die Reihenfolge der
Mediatheken bleibt erhalten — nach den Antworten zu ordnen hiesse, dass
die Reihen nach jedem Start woanders stünden. Ein Actor steht dem nicht
im Weg: jede Anfrage gibt ihn beim Warten frei, genau wie die beiden
`async let` darüber es schon tun.
Nebenbei: ein Fehler bei einer Mediathek nimmt die anderen nicht mehr
mit. Eine Startseite mit drei von vier Reihen ist besser als keine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498 lines
21 KiB
Swift
498 lines
21 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 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
|
|
}
|
|
}
|