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)!) } }