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.. 0 else { return nil } return .seconds(sekunden) } return nil } }