Der Vorrat laeuft jetzt voraus, nicht der Player

AVPlayer entscheidet selbst, wie weit er puffert, und bei einer Abspielliste
laesst Apple uns nicht dazwischen: auf eine Anfrage nach einem Schnipsel
nimmt er von uns nur eine Weiterleitung an, keine Daten. Der Denkfehler war,
seinen Puffer vergroessern zu wollen.

Wir muessen aber gar nicht dazwischen — wir koennen die Gegenstelle sein.
Ein winziger HTTP-Server auf 127.0.0.1 liefert dem Player die Abspielliste,
umgeschrieben auf sich selbst, und holt die Schnipsel zuegiger von Jellyfin,
als abgespielt wird. Jedes Segment, das der Player verlangt, liegt dann
schon lokal. Faellt die Verbindung im Tunnel aus, merkt er es nicht.

Das ist der Unterschied zwischen Puffer und Vorrat: den Puffer bestimmt
Apple, den Vorrat bestimmen wir.

Streng der Reihe nach, und mit begrenztem Vorsprung. Der Server erzeugt die
Schnipsel gerade erst; wer Nummer 40 verlangt, waehrend er bei 12 ist, sieht
fuer Jellyfin aus wie jemand, der gesprungen ist — und dann beginnt das
Umrechnen von vorn, an der falschen Stelle.

Der Lauscher haengt ausdruecklich an 127.0.0.1, nicht an allen
Schnittstellen. Ein Server, der den Film ins Netz stellt, waere das Letzte,
was eine Mediathek-App tun sollte.

Kommt er nicht hoch, spielt es trotzdem — ueber die urspruengliche Adresse,
ohne Vorrat. Ein fehlender Vorrat ist ein Verlust an Bequemlichkeit; keine
Wiedergabe ist ein Verlust an allem.

Das Lesen und Umschreiben der Abspielliste ist ein eigener Typ ohne Netz,
mit sieben Tests. Eine falsch umgeschriebene Liste ergibt entweder ein
schwarzes Bild oder — schlimmer — eine, die stellenweise am Speicher
vorbeigeht und nur manchmal hakt. So etwas findet man im laufenden Film
nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 20:23:14 +02:00
co-authored by Claude Opus 5
parent 46550fc30b
commit 134dc82927
5 changed files with 669 additions and 7 deletions
@@ -0,0 +1,107 @@
import Foundation
/// Eine HLS-Abspielliste — gelesen und umgeschrieben.
///
/// **Wozu.** Damit ein Vorratsspeicher zwischen Player und Server passen
/// kann, muss er zweierlei können: wissen, welche Schnipsel es gibt und in
/// welcher Reihenfolge, und dem Player eine Liste geben, die auf ihn selbst
/// zeigt statt auf den Server. Beides ist reine Textarbeit an einer Datei mit
/// einem sehr einfachen Aufbau.
///
/// **Warum als eigener Typ ohne Netz.** Weil hier ein Fehler besonders teuer
/// wäre: eine falsch umgeschriebene Liste ergibt entweder ein schwarzes Bild
/// oder — schlimmer — eine, die stellenweise am Speicher vorbeigeht und nur
/// manchmal hakt. So etwas ist im laufenden Film nicht zu finden. Mit einer
/// Zeichenkette hinein und einer heraus schon.
///
/// Gelesen wird nur, was gebraucht wird: die Zeilen, die auf Dateien zeigen.
/// Alles andere — Verschlüsselung, Zeitmarken, Untertitelspuren — bleibt
/// unangetastet stehen. Eine Abspielliste, die wir nicht verstehen, soll
/// hindurchgehen, nicht verstümmelt werden.
public struct MediaPlaylist: Sendable, Equatable {
/// Die Zeilen, die auf eine Datei zeigen — in der Reihenfolge der Liste.
public let segments: [String]
/// Ob es eine Liste von Stufen ist statt einer von Schnipseln.
///
/// Die beiden sehen sich ähnlich und meinen Verschiedenes: eine
/// Master-Liste zählt Qualitätsstufen auf, eine Medienliste die Schnipsel
/// einer Stufe. Wer das verwechselt, lädt Abspiellisten vor statt Bild.
public let isMaster: Bool
private let lines: [String]
public init(_ text: String) {
let zeilen = text.components(separatedBy: .newlines)
self.lines = zeilen
var gefunden: [String] = []
var master = false
var nächsteIstStufe = false
for zeile in zeilen {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
master = true
nächsteIstStufe = true
continue
}
guard !getrimmt.isEmpty else { continue }
// Kommentare und Anweisungen beginnen mit `#`. Alles andere ist
// eine Adresse.
guard !getrimmt.hasPrefix("#") else { continue }
if nächsteIstStufe {
nächsteIstStufe = false
// Bei einer Master-Liste zeigen die Zeilen auf weitere
// Listen, nicht auf Bild. Sie zählen trotzdem als Adresse.
}
gefunden.append(getrimmt)
}
self.segments = gefunden
self.isMaster = master
}
/// Schreibt jede Adresse um, ohne sonst etwas anzufassen.
///
/// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen
/// bleiben. Nur wo eine Adresse stand, steht danach eine andere.
public func rewritingReferences(_ transform: (String) -> String) -> String {
var ergebnis: [String] = []
ergebnis.reserveCapacity(lines.count)
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.isEmpty || getrimmt.hasPrefix("#") {
ergebnis.append(zeile)
} else {
ergebnis.append(transform(getrimmt))
}
}
return ergebnis.joined(separator: "\n")
}
/// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu.
///
/// Bei einem laufenden Sender fehlt diese Marke, und die Liste muss
/// wiederholt geholt werden. Bei einem Film steht sie am Ende, und dann
/// weiss der Vorratsspeicher, dass er die Liste kennt.
public var isComplete: Bool {
lines.contains { $0.trimmingCharacters(in: .whitespaces) == "#EXT-X-ENDLIST" }
}
/// Wie lang die Schnipsel dauern — für die Frage, wie viele einem
/// Vorsprung von N Sekunden entsprechen.
public var targetDuration: Duration? {
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard getrimmt.hasPrefix("#EXT-X-TARGETDURATION:") else { continue }
let wert = getrimmt.dropFirst("#EXT-X-TARGETDURATION:".count)
guard let sekunden = Double(wert.trimmingCharacters(in: .whitespaces)),
sekunden > 0
else { return nil }
return .seconds(sekunden)
}
return nil
}
}