Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.5 KiB
Swift
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
|
|
}
|
|
}
|