Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web beim Überfahren, in der App beim Ziehen. **Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453 Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch. Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein Zweistünder liegt damit bei etwa fünf Sekunden. **Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2, tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen kann, sieht niemand. **Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.** Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App — und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des nächsten, und hinter dem Ende. **Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2` hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben. Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf. **Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus, überliess der Spieler die Pfeile dem Browser — der verschiebt `value` und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden gewesen, weil `step` auf 0,1 steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
26 KiB
Swift
612 lines
26 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? {
|
|
try await nachbar(von: item, weg: "next")
|
|
}
|
|
|
|
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
|
try await nachbar(von: item, weg: "previous")
|
|
}
|
|
|
|
/// Die Folge vor oder nach dieser.
|
|
///
|
|
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
|
|
/// oder ihr Anfang.
|
|
private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
|
|
guard item.kind == .episode else { return nil }
|
|
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
|
|
guard !daten.isEmpty else { return nil }
|
|
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
|
|
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
|
|
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
|
|
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
|
|
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
|
|
// gäbe es keine nächste Folge: der Knopf blieb weg.
|
|
guard let folge = try? FlaroClient.decoder.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.
|
|
/// Wie die Vorschaubilder einer Datei liegen.
|
|
///
|
|
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
|
|
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
|
|
/// der Spieler fragt später noch einmal.
|
|
public func vorschauplan(fileId: String) async -> Vorschauplan? {
|
|
guard let auskunft = try? await client.send(
|
|
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
|
|
as: Wire.Vorschau.self), auskunft.fertig
|
|
else { return nil }
|
|
|
|
return Vorschauplan(
|
|
intervall: auskunft.intervall,
|
|
spalten: auskunft.spalten,
|
|
zeilen: auskunft.zeilen,
|
|
kachelBreite: Double(auskunft.kachelBreite),
|
|
kachelHoehe: Double(auskunft.kachelHoehe),
|
|
blaetter: auskunft.blaetter)
|
|
}
|
|
|
|
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
|
|
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
|
|
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
|
|
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|