Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user