Files
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

221 lines
8.7 KiB
Swift

import Testing
import Foundation
@testable import FlaroPlayback
@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"))
}
}