Sechs Reparaturversuche haben nichts bewirkt, weil jeder an einer Stelle
ansetzte, die gar nicht die Ursache war. Eine systematische Untersuchung —
45 Hypothesen, jede von drei Skeptikern gegengeprüft — hat fünf voneinander
unabhängige Defekte auf demselben Weg gefunden. Jeder einzelne genügt.
**1. Es wurde eine Playlist bestellt, die keine Untertitelspur tragen kann.**
Jellyfins TranscodingUrl lautet `main.m3u8` — eine Media Playlist, also 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 Playlist
stehen. Der Wechsel main→master hing aber an der Leiter, und die wird beim
Umpacken und bei jeder von Hand gewählten Stufe nicht bestellt. Ein Matroska
geht immer über das Umpacken — die halbe Sammlung konnte strukturell keine
Untertitel haben.
Der Kommentar daneben war überholt und hat den Fehler verdeckt: er stammte
aus der Zeit, als Umpacken eine fortlaufende MP4 war. Seit es über HLS läuft,
ist die Master-Liste genau richtig. Pfadwechsel und Leiter sind jetzt zwei
Fragen, nicht eine.
**2. Der lokale Server etikettierte jeden Schnipsel als video/mp4.** Auch die
WebVTT-Schnipsel der Untertitelspur. So angekündigt gehen sie bei
AVFoundation an den falschen Parser, finden keine Box, und es entsteht
lautlos keine Einblendung — die Spur steht im Blatt, ist ausgewählt, bleibt
leer. Der Vorrat behält jetzt die Sorte der Gegenstelle; die Endung ist nur
noch der Notnagel.
**3. Nach dem ersten Untertitel wählte niemand mehr etwas aus.**
`selectedSubtitleTrack` wurde beim Neuaufbau nie zurückgesetzt, und beide
Wähler prüfen genau darauf. Eine frische Engine hat aber nichts ausgewählt —
also blieb sie für den Rest des Films stumm, während das Symbol „ein" zeigte.
Auch das Nachfassen im Sekundentakt war damit stillgelegt. Die Bestellung
(`selectedSubtitleStream`) bleibt, die Merker fallen.
**4. Die Schutzmarke war nach 250 ms tot.** `restart()` setzte sie als
Allererstes — und wartete danach auf einen echten Netzaufruf mit fünf
Sekunden Zeitgrenze, bevor es den alten Ereignisstrom abbestellte. In diesem
Fenster meldete der alte Player weiter seine Stelle, und die Prüfung „sind
wir angekommen" traf sofort zu. Die Marke war gelöscht, bevor der neue Stream
bestellt war; danach setzte jede Meldung des anlaufenden Streams die Stelle
auf null, und der nächste Neuaufbau merkte sich den Filmanfang. Das war der
Sprung — und der Grund, warum mein Fix von 88ba1a5 wirkungslos blieb.
**5. Der Sprung wurde einmal versucht und nie geprüft.** Der Rückgabewert von
`seek` wurde verworfen, die gemerkte Stelle verbraucht, bevor gesprungen
wurde, und framegenaue Toleranz verlangt — in einen Stream, den der Server
gerade erst erzeugt. Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt
ist, die Toleranz liegt bei einer halben Sekunde, und jedes Wachsen des
springbaren Bereichs ist ein neuer Anlass.
Belegt statt vermutet: ein neuer Test startet den lokalen Server gegen einen
eigenen Herkunftsserver und liest die Antworten Byte für Byte — ohne Gerät,
ohne Simulator, in 0,03 Sekunden. Er hat Fehler 2 vor der Reparatur
reproduziert und danach bestätigt. Fünf weitere Tests halten Fehler 1 fest.
Zwei alte Tests hielten das fehlerhafte Verhalten fest und waren damit
mitschuldig; beide sind mitgeändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.9 KiB
Swift
250 lines
9.9 KiB
Swift
import Foundation
|
|
import Network
|
|
import OSLog
|
|
|
|
/// Ein winziger HTTP-Server, von dem AVPlayer seinen Film holt.
|
|
///
|
|
/// **Warum überhaupt ein Server.** Apple lässt uns bei HLS nicht zwischen
|
|
/// Player und Netz: auf eine Anfrage nach einem Schnipsel nimmt AVPlayer von
|
|
/// uns nur eine Weiterleitung an, keine Daten. Der einzige Weg, ihm etwas
|
|
/// Eigenes zu liefern, ist, die Gegenstelle **zu sein**. Dann ist es keine
|
|
/// Einmischung mehr, sondern eine gewöhnliche Anfrage an eine gewöhnliche
|
|
/// Adresse — die zufällig auf demselben Gerät liegt.
|
|
///
|
|
/// **Nur auf diesem Gerät.** Der Lauscher hängt ausdrücklich an `127.0.0.1`,
|
|
/// nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt,
|
|
/// wäre das Letzte, was eine Mediathek-App tun sollte — und in einem offenen
|
|
/// WLAN eine Einladung.
|
|
///
|
|
/// Der Port kommt vom System. Ein fester wäre beim zweiten Start belegt, und
|
|
/// zwei Wiedergaben gleichzeitig — Kachel und Vollbild beim Umschalten — gäbe
|
|
/// es dann nie zweimal.
|
|
public actor LocalStreamServer {
|
|
/// Der Pfad, unter dem Schnipsel ausgeliefert werden.
|
|
private static let segmentPrefix = "/s/"
|
|
/// Der Pfad der Abspielliste selbst.
|
|
private static let playlistPath = "/playlist.m3u8"
|
|
/// Der Pfad einer Stufe, die eine Ebene tiefer liegt.
|
|
///
|
|
/// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine
|
|
/// Abspielliste oder Bild erwartet.
|
|
private static let levelPrefix = "/level.m3u8"
|
|
|
|
/// Wie eine Adresse aus der Abspielliste beim Server heisst.
|
|
///
|
|
/// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche
|
|
/// und Punkte darin nichts anrichten.
|
|
/// Wie eine Stufe aus der Stufenliste beim Server heisst.
|
|
///
|
|
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
|
|
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
|
|
/// Leere.
|
|
/// - Parameter prefetch: ob dahinter Bild liegt. Bei einer Nebenspur —
|
|
/// Untertitel oder zusätzlicher Ton — steht es auf `false`, sonst lüde
|
|
/// der Vorrat Untertitel statt Bild, und der Film bliebe stehen,
|
|
/// während die Anzeige reichlich Vorrat meldet.
|
|
public nonisolated static func localPlaylistPath(
|
|
for absolute: String,
|
|
prefetch: Bool
|
|
) -> String {
|
|
var teile = URLComponents()
|
|
teile.path = levelPrefix
|
|
teile.queryItems = [
|
|
URLQueryItem(name: "u", value: absolute),
|
|
URLQueryItem(name: "v", value: prefetch ? "1" : "0"),
|
|
]
|
|
return teile.string ?? levelPrefix
|
|
}
|
|
|
|
/// Wie ein Schnipsel beim Server heisst.
|
|
///
|
|
/// Die **vollständige** Adresse wandert in die Abfrage. Eine relative
|
|
/// gälte je nach Liste gegen eine andere Grundadresse — die
|
|
/// Untertitelspur liegt woanders als das Bild —, und wer das erst beim
|
|
/// Abholen auflöst, löst es irgendwann gegen die falsche auf.
|
|
public nonisolated static func localPath(for absolute: String) -> String {
|
|
var teile = URLComponents()
|
|
teile.path = segmentPrefix
|
|
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
|
|
return teile.string ?? segmentPrefix
|
|
}
|
|
|
|
private let cache: StreamCache
|
|
private let logger = Logger(subsystem: "app.flixy", category: "LocalStreamServer")
|
|
private var listener: NWListener?
|
|
private var port: UInt16?
|
|
private var playlist: String?
|
|
|
|
public init(cache: StreamCache) {
|
|
self.cache = cache
|
|
}
|
|
|
|
// MARK: - Starten
|
|
|
|
/// Startet den Server und gibt die Adresse zurück, die der Player bekommt.
|
|
///
|
|
/// Wirft, wenn der Lauscher nicht hochkommt — dann muss der Aufrufer die
|
|
/// ursprüngliche Adresse verwenden. **Kein Ersatz für Wiedergabe:** lieber
|
|
/// ohne Vorrat spielen als gar nicht.
|
|
public func start(upstream: URL, headers: [String: String] = [:]) async throws -> URL {
|
|
playlist = try await cache.start(upstream: upstream, headers: headers)
|
|
|
|
let parameters = NWParameters.tcp
|
|
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
|
let lauscher = try NWListener(using: parameters)
|
|
|
|
lauscher.newConnectionHandler = { [weak self] verbindung in
|
|
Task { await self?.accept(verbindung) }
|
|
}
|
|
|
|
// Der Zustandsmelder kommt mehrfach und aus fremdem Zusammenhang.
|
|
// Eine Fortsetzung zweimal aufzulösen bringt das Programm zum
|
|
// Anhalten — deshalb ein Riegel, der das ausschliesst.
|
|
let vergeben: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
|
|
let riegel = OneShot()
|
|
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
|
|
port = vergeben
|
|
guard vergeben > 0,
|
|
let adresse = URL(string: "http://127.0.0.1:\(vergeben)\(Self.playlistPath)")
|
|
else { throw URLError(.cannotConnectToHost) }
|
|
return adresse
|
|
}
|
|
|
|
public func stop() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
Task { await cache.teardown() }
|
|
}
|
|
|
|
// MARK: - Bedienen
|
|
|
|
private func accept(_ verbindung: NWConnection) {
|
|
verbindung.start(queue: .global(qos: .userInitiated))
|
|
receive(on: verbindung, buffer: Data())
|
|
}
|
|
|
|
private nonisolated 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) }
|
|
|
|
// Der Kopf endet mit einer Leerzeile. Bis dahin sammeln — eine
|
|
// Anfrage kann auf mehrere Pakete verteilt ankommen, und wer das
|
|
// nicht abwartet, liest eine halbe Zeile als ganze Anfrage.
|
|
guard let kopfEnde = 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[..<kopfEnde.lowerBound], as: UTF8.self)
|
|
Task { await self.respond(to: kopf, on: verbindung) }
|
|
}
|
|
}
|
|
|
|
private func respond(to kopf: String, on verbindung: NWConnection) async {
|
|
guard let anfrage = kopf.components(separatedBy: "\r\n").first,
|
|
let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first
|
|
else { return finish(verbindung, status: 400, body: Data()) }
|
|
|
|
if pfadTeil.hasPrefix(Self.levelPrefix) {
|
|
guard let teile = URLComponents(string: pfadTeil),
|
|
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
|
|
let inhalt = await cache.playlist(
|
|
at: adresse,
|
|
prefetch: teile.queryItems?.first { $0.name == "v" }?.value != "0"
|
|
)
|
|
else { return finish(verbindung, status: 404, body: Data()) }
|
|
|
|
return finish(
|
|
verbindung, status: 200, body: Data(inhalt.utf8),
|
|
type: "application/vnd.apple.mpegurl"
|
|
)
|
|
}
|
|
|
|
if pfadTeil.hasPrefix(Self.playlistPath) {
|
|
let inhalt = Data((playlist ?? "").utf8)
|
|
return finish(
|
|
verbindung, status: 200, body: inhalt,
|
|
type: "application/vnd.apple.mpegurl"
|
|
)
|
|
}
|
|
|
|
guard pfadTeil.hasPrefix(Self.segmentPrefix),
|
|
let teile = URLComponents(string: pfadTeil),
|
|
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
|
|
else { return finish(verbindung, status: 404, body: Data()) }
|
|
|
|
guard let schnipsel = await cache.data(for: adresse) else {
|
|
return finish(verbindung, status: 504, body: Data())
|
|
}
|
|
// **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(
|
|
_ verbindung: NWConnection,
|
|
status: Int,
|
|
body: Data,
|
|
type: String = "application/octet-stream"
|
|
) {
|
|
let kopf = """
|
|
HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")\r
|
|
Content-Type: \(type)\r
|
|
Content-Length: \(body.count)\r
|
|
Accept-Ranges: none\r
|
|
Connection: close\r
|
|
\r
|
|
|
|
"""
|
|
var antwort = Data(kopf.utf8)
|
|
antwort.append(body)
|
|
verbindung.send(content: antwort, completion: .contentProcessed { _ in
|
|
verbindung.cancel()
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
/// Ein Riegel, der genau einmal aufgeht.
|
|
///
|
|
/// Für Rückrufe, die mehrfach kommen, aber nur einmal beantwortet werden
|
|
/// dürfen. Eine `var` im Abschluss täte es nicht: sie wird aus fremdem
|
|
/// Zusammenhang berührt, und Swift lässt das zu Recht nicht durchgehen.
|
|
private final class OneShot: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var used = false
|
|
|
|
func claim() -> Bool {
|
|
lock.withLock {
|
|
guard !used else { return false }
|
|
used = true
|
|
return true
|
|
}
|
|
}
|
|
}
|