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 } }