Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift
T
ScarriffleandClaude Opus 5 cff0c06745 Untertitel brachten den Vorratsspeicher auf 404
Untertitel stehen in der Abspielliste als eigene Spur unter EXT-X-MEDIA —
eine Anweisung mit einer Adresse darin. Umgeschrieben habe ich bisher nur
EXT-X-MAP, also blieb diese relativ stehen und zeigte danach auf den
lokalen Server, wo es sie nicht gibt. Zu sehen war der Fehler erst, als
jemand Untertitel einschaltete: HTTP 404, mitten im Film.

Jetzt sagt der Umschreiber, **was** fuer eine Adresse er gerade in der Hand
hat: eine Qualitaetsstufe, eine Nebenspur, ein Kopfstueck oder ein
Schnipsel. Das ist noetig, weil sie Verschiedenes wollen — hinter einer
Stufe liegt Bild und die soll vorgeladen werden, hinter einer Untertitel-
spur nicht. Sonst lueden wir Untertitel statt Bild: der Film bliebe stehen,
waehrend die Anzeige reichlich Vorrat meldet.

Dazu wandern alle Adressen im lokalen Server auf die **vollstaendige**
Form. Eine relative gilt je nach Liste gegen eine andere Grundadresse — die
Untertitelspur liegt woanders als das Bild —, und wer das erst beim Abholen
aufloest, loest es irgendwann gegen die falsche auf. Das waere der naechste
Fehler derselben Art gewesen.

Drei Tests halten die Unterscheidung fest, darunter die Liste mit
Untertitelspur, die den 404 ausgeloest hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 21:36:35 +02:00

156 lines
6.5 KiB
Swift

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
}
/// Was für eine Adresse an einer Stelle steht.
///
/// **Warum das jemanden interessiert.** Eine Abspielliste enthält
/// viererlei Adressen, und sie wollen Verschiedenes: eine Qualitätsstufe
/// führt zu weiteren Schnipseln und soll vorgeladen werden, eine
/// Untertitelspur führt zu einer eigenen Liste und soll es **nicht** —
/// sonst lädt der Vorrat Untertitel statt Bild.
public enum Reference: Sendable, Equatable {
/// Eine Qualitätsstufe — dahinter liegt das Bild.
case level
/// Eine Nebenspur: Untertitel oder zusätzlicher Ton.
case rendition
/// Das Kopfstück einer fragmentierten MP4.
case map
/// Ein Schnipsel.
case segment
}
/// 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.
///
/// **Auch die Adressen in Anweisungen.** Zwei Anweisungen tragen eine:
/// `EXT-X-MAP` das Kopfstück und `EXT-X-MEDIA` die Nebenspuren. Beide
/// stehen zu lassen ergibt eine Liste, die halb auf uns und halb ins
/// Leere zeigt — und das endet mit „404", sobald jemand Untertitel
/// einschaltet.
public func rewritingReferences(_ transform: (String, Reference) -> String) -> String {
var ergebnis: [String] = []
ergebnis.reserveCapacity(lines.count)
var nächsteIstStufe = false
for zeile in lines {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
if getrimmt.hasPrefix("#EXT-X-STREAM-INF") {
nächsteIstStufe = true
ergebnis.append(zeile)
} else if getrimmt.hasPrefix("#EXT-X-MEDIA:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .rendition) })
} else if getrimmt.hasPrefix("#EXT-X-MAP:") {
ergebnis.append(Self.rewritingURI(in: zeile) { transform($0, .map) })
} else if getrimmt.isEmpty || getrimmt.hasPrefix("#") {
ergebnis.append(zeile)
} else {
ergebnis.append(transform(getrimmt, nächsteIstStufe ? .level : .segment))
nächsteIstStufe = false
}
}
return ergebnis.joined(separator: "\n")
}
/// Tauscht die Adresse in `URI="…"` aus, sonst nichts.
private static func rewritingURI(
in zeile: String,
_ transform: (String) -> String
) -> String {
let marke = "URI=\""
guard let start = zeile.range(of: marke),
let ende = zeile[start.upperBound...].firstIndex(of: "\"")
else { return zeile }
let adresse = String(zeile[start.upperBound..<ende])
return zeile.replacingCharacters(in: start.upperBound..<ende, with: transform(adresse))
}
/// 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
}
}