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>
221 lines
8.7 KiB
Swift
221 lines
8.7 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import HaloPlayback
|
|
|
|
@Suite("Abspielliste lesen und umschreiben")
|
|
struct MediaPlaylistTests {
|
|
|
|
private let medien = """
|
|
#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
#EXT-X-MAP:URI="hls1/main/-1.mp4"
|
|
#EXTINF:6.000000,
|
|
hls1/main/0.mp4
|
|
#EXTINF:6.000000,
|
|
hls1/main/1.mp4
|
|
#EXTINF:5.400000,
|
|
hls1/main/2.mp4
|
|
#EXT-X-ENDLIST
|
|
"""
|
|
|
|
private let stufen = """
|
|
#EXTM3U
|
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
|
|
main.m3u8?level=0
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
|
|
main.m3u8?level=1
|
|
"""
|
|
|
|
@Test("Die Schnipsel kommen in der Reihenfolge der Liste")
|
|
func readsSegmentsInOrder() {
|
|
let liste = MediaPlaylist(medien)
|
|
#expect(liste.segments == ["hls1/main/0.mp4", "hls1/main/1.mp4", "hls1/main/2.mp4"])
|
|
#expect(!liste.isMaster)
|
|
}
|
|
|
|
/// Die beiden Listenarten sehen sich ähnlich und meinen Verschiedenes.
|
|
/// Wer sie verwechselt, lädt Abspiellisten vor statt Bild.
|
|
@Test("Eine Liste von Stufen ist keine Liste von Schnipseln")
|
|
func tellsMasterFromMedia() {
|
|
#expect(MediaPlaylist(stufen).isMaster)
|
|
#expect(MediaPlaylist(stufen).segments.count == 2)
|
|
#expect(!MediaPlaylist(medien).isMaster)
|
|
}
|
|
|
|
/// **Die teuerste Stelle.** Eine Liste, die stellenweise am Speicher
|
|
/// vorbeizeigt, ergibt einen Film, der nur manchmal hakt — und so etwas
|
|
/// findet man im laufenden Bild nicht.
|
|
@Test("Umgeschrieben werden Adressen, sonst nichts")
|
|
func rewritesOnlyReferences() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "http://127.0.0.1:1234/s/\(adresse)" }
|
|
|
|
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4"))
|
|
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4"))
|
|
// Anweisungen bleiben, wie sie waren.
|
|
#expect(neu.contains("#EXT-X-TARGETDURATION:6"))
|
|
// **Mit einer Ausnahme**, und die hat mich ein Schwarzbild gekostet:
|
|
// in `EXT-X-MAP` steckt eine Adresse, und die muss mit. Siehe unten.
|
|
#expect(neu.contains("#EXT-X-MAP:URI=\"http://127.0.0.1:1234/s/hls1/main/-1.mp4\""))
|
|
#expect(neu.contains("#EXT-X-ENDLIST"))
|
|
#expect(neu.hasPrefix("#EXTM3U"))
|
|
}
|
|
|
|
/// Ohne das Kopfstück ist kein einziger Schnipsel einer fragmentierten
|
|
/// MP4 zu dekodieren. Bliebe die Adresse stehen, zeigte sie relativ auf
|
|
/// den lokalen Server — schwarzes Bild, ohne dass an den Schnipseln
|
|
/// irgendetwas falsch wäre.
|
|
@Test("Das Kopfstück wandert mit")
|
|
func rewritesInitSegment() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
|
|
#expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\""))
|
|
}
|
|
|
|
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
|
|
func leavesOtherTagsAlone() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
|
|
#expect(neu.contains("#EXT-X-VERSION:7"))
|
|
#expect(neu.contains("#EXTINF:6.000000,"))
|
|
}
|
|
|
|
@Test("Die Zeilenzahl bleibt gleich")
|
|
func keepsStructure() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in adresse }
|
|
#expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count)
|
|
}
|
|
|
|
@Test("Eine vollständige Liste ist als solche zu erkennen")
|
|
func knowsWhenComplete() {
|
|
#expect(MediaPlaylist(medien).isComplete)
|
|
// Ohne Schlussmarke: ein laufender Sender, die Liste wächst noch.
|
|
#expect(!MediaPlaylist(medien.replacingOccurrences(of: "#EXT-X-ENDLIST", with: "")).isComplete)
|
|
}
|
|
|
|
@Test("Die Schnipseldauer wird gelesen")
|
|
func readsTargetDuration() {
|
|
#expect(MediaPlaylist(medien).targetDuration == .seconds(6))
|
|
#expect(MediaPlaylist(stufen).targetDuration == nil)
|
|
}
|
|
|
|
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
|
|
func emptyIsEmpty() {
|
|
#expect(MediaPlaylist("").segments.isEmpty)
|
|
#expect(MediaPlaylist("").rewritingReferences { adresse, _ in adresse } == "")
|
|
}
|
|
}
|
|
|
|
@Suite("Nebenspuren in der Abspielliste")
|
|
struct PlaylistRenditionTests {
|
|
/// **Der Fehler, der mit „404" endete.** Untertitel stehen in einer
|
|
/// eigenen Anweisung mit einer Adresse darin. Blieb sie stehen, zeigte
|
|
/// sie relativ auf den lokalen Server — und dort gibt es sie nicht. Zu
|
|
/// sehen war das erst, als jemand Untertitel einschaltete.
|
|
private let mitUntertiteln = """
|
|
#EXTM3U
|
|
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",URI="subtitles/0.m3u8"
|
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,SUBTITLES="subs"
|
|
main.m3u8?level=0
|
|
"""
|
|
|
|
@Test("Die Untertitelspur wird mit umgeschrieben")
|
|
func rewritesSubtitleRendition() {
|
|
let neu = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, _ in
|
|
"/um/\(adresse)"
|
|
}
|
|
#expect(neu.contains("URI=\"/um/subtitles/0.m3u8\""))
|
|
#expect(!neu.contains("URI=\"subtitles/0.m3u8\""))
|
|
}
|
|
|
|
/// **Und sie darf nicht vorgeladen werden.** Sonst lüde der Vorrat
|
|
/// Untertitel statt Bild: der Film bliebe stehen, während die Anzeige
|
|
/// reichlich Vorrat meldet.
|
|
@Test("Stufe und Nebenspur werden unterschieden")
|
|
func tellsLevelFromRendition() {
|
|
var arten: [String: MediaPlaylist.Reference] = [:]
|
|
_ = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, art in
|
|
arten[adresse] = art
|
|
return adresse
|
|
}
|
|
|
|
#expect(arten["main.m3u8?level=0"] == .level)
|
|
#expect(arten["subtitles/0.m3u8"] == .rendition)
|
|
}
|
|
|
|
@Test("Das Kopfstück gilt als Schnipsel, nicht als Liste")
|
|
func mapIsASegment() {
|
|
let liste = """
|
|
#EXTM3U
|
|
#EXT-X-MAP:URI="hls1/main/-1.mp4"
|
|
#EXTINF:6.0,
|
|
hls1/main/0.mp4
|
|
"""
|
|
var arten: [String: MediaPlaylist.Reference] = [:]
|
|
_ = MediaPlaylist(liste).rewritingReferences { adresse, art in
|
|
arten[adresse] = art
|
|
return adresse
|
|
}
|
|
|
|
#expect(arten["hls1/main/-1.mp4"] == .map)
|
|
#expect(arten["hls1/main/0.mp4"] == .segment)
|
|
}
|
|
}
|
|
|
|
@Suite("Adressen für den lokalen Speicher")
|
|
struct LocalStreamServerPathTests {
|
|
/// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von
|
|
/// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach
|
|
/// auf den lokalen Server — und dort gibt es sie nicht.
|
|
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
|
|
func levelPathIsAbsolute() {
|
|
let pfad = LocalStreamServer.localPlaylistPath(
|
|
for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t",
|
|
prefetch: true
|
|
)
|
|
let teile = URLComponents(string: pfad)
|
|
#expect(teile?.path == "/level.m3u8")
|
|
#expect(
|
|
teile?.queryItems?.first { $0.name == "u" }?.value
|
|
== "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
|
|
)
|
|
}
|
|
|
|
/// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er
|
|
/// eine Abspielliste oder Bild erwartet.
|
|
@Test("Die Endung bleibt eine Abspielliste")
|
|
func levelPathKeepsExtension() {
|
|
#expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8", prefetch: true).hasPrefix("/level.m3u8"))
|
|
}
|
|
|
|
/// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts
|
|
/// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad.
|
|
@Test("Der Schnipselpfad überlebt Schrägstriche")
|
|
func segmentPathSurvivesSlashes() {
|
|
let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
|
|
let teile = URLComponents(string: pfad)
|
|
#expect(teile?.path == "/s/")
|
|
#expect(teile?.queryItems?.first { $0.name == "u" }?.value == "hls1/main/12.mp4?runtimeTicks=0")
|
|
}
|
|
|
|
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
|
|
/// Server zeigen — und zwar jede Zeile.
|
|
@Test("Jede Stufe zeigt danach auf uns")
|
|
func everyLevelIsRewritten() {
|
|
let stufen = """
|
|
#EXTM3U
|
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000
|
|
main.m3u8?level=0
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3000000
|
|
main.m3u8?level=1
|
|
"""
|
|
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
|
|
let neu = MediaPlaylist(stufen).rewritingReferences { eintrag, _ in
|
|
let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag
|
|
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true)
|
|
}
|
|
|
|
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
|
|
#expect(!neu.contains("\nmain.m3u8"))
|
|
}
|
|
}
|