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