diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 633a6f0..27607dc 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -215,6 +215,8 @@ final class PlayerModel { /// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben /// Aufbau scheiterte am selben Grund. private var prefetchGaveUp = false + /// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt. + @ObservationIgnored private var resumeGiveUp: Task? /// Der Server, von dem der Player holt — und der Vorrat dahinter. private var streamServer: LocalStreamServer? private var streamCache: StreamCache? @@ -553,7 +555,6 @@ final class PlayerModel { defer { isChangingQuality = false } let resumeAt = currentTime - resumeTarget = resumeAt // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Streams gleichzeitig für einen Zuschauer. @@ -564,6 +565,35 @@ final class PlayerModel { eventTask?.cancel() engine?.teardown() engine = nil + + // **Die Marke erst jetzt — nicht vorher.** + // + // Hier lag der Sprung an den Filmanfang. Die Zeile stand ganz oben, + // vor dem Abbestellen; dazwischen liegt aber ein echter Netzaufruf + // mit fünf Sekunden Zeitgrenze. In diesem Fenster meldet der **alte** + // Player viermal je Sekunde weiter seine Stelle — und die ist die + // gemerkte. Die Prüfung „sind wir angekommen" traf damit sofort zu + // und löschte die Marke, bevor der neue Stream überhaupt bestellt + // war. Danach setzte jede Meldung des anlaufenden Streams — null, + // eine halbe, eine Sekunde — ungebremst die Stelle, und der nächste + // Neuaufbau merkte sich den Filmanfang. + // + // Nach `eventTask?.cancel()` kommt keine Meldung mehr durch, und die + // Marke gilt für den neuen Stream, für den sie gedacht ist. + resumeTarget = resumeAt + + // **Die Spuren des alten Streams gelten nicht mehr.** + // + // Eine frische Engine hat nichts ausgewählt. Blieben die Merker + // stehen, hielten sich beide Wähler für erledigt — und auf dem neuen + // Stream würde nie etwas eingeschaltet. Genau deshalb erschien nach + // dem ersten Untertitel nie wieder einer. + // + // `selectedSubtitleStream` bleibt: das ist die **Bestellung**, und + // die soll den Neuaufbau gerade überleben. + selectedSubtitleTrack = nil + selectedAudioTrack = nil + phase = .preparing // Eine erzwungene Entscheidung steht über der Rechnung: sie kommt @@ -598,8 +628,13 @@ final class PlayerModel { // Notbremse: springt der Player nie an die Stelle, bliebe sonst für // immer eine Abdeckung über dem Bild. - Task { [weak self] in + // + // Festgehalten und beim nächsten Neuaufbau abbestellt — sonst räumte + // der Zeitgeber eines alten Versuchs die Marke eines neuen ab. + resumeGiveUp?.cancel() + resumeGiveUp = Task { [weak self] in try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { return } self?.resumeTarget = nil } } @@ -1386,6 +1421,10 @@ final class PlayerModel { engine?.teardown() engine = nil + // Was auf der alten Engine ausgewählt war, gilt für keine neue. + selectedSubtitleTrack = nil + selectedAudioTrack = nil + resumeGiveUp?.cancel() // Der Vorrat gehört zu diesem Stream. Stehen zu bleiben hiesse, // Schnipsel für einen Film zu holen, den niemand mehr sieht — und den // Platz dafür zu behalten. diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index fad508b..352b9a0 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -49,6 +49,8 @@ public final class AVPlayerEngine: VideoPlayerEngine { /// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das /// Fortsetzen landete dann wieder am Anfang. private var pendingStartTime: Duration = .zero + /// Ob gerade ein Sprung an die gemerkte Stelle läuft. + private var isSeekingToStart = false private var observations: [NSKeyValueObservation] = [] private var endObserver: (any NSObjectProtocol)? private var stallObserver: (any NSObjectProtocol)? @@ -111,6 +113,11 @@ public final class AVPlayerEngine: VideoPlayerEngine { // Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel // fängt bei null an, sonst schleppte die Zahl den vorigen Film mit. variantBitrates = [] + // Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste + // zwischen zwei Aufbauten die Spuren des alten. + audioTracks = [] + subtitleTracks = [] + isSeekingToStart = false wantsToPlay = false stallCount = 0 accumulatedStall = .zero @@ -452,6 +459,20 @@ public final class AVPlayerEngine: VideoPlayerEngine { } ) + // **Ein zweiter Anlass für den Sprung.** + // + // Bei einer Abspielliste wächst der springbare Bereich, während der + // Server produziert. Ein Sprung, der bei „bereit" noch ins Leere + // ging, kann Sekunden später gelingen — und ohne diesen Beobachter + // gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt + // gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter + // nichts mehr. + observations.append( + item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in + Task { @MainActor in self?.applyPendingStartTime() } + } + ) + // Der ausdrückliche Stocker-Melder des Systems. // // Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen @@ -481,11 +502,45 @@ public final class AVPlayerEngine: VideoPlayerEngine { } /// Springt an die gemerkte Stelle, sobald der Player bereit ist. + /// + /// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle + /// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von + /// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst + /// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —, + /// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an + /// genau diesem einen Sprung, weil der Server bewusst bei null beginnt. + /// + /// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und + /// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu + /// versuchen. private func applyPendingStartTime() { - guard pendingStartTime > .zero else { return } + guard pendingStartTime > .zero, !isSeekingToStart else { return } let target = pendingStartTime - pendingStartTime = .zero - Task { await seek(to: target) } + isSeekingToStart = true + + Task { [weak self] in + let geschafft = await self?.seekReportingSuccess(to: target) ?? false + guard let self else { return } + isSeekingToStart = false + if geschafft { pendingStartTime = .zero } + } + } + + /// Springt und sagt, ob es getragen hat. + /// + /// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu + /// springen, den der Server gerade erst erzeugt, ist der teuerste und + /// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein + /// misslungener Sprung dagegen kostet den ganzen Fortschritt. + private func seekReportingSuccess(to time: Duration) async -> Bool { + let target = CMTime(seconds: time.seconds, preferredTimescale: 600) + let geschafft = await player.seek( + to: target, + toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600), + toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600) + ) + if geschafft { continuation?.yield(.timeChanged(time)) } + return geschafft } private func teardownObservers() { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift index 7e5f0db..5c13463 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift @@ -195,10 +195,15 @@ public actor LocalStreamServer { let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value else { return finish(verbindung, status: 404, body: Data()) } - guard let daten = await cache.data(for: adresse) else { + guard let schnipsel = await cache.data(for: adresse) else { return finish(verbindung, status: 504, body: Data()) } - finish(verbindung, status: 200, body: daten, type: "video/mp4") + // **Die Sorte, die wirklich ankam.** Hier stand fest „video/mp4", für + // jeden Schnipsel. Ein WebVTT-Körper, so angekündigt, geht bei + // AVFoundation an den falschen Parser und ergibt lautlos keine + // Einblendung — die Spur steht im Blatt, ist ausgewählt, und bleibt + // leer. Genau daran lag es. + finish(verbindung, status: 200, body: schnipsel.data, type: schnipsel.contentType) } private nonisolated func finish( diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift index 8245b4a..de1f602 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift @@ -22,6 +22,12 @@ import OSLog /// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in /// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet. public actor StreamCache { + /// Ein Schnipsel — Daten **und** wofür sie sich ausgeben. + public struct Segment: Sendable { + public let data: Data + public let contentType: String + } + /// Wie weit vorausgelaufen wird. public struct Policy: Sendable { /// Wie viel Film im Voraus geholt werden soll. @@ -47,8 +53,14 @@ public actor StreamCache { private var segments: [String] = [] private var targetDuration: Duration = .seconds(6) - /// Was schon auf der Platte liegt — Pfad auf Datei. - private var stored: [String: URL] = [:] + /// Was schon auf der Platte liegt — Adresse auf Datei **und Sorte**. + /// + /// **Die Sorte gehört dazu.** Ein Schnipsel ist nicht zwangsläufig Bild: + /// die Untertitelspur liefert WebVTT, der Ton kann AAC sein. Wer die + /// Sorte wegwirft und später etwas behauptet, liefert dem Player einen + /// Text als Video — der gibt ihn dem falschen Parser, findet keine Box, + /// und es entsteht lautlos gar nichts. + private var stored: [String: (ort: URL, type: String)] = [:] private var storedBytes = 0 /// Welchen Schnipsel der Player zuletzt verlangt hat. private var lastRequested = 0 @@ -147,13 +159,15 @@ public actor StreamCache { // MARK: - Ausliefern /// Die Daten zu einer Adresse — aus dem Vorrat, sonst frisch geholt. - public func data(for address: String) async -> Data? { + public func data(for address: String) async -> Segment? { if let index = segments.firstIndex(of: address) { lastRequested = max(lastRequested, index) trimBehind() } - if let ort = stored[address], let daten = try? Data(contentsOf: ort) { return daten } + if let eintrag = stored[address], let daten = try? Data(contentsOf: eintrag.ort) { + return Segment(data: daten, contentType: eintrag.type) + } // **Der Player hat Vorrang.** Fehlt ihm etwas, wartet er darauf — und // der Vorrat darf ihm dabei nicht die Leitung wegnehmen. Rechnet der @@ -218,23 +232,43 @@ public actor StreamCache { } @discardableResult - private func fetch(_ address: String) async -> Data? { + private func fetch(_ address: String) async -> Segment? { guard let ziel = URL(string: address) else { return nil } do { - let (daten, antwort) = try await session.data(from: ziel) - guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil } - store(daten, at: address) - return daten + var anfrage = URLRequest(url: ziel) + for (name, wert) in headers { anfrage.setValue(wert, forHTTPHeaderField: name) } + let (daten, antwort) = try await session.data(for: anfrage) + let http = antwort as? HTTPURLResponse + guard http?.statusCode ?? 200 < 400 else { return nil } + + // **Die Sorte kommt von der Gegenstelle, nicht von uns.** Sie + // weiss, was sie geschickt hat; wir wüssten es nur aus der + // Endung, und die kann fehlen oder lügen. Erst wenn sie nichts + // sagt, raten wir anhand der Adresse. + let sorte = http?.value(forHTTPHeaderField: "Content-Type") + ?? Self.guessedType(for: address) + store(daten, type: sorte, at: address) + return Segment(data: daten, contentType: sorte) } catch { return nil } } - private func store(_ daten: Data, at path: String) { + /// Die Sorte aus der Endung — der Notnagel. + static func guessedType(for address: String) -> String { + let pfad = URL(string: address)?.path.lowercased() ?? address.lowercased() + if pfad.hasSuffix(".vtt") { return "text/vtt" } + if pfad.hasSuffix(".ts") { return "video/MP2T" } + if pfad.hasSuffix(".aac") { return "audio/aac" } + if pfad.hasSuffix(".m3u8") { return "application/vnd.apple.mpegurl" } + return "video/mp4" + } + + private func store(_ daten: Data, type: String, at path: String) { let ort = directory.appending(path: UUID().uuidString) guard (try? daten.write(to: ort, options: .atomic)) != nil else { return } - stored[path] = ort + stored[path] = (ort, type) storedBytes += daten.count } @@ -249,11 +283,11 @@ public actor StreamCache { for index in 0.. URL { - var url = url - if url.lastPathComponent == "main.m3u8" { - url = url.deletingLastPathComponent().appending(path: "master.m3u8") - } - - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url - } + guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false) + else { return masterPlaylist(url) } var items = (components.queryItems ?? []).filter { $0.name.lowercased() != "enableadaptivebitratestreaming" } @@ -295,6 +305,29 @@ extension JellyfinBackend { components.queryItems = items return components.url ?? url } + + /// Von der Liste **einer** Stufe zur Liste **aller** Spuren. + /// + /// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine + /// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8` + /// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein + /// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine + /// **Untertitelspur** ankündigt. + /// + /// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel + /// geben, ganz gleich was bestellt wurde und was der Server liefern + /// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand + /// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse + /// `main.m3u8`, weil der Wechsel an der Leiter hing. + /// + /// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die + /// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage + /// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine + /// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung. + static func masterPlaylist(_ url: URL) -> URL { + guard url.lastPathComponent == "main.m3u8" else { return url } + return url.deletingLastPathComponent().appending(path: "master.m3u8") + } } /// Was der Server für eine Wiedergabe vorbereitet hat. diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/LocalStreamServerTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/LocalStreamServerTests.swift new file mode 100644 index 0000000..539413f --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/LocalStreamServerTests.swift @@ -0,0 +1,367 @@ +import Foundation +import Network +import Testing +@testable import FlixyPlayback + +/// Was der lokale Server dem Player wirklich schickt. +/// +/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und +/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server +/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an +/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die +/// Antwort **liest**, ist das nicht zu finden. +/// +/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher +/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein +/// Gerät, kein Simulator, kein echtes Jellyfin. +@Suite("Der lokale Server", .serialized) +struct LocalStreamServerTests { + + // MARK: - Vorlagen + + private static let master = """ + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2" + #EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs" + main.m3u8?api_key=abc + """ + + private static let bildListe = """ + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-TARGETDURATION:6 + #EXT-X-PLAYLIST-TYPE:VOD + #EXTINF:6.000000, + hls1/main/0.ts?api_key=abc + #EXTINF:6.000000, + hls1/main/1.ts?api_key=abc + #EXT-X-ENDLIST + """ + + private static let untertitelListe = """ + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-TARGETDURATION:6 + #EXT-X-PLAYLIST-TYPE:VOD + #EXTINF:6.000000, + 0.vtt?api_key=abc&SubtitleStreamIndex=2 + #EXTINF:6.000000, + 1.vtt?api_key=abc&SubtitleStreamIndex=2 + #EXT-X-ENDLIST + """ + + private static let vtt = """ + WEBVTT + X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 + + 00:00:01.000 --> 00:00:04.000 + Hallo Welt. + """ + + /// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde. + private static func jellyfin() -> FakeOrigin { + let server = FakeOrigin() + server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master) + server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe) + server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe) + server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0") + server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1") + server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt) + server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt) + return server + } + + // MARK: - Der Aufbau + + /// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die + /// der Player bekäme. + private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) { + let herkunft = Self.jellyfin() + let port = try await herkunft.start() + + let speicher = StreamCache() + let server = LocalStreamServer(cache: speicher) + let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")! + let lokal = try await server.start(upstream: oben) + return (herkunft, server, lokal) + } + + /// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück. + private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) { + let (daten, antwort) = try await URLSession.shared.data(from: url) + let http = antwort as? HTTPURLResponse + return ( + http?.statusCode ?? 0, + http?.value(forHTTPHeaderField: "Content-Type"), + String(decoding: daten, as: UTF8.self) + ) + } + + /// Zieht die erste Adresse aus einem `URI="…"` der Liste. + private func renditionAdresse(in liste: String, basis: URL) -> URL? { + guard let start = liste.range(of: "URI=\""), + let ende = liste[start.upperBound...].firstIndex(of: "\"") + else { return nil } + return URL(string: String(liste[start.upperBound.. URL? { + for zeile in liste.components(separatedBy: .newlines) { + let getrimmt = zeile.trimmingCharacters(in: .whitespaces) + guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue } + return URL(string: getrimmt, relativeTo: basis) + } + return nil + } + + // MARK: - Die Tests + + /// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`, + /// geht bei AVFoundation an den falschen Parser, findet keine Box, und es + /// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist + /// ausgewählt, und bleibt leer. + @Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück") + func subtitleSegmentKeepsItsType() async throws { + let (herkunft, server, lokal) = try await aufbauen() + defer { Task { await server.stop() }; herkunft.stop() } + + let masterAntwort = try await hole(lokal) + let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal)) + + let rendition = try await hole(renditionURL) + let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal)) + + let segment = try await hole(segmentURL) + #expect(segment.status == 200) + #expect(segment.body.hasPrefix("WEBVTT")) + #expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")") + } + + /// Gegentest, damit die Reparatur nicht zu breit wird. + @Test("Ein Bild-Schnipsel behält seinen eigenen Typ") + func videoSegmentKeepsItsType() async throws { + let (herkunft, server, lokal) = try await aufbauen() + defer { Task { await server.stop() }; herkunft.stop() } + + let masterAntwort = try await hole(lokal) + // Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche + // Adresse in der Liste. + let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal)) + let stufe = try await hole(stufeURL) + let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal)) + + let segment = try await hole(segmentURL) + #expect(segment.status == 200) + #expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")") + } + + /// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch. + /// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche + /// Datei — und niemand sähe es der Antwort an. + @Test("Die Adresse übersteht die Rundreise zeichengenau") + func addressSurvivesTheRoundTrip() async throws { + let (herkunft, server, lokal) = try await aufbauen() + defer { Task { await server.stop() }; herkunft.stop() } + + let masterAntwort = try await hole(lokal) + let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal)) + _ = try await hole(renditionURL) + + let gesehen = herkunft.requests + #expect( + gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8") + && $0.contains("api_key=abc") + && $0.contains("SubtitleStreamIndex=2") }, + "der Herkunftsserver sah: \(gesehen)" + ) + } + + /// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was + /// danach passiert. + @Test("Die Untertitelspur bleibt in der Master-Liste stehen") + func masterKeepsTheRendition() async throws { + let (herkunft, server, lokal) = try await aufbauen() + defer { Task { await server.stop() }; herkunft.stop() } + + let antwort = try await hole(lokal) + #expect(antwort.status == 200) + #expect(antwort.type == "application/vnd.apple.mpegurl") + #expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES")) + #expect(antwort.body.contains("GROUP-ID=\"subs\"")) + #expect(antwort.body.contains("LANGUAGE=\"ger\"")) + #expect(antwort.body.contains("DEFAULT=YES")) + #expect(antwort.body.contains("SUBTITLES=\"subs\"")) + } + + /// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen, + /// während die Anzeige reichlich Vorrat meldet. + @Test("Die Untertitelspur wird nicht vorgeladen") + func renditionIsNotPrefetched() async throws { + let herkunft = Self.jellyfin() + let port = try await herkunft.start() + + let speicher = StreamCache() + let server = LocalStreamServer(cache: speicher) + let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")! + let lokal = try await server.start(upstream: oben) + defer { Task { await server.stop() }; herkunft.stop() } + + let masterAntwort = try await hole(lokal) + let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal)) + _ = try await hole(renditionURL) + + #expect(await speicher.bufferedAhead == .zero) + } + + /// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden + /// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen + /// Stelle. + @Test("Ein Fehler von oben bleibt ein Fehler") + func upstreamFailureStaysAFailure() async throws { + let herkunft = Self.jellyfin() + herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope") + let port = try await herkunft.start() + + let speicher = StreamCache() + let server = LocalStreamServer(cache: speicher) + let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")! + let lokal = try await server.start(upstream: oben) + defer { Task { await server.stop() }; herkunft.stop() } + + let masterAntwort = try await hole(lokal) + let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal)) + let rendition = try await hole(renditionURL) + let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal)) + + let segment = try await hole(segmentURL) + #expect(segment.status == 504) + } +} + +// MARK: - Der Herkunftsserver + +/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde +/// — und aufschreibt, wonach gefragt wurde. +/// +/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was +/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke +/// hat die Fehlersuche so lange gekostet. +final class FakeOrigin: @unchecked Sendable { + private struct Antwort { + let status: Int + let type: String + let body: String + } + + private let lock = NSLock() + private var antworten: [String: Antwort] = [:] + private var gesehen: [String] = [] + private var listener: NWListener? + + func add(_ path: String, status: Int = 200, type: String, body: String) { + lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) } + } + + /// Alles, wonach gefragt wurde — mit vollständiger Abfrage. + var requests: [String] { + lock.withLock { gesehen } + } + + func start() async throws -> UInt16 { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) + let lauscher = try NWListener(using: parameters) + + lauscher.newConnectionHandler = { [weak self] verbindung in + verbindung.start(queue: .global(qos: .userInitiated)) + self?.receive(on: verbindung, buffer: Data()) + } + + let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in + let riegel = TestOneShot() + lauscher.stateUpdateHandler = { zustand in + switch zustand { + case .ready: + guard riegel.claim() else { return } + fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0) + case .failed(let fehler): + guard riegel.claim() else { return } + fortsetzen.resume(throwing: fehler) + default: + break + } + } + lauscher.start(queue: .global(qos: .userInitiated)) + } + + listener = lauscher + return port + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func receive(on verbindung: NWConnection, buffer: Data) { + verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) { + [weak self] daten, _, abgeschlossen, fehler in + guard let self else { return } + var gesammelt = buffer + if let daten { gesammelt.append(daten) } + + guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else { + if fehler == nil, !abgeschlossen { + self.receive(on: verbindung, buffer: gesammelt) + } else { + verbindung.cancel() + } + return + } + + let kopf = String(decoding: gesammelt[.. Antwort? in + gesehen.append(pfadMitAbfrage) + return antworten[pfad] + } + + let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da") + let koerper = Data(a.body.utf8) + let ausgabe = """ + HTTP/1.1 \(a.status) OK\r + Content-Type: \(a.type)\r + Content-Length: \(koerper.count)\r + Connection: close\r + \r + + """ + var alles = Data(ausgabe.utf8) + alles.append(koerper) + verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() }) + } +} + +/// Wie im Original: ein Riegel, der genau einmal aufgeht. +private final class TestOneShot: @unchecked Sendable { + private let lock = NSLock() + private var used = false + + func claim() -> Bool { + lock.withLock { + guard !used else { return false } + used = true + return true + } + } +} diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift index fae6342..ef6f9d8 100644 --- a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift @@ -65,7 +65,13 @@ struct PlaybackTests { transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720") )) - #expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8")) + // **`master.m3u8`, nicht `main.m3u8`.** Der Pfad des Servers bleibt, + // nur die Liste wechselt: `main` führt die Schnipsel einer Stufe, + // `master` die Spuren. Nur in der zweiten darf `#EXT-X-MEDIA` stehen + // — die Zeile, mit der eine Untertitelspur angekündigt wird. Dieser + // Test hielt vorher das Gegenteil fest und war damit mitschuldig, + // dass nie ein Untertitel erschien. + #expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/master.m3u8")) #expect(target.sessionID == "ps-7") // Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu @@ -344,3 +350,94 @@ struct SubtitleStreamIndexTests { #expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" }) } } + +@Suite("Die Master-Liste, auch ohne Leiter") +struct MasterPlaylistTests { + private func backend(_ transport: RecordingTransport) -> JellyfinBackend { + JellyfinBackend( + client: JellyfinClient( + baseURL: URL(string: "http://server:8096")!, + identity: JellyfinIdentity( + client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1" + ), + token: "tok", + transport: transport + ), + account: JellyfinAccount( + userID: "u1", userName: "Test", token: "tok", isAdministrator: false + ), + serverName: "Attrappe" + ) + } + + private func transport() -> RecordingTransport { + RecordingTransport(response: """ + {"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}], + "PlaySessionId": "ps-7"} + """) + } + + private func target(remux: Bool, adaptive: Bool) async throws -> URL { + try await backend(transport()).preparePlayback(PlaybackIntent( + itemID: "film-1", + versionIndex: 0, + file: MediaFile(id: "src-1", key: "src-1", container: "mkv"), + transcode: TranscodeSettings( + maximumBitrate: nil, + resolution: nil, + prefersAdaptive: adaptive, + prefersRemux: remux + ) + )).url + } + + /// **Der Fehler, wegen dem nie ein Untertitel erschien.** `main.m3u8` ist + /// eine Liste von Schnipseln; `#EXT-X-MEDIA` — die Zeile, mit der ein + /// Server eine Untertitelspur ankündigt — darf nach RFC 8216 nur in einer + /// Master-Liste stehen. Auf `main.m3u8` kann es strukturell keine + /// Untertitel geben. Und ein Matroska geht immer über das Umpacken. + @Test("Umgepackt kommt die Master-Liste") + func remuxGetsMaster() async throws { + let adresse = try await target(remux: true, adaptive: false) + #expect(adresse.lastPathComponent == "master.m3u8") + } + + /// Die Leiter ist eine **andere** Frage als die Master-Liste. Beim + /// Umpacken gibt es nur eine Fassung — sie zu bestellen wäre sinnlos. + @Test("Umgepackt aber keine Leiter") + func remuxHasNoLadder() async throws { + let adresse = try await target(remux: true, adaptive: true) + let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + #expect(adresse.lastPathComponent == "master.m3u8") + #expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" }) + } + + /// Auch eine von Hand gewählte Stufe braucht die Master-Liste — sonst + /// verliert man die Untertitel, sobald man die Qualität einstellt. + @Test("Eine feste Stufe bekommt sie auch") + func fixedRungGetsMaster() async throws { + let adresse = try await target(remux: false, adaptive: false) + let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + #expect(adresse.lastPathComponent == "master.m3u8") + #expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" }) + } + + @Test("Bei automatisch kommt zusätzlich die Leiter") + func automaticGetsLadder() async throws { + let adresse = try await target(remux: false, adaptive: true) + let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + #expect(adresse.lastPathComponent == "master.m3u8") + #expect(werte.contains(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))) + } + + /// Heisst die Adresse schon so, bleibt sie — und wird nicht doppelt + /// umgeschrieben. + @Test("Was schon Master heisst, bleibt es") + func masterStaysMaster() { + let schon = URL(string: "http://s/Videos/x/master.m3u8?api_key=t")! + #expect(JellyfinBackend.masterPlaylist(schon) == schon) + } +}