Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
14 KiB
Swift
368 lines
14 KiB
Swift
import Foundation
|
|
import Network
|
|
import Testing
|
|
@testable import HaloPlayback
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|