Die Apps können jetzt beides: Jellyfin oder Flaro
`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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6d5824b889
commit
172b38da06
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
|
||||
pendingStartTime = source.startTime
|
||||
|
||||
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
|
||||
//
|
||||
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
|
||||
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
|
||||
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
|
||||
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
|
||||
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
|
||||
//
|
||||
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||
// ändert sich nichts.
|
||||
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||
|
||||
let asset = AVURLAsset(url: source.url, options: [
|
||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||
AVURLAssetHTTPCookiesKey: kekse,
|
||||
])
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Adressraterei für Flaro.
|
||||
///
|
||||
/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von
|
||||
/// Jellyfin unterscheidet: der Port.
|
||||
public enum FlaroAddress {
|
||||
/// Der Standardport eines Flaro-Servers.
|
||||
///
|
||||
/// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und
|
||||
/// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins
|
||||
/// Leere läuft, bevor sie den richtigen findet.
|
||||
public static let defaultPort = 8080
|
||||
|
||||
public static func candidates(for input: String) -> [URL] {
|
||||
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Wer wir für einen Flaro-Server sind.
|
||||
///
|
||||
/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt
|
||||
/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät
|
||||
/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg
|
||||
/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an,
|
||||
/// und in der Geräteliste sammelt sich Müll.
|
||||
public struct FlaroIdentity: Sendable, Equatable {
|
||||
public let deviceName: String
|
||||
public let deviceID: String
|
||||
|
||||
public init(deviceName: String, deviceID: String) {
|
||||
self.deviceName = deviceName
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
}
|
||||
|
||||
/// Spricht mit einem Flaro-Server.
|
||||
public actor FlaroClient {
|
||||
public nonisolated let baseURL: URL
|
||||
private let identity: FlaroIdentity
|
||||
private let transport: any FlaroTransport
|
||||
private let cookieJar: (any FlaroCookieJar)?
|
||||
private var token: String?
|
||||
|
||||
public init(
|
||||
baseURL: URL,
|
||||
identity: FlaroIdentity,
|
||||
token: String? = nil,
|
||||
transport: any FlaroTransport = URLSession.shared,
|
||||
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.identity = identity
|
||||
self.token = token
|
||||
self.transport = transport
|
||||
self.cookieJar = cookieJar
|
||||
if let token { plant(token) }
|
||||
}
|
||||
|
||||
public func setToken(_ token: String?) {
|
||||
self.token = token
|
||||
if let token { plant(token) } else { clearCookie() }
|
||||
}
|
||||
|
||||
public nonisolated var deviceID: String { identity.deviceID }
|
||||
public nonisolated var deviceName: String { identity.deviceName }
|
||||
|
||||
// MARK: - Das Cookie
|
||||
|
||||
/// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken.
|
||||
///
|
||||
/// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde
|
||||
/// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem
|
||||
/// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL`
|
||||
/// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der
|
||||
/// Adresse selbst".
|
||||
///
|
||||
/// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der
|
||||
/// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit
|
||||
/// der Standardkonfiguration und schicken es von sich aus mit; dem
|
||||
/// Systemplayer wird es ausdrücklich mitgegeben (siehe
|
||||
/// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht
|
||||
/// von allein liest.
|
||||
///
|
||||
/// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung
|
||||
/// auch selbst — aber nach einem Neustart hat die App nur das Token aus
|
||||
/// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre
|
||||
/// kein Cookie da, und jedes Plakat bliebe grau.
|
||||
private nonisolated func plant(_ token: String) {
|
||||
guard let cookie = Self.cookie(for: baseURL, token: token) else { return }
|
||||
cookieJar?.store(cookie)
|
||||
}
|
||||
|
||||
private nonisolated func clearCookie() {
|
||||
guard let host = baseURL.host() else { return }
|
||||
cookieJar?.removeCookie(named: Self.cookieName, host: host)
|
||||
}
|
||||
|
||||
public static let cookieName = "flaro_session"
|
||||
|
||||
nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? {
|
||||
guard let host = baseURL.host() else { return nil }
|
||||
return HTTPCookie(properties: [
|
||||
.name: cookieName,
|
||||
.value: token,
|
||||
.domain: host,
|
||||
.path: "/",
|
||||
// Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das
|
||||
// Plakat lädt nicht mehr, obwohl das Token noch gilt.
|
||||
.expires: Date(timeIntervalSinceNow: 365 * 24 * 3600),
|
||||
])
|
||||
}
|
||||
|
||||
/// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll.
|
||||
public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] {
|
||||
guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] }
|
||||
return [cookie]
|
||||
}
|
||||
|
||||
// MARK: - Adressen
|
||||
|
||||
/// Die Adresse eines Endpunkts.
|
||||
///
|
||||
/// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen —
|
||||
/// wer die Adresse abruft, bringt das Cookie mit.
|
||||
public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appending(path: path),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else { return nil }
|
||||
components.queryItems = query.isEmpty ? nil : query
|
||||
return components.url
|
||||
}
|
||||
|
||||
// MARK: - Anfragen
|
||||
|
||||
public func send<Value: Decodable & Sendable>(
|
||||
_ request: FlaroRequest,
|
||||
as type: Value.Type
|
||||
) async throws(MediaBackendError) -> Value {
|
||||
let data = try await send(request)
|
||||
do {
|
||||
return try Self.decoder.decode(Value.self, from: data)
|
||||
} catch {
|
||||
throw .decoding(description: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data {
|
||||
guard var components = URLComponents(
|
||||
url: baseURL.appending(path: request.path),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
}
|
||||
components.queryItems = request.query.isEmpty ? nil : request.query
|
||||
|
||||
guard let url = components.url else {
|
||||
throw .transport(description: "Adresse nicht baubar")
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = request.method.rawValue
|
||||
urlRequest.timeoutInterval = request.timeout.seconds
|
||||
// Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum
|
||||
// selben Token, für Lader, die keine Kopfzeilen können.
|
||||
if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body = request.body {
|
||||
urlRequest.httpBody = body
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await transport.data(for: urlRequest)
|
||||
} catch {
|
||||
throw .transport(description: error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw .transport(description: "Keine HTTP-Antwort")
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200..<300: return data
|
||||
case 401, 403: throw .unauthorized
|
||||
case 404: throw .notFound
|
||||
default:
|
||||
// **Flaro begründet seine Absagen.** Ein Problem-Dokument nach
|
||||
// RFC 9457 trägt einen Satz, den der Server selbst formuliert
|
||||
// hat — und der ist mehr wert als die Zahl davor. Genau daran
|
||||
// hing beim Live-TV tagelang alles, weil die Antwort weggeworfen
|
||||
// wurde.
|
||||
if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) }
|
||||
throw .server(status: http.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Satz aus einem Problem-Dokument, falls einer dasteht.
|
||||
private static func begruendung(in data: Data) -> String? {
|
||||
struct Problem: Decodable { let detail: String?; let title: String? }
|
||||
guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil }
|
||||
let text = problem.detail ?? problem.title
|
||||
return text?.isEmpty == false ? text : nil
|
||||
}
|
||||
|
||||
/// Flaro schreibt klein und rechnet in Sekunden.
|
||||
///
|
||||
/// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie
|
||||
/// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und
|
||||
/// Swift liest sie genauso.
|
||||
///
|
||||
/// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit
|
||||
/// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an
|
||||
/// genau dem Datumsformat, für das die Strategie unten da ist.
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601WithFraction
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
|
||||
/// Eine Anfrage an Flaro.
|
||||
public struct FlaroRequest: Sendable {
|
||||
public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" }
|
||||
|
||||
public let method: Method
|
||||
public let path: String
|
||||
public let query: [URLQueryItem]
|
||||
public let body: Data?
|
||||
public let timeout: Duration
|
||||
|
||||
public init(
|
||||
method: Method = .get,
|
||||
path: String,
|
||||
query: [URLQueryItem] = [],
|
||||
body: Data? = nil,
|
||||
timeout: Duration = .seconds(30)
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.query = query
|
||||
self.body = body
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
/// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert.
|
||||
public static func json(
|
||||
_ method: Method, _ path: String, _ value: some Encodable
|
||||
) -> FlaroRequest {
|
||||
FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie Anfragen tatsächlich verschickt werden.
|
||||
///
|
||||
/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern
|
||||
/// vorbereitete Antworten.
|
||||
public protocol FlaroTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: FlaroTransport {}
|
||||
|
||||
/// Wo Cookies landen.
|
||||
///
|
||||
/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde,
|
||||
/// ohne den Cookie-Speicher des ganzen Systems anzufassen.
|
||||
public protocol FlaroCookieJar: Sendable {
|
||||
func store(_ cookie: HTTPCookie)
|
||||
func removeCookie(named name: String, host: String)
|
||||
}
|
||||
|
||||
extension HTTPCookieStorage: FlaroCookieJar {
|
||||
public func store(_ cookie: HTTPCookie) { setCookie(cookie) }
|
||||
|
||||
public func removeCookie(named name: String, host: String) {
|
||||
cookies?
|
||||
.filter { $0.name == name && $0.domain.hasSuffix(host) }
|
||||
.forEach(deleteCookie)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder.DateDecodingStrategy {
|
||||
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
|
||||
///
|
||||
/// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das
|
||||
/// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den
|
||||
/// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein
|
||||
/// Feld gelesen wird, das die meisten Antworten leer lassen.
|
||||
static var iso8601WithFraction: Self {
|
||||
.custom { decoder in
|
||||
let text = try decoder.singleValueContainer().decode(String.self)
|
||||
let mitBruch = ISO8601DateFormatter()
|
||||
mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let datum = mitBruch.date(from: text) { return datum }
|
||||
|
||||
let ohne = ISO8601DateFormatter()
|
||||
ohne.formatOptions = [.withInternetDateTime]
|
||||
if let datum = ohne.date(from: text) { return datum }
|
||||
|
||||
throw DecodingError.dataCorrupted(.init(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Kein ISO-8601-Datum: \(text)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// Was Flaro über die Leitung schickt.
|
||||
///
|
||||
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
|
||||
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
|
||||
/// Typen steht in `FlaroBackend+Mapping`.
|
||||
///
|
||||
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
|
||||
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
|
||||
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
|
||||
/// nachziehen müsste.
|
||||
enum Wire {
|
||||
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
|
||||
let items: [T]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
struct SystemInfo: Decodable, Sendable {
|
||||
let version: String?
|
||||
let serverName: String?
|
||||
let needsSetup: Bool
|
||||
let attributions: [Attribution]?
|
||||
}
|
||||
|
||||
struct Attribution: Decodable, Sendable {
|
||||
let text: String
|
||||
let logoUrl: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable, Sendable {
|
||||
let token: String
|
||||
let user: User
|
||||
}
|
||||
|
||||
struct User: Decodable, Sendable {
|
||||
let id: String
|
||||
let userName: String
|
||||
let isAdministrator: Bool
|
||||
}
|
||||
|
||||
struct Library: Decodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let kind: String
|
||||
let path: String
|
||||
}
|
||||
|
||||
struct Stream: Decodable, Sendable {
|
||||
let index: Int
|
||||
let kind: String
|
||||
let codec: String?
|
||||
let language: String?
|
||||
let title: String?
|
||||
let isDefault: Bool
|
||||
let isForced: Bool
|
||||
let channels: Int?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
}
|
||||
|
||||
struct File: Decodable, Sendable {
|
||||
let id: String
|
||||
let fileName: String
|
||||
let sizeBytes: Int64
|
||||
let container: String?
|
||||
let durationSeconds: Double?
|
||||
let streams: [Stream]
|
||||
}
|
||||
|
||||
struct ItemState: Decodable, Sendable {
|
||||
let positionSeconds: Double
|
||||
let runtimeSeconds: Double?
|
||||
let watched: Bool
|
||||
let playCount: Int
|
||||
let favourite: Bool
|
||||
let rating: Int?
|
||||
let dismissed: Bool
|
||||
let lastPlayedAt: Date?
|
||||
}
|
||||
|
||||
struct Movie: Decodable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let year: Int?
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
let files: [File]
|
||||
let state: ItemState?
|
||||
}
|
||||
|
||||
struct Series: Decodable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let year: Int?
|
||||
let overview: String?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
let episodeCount: Int
|
||||
let seasons: [Int]
|
||||
let unwatchedCount: Int
|
||||
}
|
||||
|
||||
struct Episode: Decodable, Sendable {
|
||||
let id: String
|
||||
let season: Int
|
||||
let episode: Int
|
||||
let title: String?
|
||||
let overview: String?
|
||||
let stillImage: String?
|
||||
let files: [File]
|
||||
let state: ItemState?
|
||||
}
|
||||
|
||||
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||
struct Started: Decodable, Sendable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let title: String
|
||||
let subTitle: String?
|
||||
let season: Int?
|
||||
let episode: Int?
|
||||
let year: Int?
|
||||
let posterImage: String?
|
||||
let backdropImage: String?
|
||||
let state: ItemState
|
||||
}
|
||||
|
||||
struct CodeResponse: Decodable, Sendable {
|
||||
let code: String
|
||||
let handle: String
|
||||
let expiresAt: Date
|
||||
}
|
||||
|
||||
/// `state` ist „pending", „approved" oder „expired".
|
||||
struct CodeState: Decodable, Sendable {
|
||||
let state: String
|
||||
let login: LoginResponse?
|
||||
}
|
||||
|
||||
struct CodeRequest: Encodable, Sendable {
|
||||
let deviceName: String
|
||||
let deviceId: String
|
||||
}
|
||||
|
||||
struct Delivery: Decodable, Sendable {
|
||||
let method: String
|
||||
let reasons: [String]
|
||||
let audioToEncode: [Int]
|
||||
let subtitlesDropped: [Int]
|
||||
let packageState: String
|
||||
let progress: Double
|
||||
let error: String?
|
||||
}
|
||||
|
||||
// --- Was hinausgeht --------------------------------------------------
|
||||
|
||||
struct LoginRequest: Encodable, Sendable {
|
||||
let userName: String
|
||||
let password: String
|
||||
let deviceName: String
|
||||
let deviceId: String
|
||||
}
|
||||
|
||||
struct ProgressRequest: Encodable, Sendable {
|
||||
let positionSeconds: Double
|
||||
let runtimeSeconds: Double?
|
||||
}
|
||||
|
||||
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
|
||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension FlaroIdentity {
|
||||
private static let storageKey = "flaro.flaroServer.deviceID"
|
||||
|
||||
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
|
||||
///
|
||||
/// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene
|
||||
/// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass
|
||||
/// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von
|
||||
/// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein
|
||||
/// Gerät, das es dort gar nicht mehr gibt.
|
||||
///
|
||||
/// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues
|
||||
/// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr.
|
||||
///
|
||||
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
|
||||
@MainActor
|
||||
public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity {
|
||||
let deviceID: String
|
||||
if let stored = defaults.string(forKey: storageKey) {
|
||||
deviceID = stored
|
||||
} else {
|
||||
deviceID = UUID().uuidString
|
||||
defaults.set(deviceID, forKey: storageKey)
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
let name = UIDevice.current.name
|
||||
#else
|
||||
let name = ProcessInfo.processInfo.hostName
|
||||
#endif
|
||||
|
||||
return FlaroIdentity(deviceName: name, deviceID: deviceID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Aus Flaros Antworten werden neutrale Typen.
|
||||
///
|
||||
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
|
||||
/// wird. Alles darüber kennt nur noch `MediaItem`.
|
||||
extension Wire.Movie {
|
||||
func neutral() -> MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
kind: .movie,
|
||||
title: title,
|
||||
summary: overview,
|
||||
year: year,
|
||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
playCount: state?.playCount,
|
||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||
lastPlayedAt: state?.lastPlayedAt,
|
||||
isFavourite: state?.favourite ?? false,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.Series {
|
||||
func neutral() -> MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
kind: .show,
|
||||
title: title,
|
||||
summary: overview,
|
||||
year: year,
|
||||
thumb: posterImage,
|
||||
art: backdropImage,
|
||||
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||
// zu derselben Aussage zu haben.
|
||||
episodeCount: episodeCount,
|
||||
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||
childCount: seasons.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.Episode {
|
||||
func neutral(series: Wire.Series? = nil) -> MediaItem {
|
||||
MediaItem(
|
||||
id: id,
|
||||
kind: .episode,
|
||||
title: title ?? "Folge \(episode)",
|
||||
summary: overview,
|
||||
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||
thumb: stillImage,
|
||||
seriesThumb: series?.posterImage,
|
||||
seriesArt: series?.backdropImage,
|
||||
index: episode,
|
||||
parentIndex: season,
|
||||
seriesTitle: series?.title,
|
||||
seriesID: series?.id,
|
||||
playCount: state?.playCount,
|
||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||
lastPlayedAt: state?.lastPlayedAt,
|
||||
isFavourite: state?.favourite ?? false,
|
||||
versions: [Wire.fassung(id: id, files: files)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.Started {
|
||||
/// Die Kachel aus „Weiterschauen" als Titel.
|
||||
///
|
||||
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
|
||||
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
|
||||
/// antippt, holt den Titel einzeln — und bekommt dann alles.
|
||||
func neutral() -> MediaItem {
|
||||
let folge = kind == "episode"
|
||||
return MediaItem(
|
||||
id: id,
|
||||
kind: folge ? .episode : .movie,
|
||||
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
|
||||
year: year,
|
||||
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||
thumb: folge ? backdropImage : posterImage,
|
||||
art: backdropImage,
|
||||
seriesThumb: folge ? posterImage : nil,
|
||||
index: episode,
|
||||
parentIndex: season,
|
||||
seriesTitle: folge ? title : nil,
|
||||
playCount: state.playCount,
|
||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||
lastPlayedAt: state.lastPlayedAt,
|
||||
isFavourite: state.favourite
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.Library {
|
||||
func neutral() -> MediaLibrary {
|
||||
MediaLibrary(
|
||||
id: id,
|
||||
title: name,
|
||||
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
|
||||
// durchzulassen wäre eine Vorsorge für einen Fall, den der
|
||||
// Server gar nicht erzeugen kann.
|
||||
kind: kind.lowercased() == "shows" ? .show : .movie)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.File {
|
||||
func neutral(itemID: String) -> MediaFile {
|
||||
MediaFile(
|
||||
id: id,
|
||||
key: "/api/v1/files/\(id)/download",
|
||||
itemID: itemID,
|
||||
container: container,
|
||||
size: sizeBytes,
|
||||
duration: durationSeconds.map { Int($0 * 1000) },
|
||||
file: fileName,
|
||||
streams: streams.map { $0.neutral() })
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire.Stream {
|
||||
func neutral() -> MediaStream {
|
||||
let art: MediaStream.Kind = switch kind.lowercased() {
|
||||
case "video": .video
|
||||
case "subtitle": .subtitle
|
||||
default: .audio
|
||||
}
|
||||
|
||||
return MediaStream(
|
||||
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
|
||||
// sie aus `id` zurück, und daran hängt die Spurwahl beim
|
||||
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
|
||||
// zurückrechnen kann.
|
||||
id: String(index),
|
||||
kind: art,
|
||||
codec: codec,
|
||||
language: language,
|
||||
languageCode: language,
|
||||
displayTitle: title ?? language,
|
||||
channels: channels,
|
||||
isSelected: isDefault,
|
||||
isForced: isForced,
|
||||
title: title,
|
||||
isDefault: isDefault)
|
||||
}
|
||||
}
|
||||
|
||||
extension Wire {
|
||||
/// Eine Fassung aus den Dateien eines Titels.
|
||||
///
|
||||
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
|
||||
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
|
||||
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
|
||||
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
|
||||
/// entscheidet die App.
|
||||
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
|
||||
let erste = files.first
|
||||
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
|
||||
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
|
||||
|
||||
return MediaVersion(
|
||||
id: erste?.id ?? id,
|
||||
container: erste?.container,
|
||||
videoCodec: bild?.codec,
|
||||
audioCodec: ton?.codec,
|
||||
videoResolution: bild?.height.map(String.init),
|
||||
width: bild?.width,
|
||||
height: bild?.height,
|
||||
audioChannels: ton?.channels,
|
||||
duration: erste?.durationSeconds.map { Int($0 * 1000) },
|
||||
files: files.map { $0.neutral(itemID: id) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Anmeldung an einem Flaro-Server.
|
||||
///
|
||||
/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch
|
||||
/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte.
|
||||
public enum FlaroSignIn {
|
||||
/// Was der Server über sich sagt, bevor sich jemand anmeldet.
|
||||
public struct Greeting: Sendable, Equatable {
|
||||
public let serverName: String
|
||||
public let version: String?
|
||||
/// Ob der Server überhaupt schon eingerichtet ist.
|
||||
///
|
||||
/// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen
|
||||
/// Server anspricht, hat kein Konto — und „Benutzername oder
|
||||
/// Passwort stimmt nicht" wäre dann die unbrauchbarste aller
|
||||
/// Antworten.
|
||||
public let needsSetup: Bool
|
||||
/// Der Pflichthinweis auf die Metadatenquelle.
|
||||
public let attributions: [MetadataAttribution]
|
||||
}
|
||||
|
||||
public struct MetadataAttribution: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let logoURL: URL?
|
||||
public let url: URL?
|
||||
}
|
||||
|
||||
/// Fragt einen Server, wer er ist — ohne Anmeldung.
|
||||
///
|
||||
/// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse
|
||||
/// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein
|
||||
/// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf.
|
||||
public static func greet(
|
||||
baseURL: URL,
|
||||
identity: FlaroIdentity,
|
||||
transport: any FlaroTransport = URLSession.shared
|
||||
) async throws(MediaBackendError) -> Greeting {
|
||||
let client = FlaroClient(
|
||||
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||
let auskunft = try await client.send(
|
||||
FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self)
|
||||
|
||||
return Greeting(
|
||||
serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro",
|
||||
version: auskunft.version,
|
||||
needsSetup: auskunft.needsSetup,
|
||||
attributions: (auskunft.attributions ?? []).map {
|
||||
MetadataAttribution(
|
||||
text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url))
|
||||
})
|
||||
}
|
||||
|
||||
/// Meldet an und liefert den Zugang samt fertigem Backend.
|
||||
public static func signIn(
|
||||
baseURL: URL,
|
||||
userName: String,
|
||||
password: String,
|
||||
identity: FlaroIdentity,
|
||||
transport: any FlaroTransport = URLSession.shared,
|
||||
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||
) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) {
|
||||
let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||
|
||||
let client = FlaroClient(
|
||||
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||
|
||||
let antwort = try await client.send(
|
||||
.json(.post, "/api/v1/auth/login", Wire.LoginRequest(
|
||||
userName: userName,
|
||||
password: password,
|
||||
deviceName: identity.deviceName,
|
||||
deviceId: identity.deviceID)),
|
||||
as: Wire.LoginResponse.self)
|
||||
|
||||
// **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre
|
||||
// eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre
|
||||
// dann schon gesetzt, bevor feststeht, dass es gilt.
|
||||
await client.setToken(antwort.token)
|
||||
|
||||
return (antwort.token, FlaroAccount(
|
||||
baseURL: baseURL,
|
||||
serverName: gruss.serverName,
|
||||
userID: antwort.user.id,
|
||||
userName: antwort.user.userName,
|
||||
isAdministrator: antwort.user.isAdministrator), client)
|
||||
}
|
||||
|
||||
/// Nimmt ein gespeichertes Token wieder auf.
|
||||
///
|
||||
/// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät
|
||||
/// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an,
|
||||
/// und in der Liste stünde dann bei jedem Start eines mehr.
|
||||
public static func restore(
|
||||
baseURL: URL,
|
||||
token: String,
|
||||
identity: FlaroIdentity,
|
||||
transport: any FlaroTransport = URLSession.shared,
|
||||
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||
) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) {
|
||||
let client = FlaroClient(
|
||||
baseURL: baseURL, identity: identity, token: token,
|
||||
transport: transport, cookieJar: cookieJar)
|
||||
|
||||
let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self)
|
||||
let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||
|
||||
return (FlaroAccount(
|
||||
baseURL: baseURL,
|
||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||
userID: wer.id,
|
||||
userName: wer.userName,
|
||||
isAdministrator: wer.isAdministrator), client)
|
||||
}
|
||||
|
||||
// MARK: - Der Weg über einen Code
|
||||
|
||||
/// Ein angeforderter Code samt Abholschein.
|
||||
public struct PendingCode: Sendable, Equatable {
|
||||
/// Die sechs Ziffern, die auf dem Fernseher stehen.
|
||||
public let code: String
|
||||
/// **Der Abholschein, und er ist das eigentliche Geheimnis.**
|
||||
///
|
||||
/// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der
|
||||
/// Code allein, liessen sich eine Million Möglichkeiten durchprobieren
|
||||
/// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm
|
||||
/// im Wohnzimmer.
|
||||
public let handle: String
|
||||
public let expiresAt: Date
|
||||
}
|
||||
|
||||
public enum CodeState: Sendable, Equatable {
|
||||
case pending
|
||||
case approved(token: String, account: FlaroAccount)
|
||||
case expired
|
||||
}
|
||||
|
||||
/// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt.
|
||||
public static func requestCode(
|
||||
baseURL: URL,
|
||||
identity: FlaroIdentity,
|
||||
transport: any FlaroTransport = URLSession.shared
|
||||
) async throws(MediaBackendError) -> PendingCode {
|
||||
let client = FlaroClient(
|
||||
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||
let antwort = try await client.send(
|
||||
.json(.post, "/api/v1/auth/code", Wire.CodeRequest(
|
||||
deviceName: identity.deviceName, deviceId: identity.deviceID)),
|
||||
as: Wire.CodeResponse.self)
|
||||
|
||||
return PendingCode(
|
||||
code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt)
|
||||
}
|
||||
|
||||
/// Fragt nach, ob jemand den Code inzwischen bestätigt hat.
|
||||
public static func pollCode(
|
||||
baseURL: URL,
|
||||
pending: PendingCode,
|
||||
identity: FlaroIdentity,
|
||||
transport: any FlaroTransport = URLSession.shared,
|
||||
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||
) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) {
|
||||
let client = FlaroClient(
|
||||
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||
|
||||
// **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`.
|
||||
let zustand = try await client.send(
|
||||
FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self)
|
||||
|
||||
switch zustand.state {
|
||||
case "approved":
|
||||
guard let anmeldung = zustand.login else { return (.expired, nil) }
|
||||
await client.setToken(anmeldung.token)
|
||||
let gruss = try? await greet(
|
||||
baseURL: baseURL, identity: identity, transport: transport)
|
||||
return (.approved(token: anmeldung.token, account: FlaroAccount(
|
||||
baseURL: baseURL,
|
||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||
userID: anmeldung.user.id,
|
||||
userName: anmeldung.user.userName,
|
||||
isAdministrator: anmeldung.user.isAdministrator)), client)
|
||||
case "expired":
|
||||
return (.expired, nil)
|
||||
default:
|
||||
return (.pending, nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Meldet dieses Gerät ab und widerruft sein Token.
|
||||
public static func signOut(client: FlaroClient) async {
|
||||
_ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout"))
|
||||
await client.setToken(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"flaro.remux.tooLong": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Remuxing is taking unusually long. Check the server log."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.continue": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Weiterschauen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Continue Watching"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.movies": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Filme"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Movies"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.nextUp": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nächste Folge"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Next Up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flaro.row.series": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Serien"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Shows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -1,65 +1,17 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
||||
/// Die Adressraterei für Jellyfin.
|
||||
///
|
||||
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
|
||||
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
|
||||
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
|
||||
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
|
||||
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
|
||||
/// die sich als Jellyfin-Server ausweist.
|
||||
///
|
||||
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
||||
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
||||
/// Domänenname fast immer.
|
||||
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
|
||||
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
|
||||
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
|
||||
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
|
||||
public enum JellyfinAddress {
|
||||
/// Der Standardport eines Jellyfin-Servers.
|
||||
public static let defaultPort = 8096
|
||||
|
||||
public static func candidates(for input: String) -> [URL] {
|
||||
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while text.hasSuffix("/") { text.removeLast() }
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
||||
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
||||
// ausdrücklich „https" getippt hat.
|
||||
if text.contains("://") {
|
||||
return URL(string: text).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
||||
// Doppelpunkte sonst als Port gelesen würden.
|
||||
if text.hasPrefix("[") {
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
||||
let host = String(authority.split(separator: ":").first ?? "")
|
||||
let hasExplicitPort = authority.contains(":")
|
||||
&& Int(authority.split(separator: ":").last ?? "") != nil
|
||||
|
||||
if hasExplicitPort {
|
||||
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
||||
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
||||
// — alles Dinge, die es nur im eigenen Netz gibt.
|
||||
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
||||
|| host.hasSuffix(".local")
|
||||
|| !host.contains(".")
|
||||
|
||||
let forms = isLocalLooking
|
||||
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
|
||||
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
|
||||
return forms.compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
||||
private static func withDefaultPort(_ text: String) -> String {
|
||||
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
|
||||
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
|
||||
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,18 @@ public enum BackendCapability: String, Sendable, Hashable {
|
||||
/// Die Verwaltung des Servers aus der App heraus.
|
||||
case administration
|
||||
case transcoding
|
||||
/// Ein Download bringt **alle** Ton- und Textspuren mit.
|
||||
///
|
||||
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins
|
||||
/// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was
|
||||
/// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline
|
||||
/// weg. Einmal hat die App dabei still die Audiodeskription genommen —
|
||||
/// drei Gigabyte Mobilfunk für eine unbrauchbare Datei.
|
||||
///
|
||||
/// Kann der Server es besser, soll die App nicht so tun, als müsste
|
||||
/// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl
|
||||
/// im Sheet entfällt, und der gewöhnliche Download liefert alles.
|
||||
case allTracksDownload
|
||||
}
|
||||
|
||||
/// Ein Ausschnitt aus einer Mediathek.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
||||
///
|
||||
/// Weder Jellyfin noch Flaro haben ein Verzeichnis wie plex.tv — die Adresse
|
||||
/// kommt vom Nutzer. Und die tippt niemand vollständig: „192.168.1.50" ohne
|
||||
/// Schema, ohne Port, oder „medien.example.com" ohne „https://". Dieser Typ
|
||||
/// rät nicht **eine** Ergänzung, sondern nennt die plausiblen in der
|
||||
/// Reihenfolge, in der sie es wahrscheinlich sind; der Aufrufer probiert sie
|
||||
/// durch und nimmt die erste, die sich ausweist.
|
||||
///
|
||||
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
||||
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
||||
/// Domänenname fast immer.
|
||||
///
|
||||
/// **In der neutralen Schicht, weil beide Server dasselbe Problem haben.**
|
||||
/// Nur der Standardport unterscheidet sich, und der ist ein Wert, kein
|
||||
/// Gedanke. Diese Überlegungen zweimal hinzuschreiben hiesse, dass die eine
|
||||
/// Fassung beim nächsten Sonderfall nachgezogen wird und die andere nicht.
|
||||
public enum ServerAddress {
|
||||
public static func candidates(for input: String, defaultPort: Int) -> [URL] {
|
||||
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while text.hasSuffix("/") { text.removeLast() }
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
||||
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
||||
// ausdrücklich „https" getippt hat.
|
||||
if text.contains("://") {
|
||||
return URL(string: text).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
||||
// Doppelpunkte sonst als Port gelesen würden.
|
||||
if text.hasPrefix("[") {
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
||||
let host = String(authority.split(separator: ":").first ?? "")
|
||||
let hasExplicitPort = authority.contains(":")
|
||||
&& Int(authority.split(separator: ":").last ?? "") != nil
|
||||
|
||||
if hasExplicitPort {
|
||||
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
||||
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
||||
// — alles Dinge, die es nur im eigenen Netz gibt.
|
||||
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
||||
|| host.hasSuffix(".local")
|
||||
|| !host.contains(".")
|
||||
|
||||
let mitPort = withDefaultPort(text, port: defaultPort)
|
||||
let forms = isLocalLooking
|
||||
? ["http://\(mitPort)", "http://\(text)", "https://\(text)"]
|
||||
: ["https://\(text)", "http://\(text)", "http://\(mitPort)"]
|
||||
return forms.compactMap(URL.init(string:))
|
||||
}
|
||||
|
||||
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
||||
private static func withDefaultPort(_ text: String, port: Int) -> String {
|
||||
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" }
|
||||
return "\(text[text.startIndex..<slash]):\(port)\(text[slash...])"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user