`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige Ansicht musste dafür neu geschrieben werden. `ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht beide nebeneinander. **Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran. --- **Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt; es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link. Flaro tut das nicht, und deshalb legt der Client sein Token selbst im gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie setzen könnte. `URLSession` schickt es von allein mit; **AVFoundation nicht**. Der Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine liegen, ist die Liste leer. **`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer einzigen Spur entfällt gleich mit. **Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport. Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen unverändert gegen die verschobene Fassung; das ist der Beweis. **Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der Schlüssel heisst jetzt so neutral wie der Text. --- **Der Fehler, den erst der Simulator zeigte:** „Stelle wird wiederhergestellt", 0:00, für immer. 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. Die Weboberfläche fragt in einer Schleife nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt. 413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s, gesehen, ein Durchlauf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
418 lines
18 KiB
Swift
418 lines
18 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] {
|
|
// **Vier 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")
|
|
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
|
|
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
|
|
|
|
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))
|
|
}
|
|
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
|
reihen.append(MediaRowGroup(
|
|
id: "flaro.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.series",
|
|
title: String(localized: "flaro.row.series", bundle: .module),
|
|
items: gefunden.items.map { $0.neutral() }))
|
|
}
|
|
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()
|
|
}
|
|
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
|
|
}
|
|
}
|