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:
Scarriffle
2026-10-03 22:00:02 +02:00
co-authored by Claude Opus 5
parent 6d5824b889
commit 172b38da06
28 changed files with 3203 additions and 153 deletions
@@ -0,0 +1,150 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Adressen und Zugang — alles, was ohne Netz entsteht.
@Suite("Flaro: Adressen und Zugang")
struct AdressenTests {
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: transport, cookieJar: keksglas),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: true))
}
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
///
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
/// Flaro geht über ein Cookie.
@Test("Bildadressen tragen keinen Zugang")
func bildAdresseOhneToken() {
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
#expect(url?.path == "/api/v1/images/abc123")
#expect(url?.query == nil)
#expect(url?.absoluteString.contains("geheim") == false)
}
@Test("Ein Zugang wird als Cookie abgelegt")
func tokenWirdZuKeks() async {
let glas = Keksglas()
_ = FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
let keks = glas.kekse.first
#expect(keks?.name == "flaro_session")
#expect(keks?.value == "geheim-123")
#expect(keks?.path == "/")
#expect(keks?.domain.hasSuffix("nas") == true)
}
/// **Nach einem Neustart meldet sich niemand neu an.**
///
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
/// bliebe grau, obwohl der Zugang gilt.
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
func keksAuchOhneAnmeldung() async throws {
let glas = Keksglas()
let transport = Attrappe([
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
("/api/v1/system/info", 200, Proben.info),
])
_ = try await FlaroSignIn.restore(
baseURL: Proben.adresse, token: "geheim-123",
identity: Proben.kennung, transport: transport, cookieJar: glas)
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
}
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
func downloadAdresse() {
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
for: MediaFile(id: "datei-1", itemID: "film-1"))
#expect(url?.path == "/api/v1/files/datei-1/download")
}
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
///
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
func alleSpuren() {
let b = backend(Attrappe(rumpf: "{}"))
#expect(b.capabilities.contains(.allTracksDownload))
#expect(b.remuxDownloadURL(
itemID: "film-1", versionIndex: 0,
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
}
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
func kennung() {
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
}
@Test("Anmelden schickt Gerätename und Kennung mit")
func anmeldung() async throws {
let transport = Attrappe([
("/api/v1/system/info", 200, Proben.info),
("/api/v1/auth/login", 200, Proben.anmeldung),
])
let (token, konto, _) = try await FlaroSignIn.signIn(
baseURL: Proben.adresse, userName: "guido", password: "geheim",
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
#expect(token == "geheim-123")
#expect(konto.userName == "guido")
#expect(konto.isAdministrator)
#expect(konto.serverName == "Wohnzimmer")
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
#expect(rumpf["deviceId"] as? String == "geraet-1")
}
/// **Ein frischer Server ist kein falsches Passwort.**
@Test("Ein nicht eingerichteter Server sagt das")
func nochNichtEingerichtet() async throws {
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
let gruss = try await FlaroSignIn.greet(
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
#expect(gruss.needsSetup)
}
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
@Test("Eine Absage mit Begründung kommt als Begründung an")
func begruendeteAbsage() async {
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
}
}
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
func abgelaufen() async {
let b = backend(Attrappe(rumpf: "", status: 401))
await #expect(throws: MediaBackendError.unauthorized) {
_ = try await b.libraries()
}
}
}
@@ -0,0 +1,104 @@
import Foundation
@testable import FlaroServerKit
/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt.
///
/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server,
/// sondern eine Antwort und die Frage, die gestellt wurde.
final class Attrappe: FlaroTransport, @unchecked Sendable {
/// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung
/// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen.
private let antworten: [(pfad: String, status: Int, rumpf: String)]
private let sperre = NSLock()
private var protokoll: [URLRequest] = []
init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) {
self.antworten = antworten
}
convenience init(rumpf: String, status: Int = 200) {
self.init([("", status, rumpf)])
}
var anfragen: [URLRequest] { sperre.withLock { protokoll } }
var letzte: URLRequest? { anfragen.last }
func anfrage(auf pfad: String) -> URLRequest? {
anfragen.last { $0.url?.path == pfad }
}
func rumpf(von pfad: String) -> [String: Any]? {
guard let daten = anfrage(auf: pfad)?.httpBody else { return nil }
return try? JSONSerialization.jsonObject(with: daten) as? [String: Any]
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
sperre.withLock { protokoll.append(request) }
let pfad = request.url?.path ?? ""
let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad }
?? ("", 404, "")
return (
Data(treffer.rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: treffer.status,
httpVersion: nil, headerFields: nil)!
)
}
}
/// Ein Keksglas, das nur mitschreibt.
///
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
private let sperre = NSLock()
private var inhalt: [HTTPCookie] = []
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
func removeCookie(named name: String, host: String) {
sperre.withLock { inhalt.removeAll { $0.name == name } }
}
}
enum Proben {
static let adresse = URL(string: "http://nas:8080")!
static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1")
/// Ein Film mit zwei Tonspuren — der Fall, um den es geht.
static let film = """
{
"id": "film-1", "title": "Der Untergang", "year": 2004,
"overview": "Berlin, April 1945.",
"posterImage": "aaa", "backdropImage": "bbb",
"files": [{
"id": "datei-1", "fileName": "Der Untergang (2004).mkv",
"sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0,
"streams": [
{"index":0,"kind":"Video","codec":"h264","language":null,"title":null,
"isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080},
{"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch",
"isDefault":true,"isForced":false,"channels":6,"width":null,"height":null},
{"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription",
"isDefault":false,"isForced":false,"channels":2,"width":null,"height":null},
{"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch",
"isDefault":false,"isForced":false,"channels":null,"width":null,"height":null}
]
}],
"state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"}
}
"""
static let info = """
{"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false,
"attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]}
"""
static let anmeldung = """
{"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}}
"""
}
@@ -0,0 +1,262 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust.
@Suite("Flaro: Übersetzung")
struct UebersetzungTests {
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
transport: transport, cookieJar: nil),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: false))
}
/// **Beide Tonspuren kommen an, mit Namen und Nummer.**
///
/// Das ist der ganze Anlass: eine Datei mit „Deutsch" und
/// „Audiodeskription", und die App muss beide sehen, um nicht blind die
/// falsche zu nehmen.
@Test("Ein Film behält alle Spuren")
func filmMitZweiTonspuren() async throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
let datei = try #require(titel.versions.first?.files.first)
#expect(titel.title == "Der Untergang")
#expect(titel.year == 2004)
#expect(datei.audioStreams.count == 2)
#expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"])
// Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl.
#expect(datei.audioStreams.map(\.index) == [1, 2])
#expect(datei.subtitleStreams.count == 1)
#expect(datei.videoStream?.codec == "h264")
}
/// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die
/// Jellyfin wegwirft.
@Test("Der Stand kommt als Fortschritt an")
func standWirdFortschritt() throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
#expect(titel.resumeMilliseconds == 300_000)
#expect(titel.duration == 9_360_000)
#expect(titel.isWatched == false)
let anteil = try #require(titel.watchProgress)
#expect(abs(anteil - 300.0 / 9360.0) < 0.0001)
}
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
///
/// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur
/// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander,
/// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist.
@Test("Ein Datum mit Sekundenbruchteilen wird gelesen")
func datumMitBruch() async throws {
let roh = Data(Proben.film.utf8)
#expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil,
"2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum")
#expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil)
let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#)
let seite = try await backend(transport).items(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie),
query: MediaQuery(), offset: 0, limit: 50)
#expect(seite.items.first?.lastPlayedAt != nil)
}
/// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl.
@Test("Offene Folgen werden zu gesehenen")
func serieZaehlt() throws {
let roh = """
{"id":"s1","title":"Dark","year":2017,"overview":null,
"posterImage":"p","backdropImage":"b",
"episodeCount":10,"seasons":[1,2],"unwatchedCount":3}
"""
let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8))
let titel = serie.neutral()
#expect(titel.kind == .show)
#expect(titel.episodeCount == 10)
#expect(titel.playedEpisodeCount == 7)
#expect(titel.childCount == 2)
}
/// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur
/// „Lügen" sagt, hilft beim Wiedererkennen nicht.
@Test("Eine Folge aus Weiterschauen trägt ihre Serie mit")
func weiterschauenFolge() throws {
let roh = """
{"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen",
"season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still",
"state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":null}}
"""
let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8))
let titel = eintrag.neutral()
#expect(titel.kind == .episode)
#expect(titel.title == "Lügen")
#expect(titel.seriesTitle == "Dark")
#expect(titel.parentIndex == 1)
#expect(titel.index == 2)
#expect(titel.resumeMilliseconds == 400_000)
}
@Test("Mediatheken: Shows werden Serien, alles andere Filme")
func mediatheken() async throws {
let transport = Attrappe(rumpf: """
[{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"},
{"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}]
""")
let liste = try await backend(transport).libraries()
#expect(liste.map(\.kind) == [.movie, .show])
#expect(liste.map(\.title) == ["Filme", "Serien"])
}
/// Nach der letzten Folge kommt keine — 204, kein Fehler.
@Test("Hinter der letzten Folge steht nichts")
func keineNaechsteFolge() async throws {
let transport = Attrappe(rumpf: "", status: 204)
let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte")
let naechste = try await backend(transport).nextEpisode(after: folge)
#expect(naechste == nil)
}
@Test("Eine Fortschrittsmeldung geht in Sekunden hinaus")
func fortschrittMelden() async {
let transport = Attrappe(rumpf: "{}")
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .playing,
time: .seconds(300), duration: .seconds(9360)))
let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress")
#expect(transport.letzte?.httpMethod == "POST")
#expect(rumpf?["positionSeconds"] as? Double == 300)
#expect(rumpf?["runtimeSeconds"] as? Double == 9360)
}
/// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.**
/// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben.
@Test("Weglegen ist nicht dasselbe wie gesehen")
func weglegen() async throws {
let transport = Attrappe(rumpf: "{}")
try await backend(transport).removeFromContinueWatching(
MediaItem(id: "film-1", title: "Der Untergang"))
#expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss")
#expect(transport.letzte?.httpMethod == "POST")
}
@Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert")
func gesehenMelden() async throws {
let transport = Attrappe(rumpf: "{}")
let b = backend(transport)
try await b.markWatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true)
try await b.markUnwatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false)
#expect(transport.letzte?.httpMethod == "PUT")
}
// --- Abspielen -----------------------------------------------------
/// **Direkt spielbar: sofort los.**
@Test("Eine MP4 geht ohne Umweg hinaus")
func direkteWiedergabe() async throws {
let transport = Attrappe(rumpf: """
{"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"none","progress":0.0,"error":null}
""")
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
// Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre.
#expect(ziel.sessionID == nil)
}
/// **Der Fehler, den erst der Simulator zeigte.**
///
/// 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:
/// „Stelle wird wiederhergestellt", 0:00, für immer.
@Test("Ein Umpacken wird abgewartet, nicht überholt")
func wartetAufsUmpacken() async throws {
let transport = Fortschrittsattrappe()
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
#expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut")
}
/// Eine Absage des Servers ist eine Absage, kein Weiterwarten.
@Test("Ein gescheitertes Umpacken kommt als Begründung an")
func gescheitertesUmpacken() async {
let transport = Attrappe(rumpf: """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."}
""")
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) {
_ = try await b.preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
}
}
/// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler.
@Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt")
func keineSchlagwoerter() async throws {
let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre)
#expect(optionen.isEmpty)
}
}
/// Antwortet beim ersten Mal „läuft noch", danach „fertig".
///
/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben
/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen.
final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable {
private let sperre = NSLock()
private var runde = 0
var abfragen: Int { sperre.withLock { runde } }
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let pfad = request.url?.path ?? ""
let fertig: Bool = sperre.withLock {
if pfad.hasSuffix("/delivery") { runde += 1 }
return runde > 1
}
let rumpf = """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null}
"""
return (Data(rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
}