Files
Flaro/Packages/FlaroKit/Tests/FlaroPlaybackTests/LocalStreamServerTests.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

368 lines
14 KiB
Swift

import Foundation
import Network
import Testing
@testable import FlaroPlayback
/// 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..<ende]), relativeTo: basis)
}
/// Zieht die erste Segment-Zeile aus einer Medienliste.
private func ersteSegmentAdresse(in liste: String, basis: URL) -> 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[..<ende.lowerBound], as: UTF8.self)
self.antworte(auf: kopf, verbindung: verbindung)
}
}
private func antworte(auf kopf: String, verbindung: NWConnection) {
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
let antwort = lock.withLock { () -> 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
}
}
}