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,196 @@
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"
/// 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.
public nonisolated static func localPath(for original: String) -> String {
var teile = URLComponents()
teile.path = segmentPrefix
teile.queryItems = [URLQueryItem(name: "p", value: original)]
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.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 original = teile.queryItems?.first(where: { $0.name == "p" })?.value
else { return finish(verbindung, status: 404, body: Data()) }
guard let daten = await cache.data(for: original) else {
return finish(verbindung, status: 504, body: Data())
}
finish(verbindung, status: 200, body: daten, type: "video/mp4")
}
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
}
}
}
@@ -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
}
}
@@ -0,0 +1,211 @@
import Foundation
import OSLog
/// Holt die Schnipsel eines Streams, bevor der Player sie verlangt.
///
/// **Warum es das gibt.** AVPlayer entscheidet selbst, wie weit er puffert,
/// und bei HLS lässt Apple uns nicht dazwischen. Wir müssen aber auch nicht:
/// steht zwischen Player und Server ein eigener Speicher, kann der so weit
/// vorauslaufen, wie er will. Der Player holt dann von der Platte, und ob die
/// Leitung gerade steht, merkt er nicht.
///
/// Genau das ist der Unterschied zwischen „Puffer" und „Vorrat": ein Puffer
/// ist das, was der Player im Arbeitsspeicher hält, und den bestimmt Apple.
/// Der Vorrat hier ist das, was **wir** schon haben, und den bestimmen wir.
///
/// **Streng der Reihe nach.** Der Server erzeugt die Schnipsel gerade erst.
/// Wer Nummer 40 verlangt, während er bei 12 ist, sieht für Jellyfin aus wie
/// jemand, der gesprungen ist — und dann beginnt das Umrechnen von vorn, an
/// der falschen Stelle. Deshalb wird nie übersprungen, sondern nur zügiger
/// geholt, als abgespielt wird.
///
/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in
/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet.
public actor StreamCache {
/// Wie weit vorausgelaufen wird.
public struct Policy: Sendable {
/// Wie viel Film im Voraus geholt werden soll.
public let lookahead: Duration
/// Wie viel Platz der Vorrat höchstens belegen darf.
public let diskLimit: Int
public init(lookahead: Duration = .seconds(300), diskLimit: Int = 768 * 1_024 * 1_024) {
self.lookahead = lookahead
self.diskLimit = diskLimit
}
}
private let policy: Policy
private let session: URLSession
private let directory: URL
private let logger = Logger(subsystem: "app.flixy", category: "StreamCache")
/// Woher die Schnipsel wirklich kommen.
private var upstream: URL?
/// Die Liste, in der Reihenfolge des Servers.
private var segments: [String] = []
private var targetDuration: Duration = .seconds(6)
/// Was schon auf der Platte liegt — Pfad auf Datei.
private var stored: [String: URL] = [:]
private var storedBytes = 0
/// Welchen Schnipsel der Player zuletzt verlangt hat.
private var lastRequested = 0
/// Bis wohin geholt wurde.
private var fetched = 0
private var prefetchTask: Task<Void, Never>?
public init(policy: Policy = Policy()) {
self.policy = policy
let configuration = URLSessionConfiguration.ephemeral
// Der Vorrat ist unser Zwischenspeicher; ein zweiter darunter
// verdoppelte nur den Platzbedarf.
configuration.urlCache = nil
configuration.httpMaximumConnectionsPerHost = 4
configuration.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: configuration)
self.directory = FileManager.default.temporaryDirectory
.appending(path: "flixy-stream-\(UUID().uuidString)", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
// MARK: - Einrichten
/// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen.
///
/// Gibt die umgeschriebene Liste zurück — die, die der Player bekommt.
/// Ist es eine Liste von Stufen, wird nichts vorgeholt: dort stehen keine
/// Schnipsel, sondern weitere Listen.
public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String {
upstream = url
var request = URLRequest(url: url)
for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) }
let (daten, _) = try await session.data(for: request)
let liste = MediaPlaylist(String(decoding: daten, as: UTF8.self))
guard !liste.isMaster else { return liste.rewritingReferences { $0 } }
segments = liste.segments
if let dauer = liste.targetDuration { targetDuration = dauer }
beginPrefetching()
return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) }
}
// MARK: - Ausliefern
/// Die Daten zu einem Pfad — aus dem Vorrat, sonst frisch geholt.
public func data(for path: String) async -> Data? {
if let index = segments.firstIndex(of: path) {
// Woran der Player gerade ist. Daran hängt, wie weit wir
// vorauslaufen — und was wir wegwerfen dürfen.
lastRequested = max(lastRequested, index)
trimBehind()
}
if let ort = stored[path], let daten = try? Data(contentsOf: ort) { return daten }
return await fetch(path)
}
// MARK: - Vorauslaufen
private func beginPrefetching() {
prefetchTask?.cancel()
prefetchTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let weiter = await self.prefetchNext()
if !weiter { try? await Task.sleep(for: .milliseconds(500)) }
}
}
}
/// Holt den nächsten Schnipsel, falls einer fehlt und Platz ist.
///
/// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die
/// Schleife, statt zu drehen.
private func prefetchNext() async -> Bool {
guard fetched < segments.count else { return false }
// Der Vorsprung in Schnipseln, aus dem Vorsprung in Sekunden.
let vorsprung = Int((policy.lookahead.seconds / max(targetDuration.seconds, 1)).rounded())
guard fetched - lastRequested < vorsprung else { return false }
guard storedBytes < policy.diskLimit else { return false }
let pfad = segments[fetched]
if stored[pfad] != nil {
fetched += 1
return true
}
guard await fetch(pfad) != nil else {
// Nicht weiterzählen: beim nächsten Durchgang noch einmal
// versuchen. Ein Schnipsel, den der Server noch nicht fertig hat,
// ist kein Fehler — nur ein „gleich".
return false
}
fetched += 1
return true
}
@discardableResult
private func fetch(_ path: String) async -> Data? {
guard let upstream, let ziel = URL(string: path, relativeTo: upstream)?.absoluteURL
else { return nil }
do {
let (daten, antwort) = try await session.data(from: ziel)
guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil }
store(daten, at: path)
return daten
} catch {
return nil
}
}
private func store(_ daten: Data, at path: String) {
let ort = directory.appending(path: UUID().uuidString)
guard (try? daten.write(to: ort, options: .atomic)) != nil else { return }
stored[path] = ort
storedBytes += daten.count
}
/// Wirft weg, was hinter der Abspielstelle liegt.
///
/// **Nicht alles dahinter**, sondern mit Abstand: ein kurzer Rücksprung
/// ist üblich, und ihn jedes Mal neu zu holen wäre teurer als die paar
/// Megabyte.
private func trimBehind() {
let grenze = lastRequested - 12
guard grenze > 0 else { return }
for index in 0..<min(grenze, segments.count) {
let pfad = segments[index]
guard let ort = stored.removeValue(forKey: pfad) else { continue }
let größe = (try? FileManager.default.attributesOfItem(atPath: ort.path))
.flatMap { $0[.size] as? Int } ?? 0
storedBytes = max(0, storedBytes - größe)
try? FileManager.default.removeItem(at: ort)
}
}
// MARK: - Abräumen
public func teardown() {
prefetchTask?.cancel()
prefetchTask = nil
stored.removeAll()
storedBytes = 0
try? FileManager.default.removeItem(at: directory)
}
/// Wie viel Film im Voraus bereitliegt — für die Anzeige.
public var bufferedAhead: Duration {
let voraus = max(0, fetched - lastRequested)
return .seconds(Double(voraus) * targetDuration.seconds)
}
}
@@ -0,0 +1,88 @@
import Testing
import Foundation
@testable import FlixyPlayback
@Suite("Abspielliste lesen und umschreiben")
struct MediaPlaylistTests {
private let medien = """
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="hls1/main/-1.mp4"
#EXTINF:6.000000,
hls1/main/0.mp4
#EXTINF:6.000000,
hls1/main/1.mp4
#EXTINF:5.400000,
hls1/main/2.mp4
#EXT-X-ENDLIST
"""
private let stufen = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
main.m3u8?level=0
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
main.m3u8?level=1
"""
@Test("Die Schnipsel kommen in der Reihenfolge der Liste")
func readsSegmentsInOrder() {
let liste = MediaPlaylist(medien)
#expect(liste.segments == ["hls1/main/0.mp4", "hls1/main/1.mp4", "hls1/main/2.mp4"])
#expect(!liste.isMaster)
}
/// Die beiden Listenarten sehen sich ähnlich und meinen Verschiedenes.
/// Wer sie verwechselt, lädt Abspiellisten vor statt Bild.
@Test("Eine Liste von Stufen ist keine Liste von Schnipseln")
func tellsMasterFromMedia() {
#expect(MediaPlaylist(stufen).isMaster)
#expect(MediaPlaylist(stufen).segments.count == 2)
#expect(!MediaPlaylist(medien).isMaster)
}
/// **Die teuerste Stelle.** Eine Liste, die stellenweise am Speicher
/// vorbeizeigt, ergibt einen Film, der nur manchmal hakt — und so etwas
/// findet man im laufenden Bild nicht.
@Test("Umgeschrieben werden Adressen, sonst nichts")
func rewritesOnlyReferences() {
let neu = MediaPlaylist(medien).rewritingReferences { "http://127.0.0.1:1234/s/\($0)" }
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4"))
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4"))
// Anweisungen bleiben, wie sie waren — auch die mit einer Adresse
// **darin**, die wir nicht verstehen.
#expect(neu.contains("#EXT-X-TARGETDURATION:6"))
#expect(neu.contains("#EXT-X-MAP:URI=\"hls1/main/-1.mp4\""))
#expect(neu.contains("#EXT-X-ENDLIST"))
#expect(neu.hasPrefix("#EXTM3U"))
}
@Test("Die Zeilenzahl bleibt gleich")
func keepsStructure() {
let neu = MediaPlaylist(medien).rewritingReferences { $0 }
#expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count)
}
@Test("Eine vollständige Liste ist als solche zu erkennen")
func knowsWhenComplete() {
#expect(MediaPlaylist(medien).isComplete)
// Ohne Schlussmarke: ein laufender Sender, die Liste wächst noch.
#expect(!MediaPlaylist(medien.replacingOccurrences(of: "#EXT-X-ENDLIST", with: "")).isComplete)
}
@Test("Die Schnipseldauer wird gelesen")
func readsTargetDuration() {
#expect(MediaPlaylist(medien).targetDuration == .seconds(6))
#expect(MediaPlaylist(stufen).targetDuration == nil)
}
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
func emptyIsEmpty() {
#expect(MediaPlaylist("").segments.isEmpty)
#expect(MediaPlaylist("").rewritingReferences { $0 } == "")
}
}