Files
Flaro/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
T
ScarriffleandClaude Opus 5 8f0568cb6c Ohne Server stand man vor der Tür, mit dem Film in der Tasche
Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit
zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im
Flugzeug nichts — und dahinter lagen die Dateien, die man genau für
diesen Fall heruntergeladen hatte.

Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät,
die Mediathekenliste auch, und die Startseite kommt neuerdings aus
demselben Speicher. Oben die Downloads, darunter der Stand vom letzten
Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt
den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf
bleibt an, weil die Datei ja da ist.

Und was man offline geschaut hat, zählt: Fortschritt, den der Server
nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder
antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:41:22 +02:00

568 lines
24 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? {
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 {
// **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
}
}