Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.
Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.
**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".
**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.
**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.
**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
460 lines
20 KiB
Swift
460 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
|
|
|
|
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
|
|
}
|
|
}
|