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>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
@@ -0,0 +1,263 @@
import Testing
import Foundation
import MediaKit
@testable import FlaroPlayback
@Suite("Sprachkürzel")
struct LanguageCodeTests {
/// Der Fall, an dem die alte Prüfung lautlos scheiterte: `ger` ist die
/// ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in viele Dateien.
@Test("ger, deu und de-DE meinen alle Deutsch")
func germanHasThreeSpellings() {
#expect(LanguageCode.matches("ger", "de"))
#expect(LanguageCode.matches("deu", "de"))
#expect(LanguageCode.matches("de-DE", "de"))
#expect(LanguageCode.matches("GER", "de"))
}
/// Schweizerdeutsch ist keine Schreibweise von Deutsch. Es gleichzusetzen
/// wäre bequem und falsch — wer Hochdeutsch will, bekäme sonst
/// Schweizerdeutsch untergeschoben.
@Test("Schweizerdeutsch ist nicht Deutsch")
func swissGermanStandsAlone() {
#expect(!LanguageCode.matches("gsw", "de"))
#expect(LanguageCode.matches("gsw", "gsw"))
}
@Test("Auch die anderen Doppelformen")
func otherDoubleForms() {
#expect(LanguageCode.matches("fre", "fra"))
#expect(LanguageCode.matches("dut", "nl"))
#expect(LanguageCode.matches("cze", "cs"))
#expect(LanguageCode.matches("chi", "zh"))
}
@Test("Ohne Angabe kein Treffer")
func missingIsNoMatch() {
#expect(!LanguageCode.matches(nil, "de"))
#expect(!LanguageCode.matches("de", nil))
}
}
@Suite("Wahl der Tonspur")
struct AudioTrackChooserTests {
private func spur(
_ index: Int,
_ sprache: String?,
title: String? = nil,
channels: Int = 2,
isDefault: Bool = false
) -> MediaStream {
MediaStream(
id: String(index),
kind: .audio,
codec: "aac",
language: sprache,
languageCode: sprache,
displayTitle: title,
channels: channels,
title: title,
isDefault: isDefault
)
}
// MARK: - Der ZDF-Fall
/// Wörtlich der Ärger, wegen dem es den Typ gibt: die als deutsch
/// ausgezeichnete Spur ist die mit Audiodeskription, und die als englisch
/// ausgezeichnete ist in Wahrheit die deutsche ohne.
private var zdf: [MediaStream] {
[
spur(0, "ger", title: "Deutsch (Audiodeskription)", isDefault: true),
spur(1, "eng", title: "Deutsch"),
]
}
@Test("Audiodeskription wird gemieden, auch wenn sie Standard ist")
func avoidsAudioDescription() {
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de")
)
#expect(gewählt?.id == "1")
}
/// Ohne das Meiden gewinnt die ausgezeichnete Sprache — der Beweis, dass
/// der Schalter tatsächlich das Ruder herumreißt und nicht nur dabeisteht.
@Test("Ohne das Meiden gewinnt das Kürzel")
func withoutAvoidanceTheCodeWins() {
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de", avoidsAudioDescription: false)
)
#expect(gewählt?.id == "0")
}
/// Und wenn der Nutzer einmal gewählt hat, gilt das — auch gegen die
/// Sprache.
@Test("Die gemerkte Spur schlägt den Sprachwunsch")
func rememberedBeatsLanguage() {
let gemerkt = AudioTrackChooser.TrackSignature(zdf[1])
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de", remembered: gemerkt, avoidsAudioDescription: false)
)
#expect(gewählt?.id == "1")
}
// MARK: - Die Rangliste
@Test("Fehlt die Wunschsprache, greift die Rangliste")
func fallbackListApplies() {
let spuren = [spur(0, "fre"), spur(1, "eng"), spur(2, "ita")]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["gsw", "en", "fr"])
)
#expect(gewählt?.id == "1")
}
@Test("Die Reihenfolge der Rangliste zählt")
func fallbackOrderMatters() {
let spuren = [spur(0, "fre"), spur(1, "eng")]
let zuerstFranzösisch = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["fr", "en"])
)
#expect(zuerstFranzösisch?.id == "0")
}
@Test("Schweizerdeutsch lässt sich in die Rangliste stellen")
func swissGermanInTheList() {
let spuren = [spur(0, "eng"), spur(1, "gsw")]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["gsw", "en"])
)
#expect(gewählt?.id == "1")
}
// MARK: - Der Boden darunter
@Test("Ohne jede Übereinstimmung kommt trotzdem eine Spur")
func alwaysAnswers() {
let spuren = [spur(0, "jpn"), spur(1, "kor", isDefault: true)]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["en"])
)
// Die Standardspur — nachvollziehbar statt beliebig.
#expect(gewählt?.id == "1")
}
@Test("Gibt es die Sprache nur mit Audiodeskription, ist sie besser als nichts")
func audioDescriptionBeatsSilence() {
let spuren = [spur(0, "ger", title: "Deutsch (Audiodeskription)")]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "0")
}
@Test("Ohne Tonspuren gibt es nichts zu wählen")
func noAudioNoChoice() {
let nurBild = [MediaStream(id: "0", kind: .video)]
#expect(AudioTrackChooser.choose(from: nurBild, wish: .init()) == nil)
}
@Test("Kommentarspuren werden gemieden")
func avoidsCommentary() {
let spuren = [
spur(0, "ger", title: "Audiokommentar des Regisseurs", isDefault: true),
spur(1, "ger", title: "Deutsch"),
]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "1")
}
/// „AD" als eigenes Wort, nicht als Teil von „Broadcast".
@Test("AD erkennt nur das freistehende Wort")
func adOnlyAsWholeWord() {
let spuren = [
spur(0, "ger", title: "Deutsch Broadcast Mix"),
spur(1, "ger", title: "Deutsch AD"),
]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "0")
}
@Test("Mehr Kanäle gewinnen bei sonst gleichem Stand")
func moreChannelsWin() {
let spuren = [spur(0, "ger", title: "Deutsch", channels: 2),
spur(1, "ger", title: "Deutsch", channels: 6)]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "1")
}
/// Zweimal dieselbe Datei muss zweimal dasselbe ergeben — sonst ist ein
/// Fehler nicht wiederholbar.
@Test("Gleichstand entscheidet die Nummer")
func tiesAreStable() {
let spuren = [spur(1, "ger", title: "Deutsch"), spur(0, "ger", title: "Deutsch")]
#expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0")
}
}
@Suite("Rangfolge der drei Ebenen")
struct WishResolutionTests {
/// Serie schlägt Mediathek schlägt allgemein. Das ist der ganze Zweck der
/// Abstufung — und der einzige Ort, an dem sie steht.
@Test("Die Serie gewinnt")
func seriesWins() {
let wunsch = AudioTrackChooser.resolve(series: "en", library: "fr", general: "de")
#expect(wunsch.language == "en")
}
@Test("Ohne Serie gewinnt die Mediathek")
func libraryWinsWithoutSeries() {
#expect(AudioTrackChooser.resolve(series: nil, library: "fr", general: "de").language == "fr")
}
@Test("Ohne beides gilt die allgemeine")
func generalIsTheFloor() {
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "de").language == "de")
}
/// In den Einstellungen steht eine leere Zeichenkette für „soll der Server
/// entscheiden". Sie als Sprache zu behandeln hiesse, die Ebene darüber
/// stillschweigend abzuschalten — genau der Fehler, den man erst bemerkt,
/// wenn eine Einstellung scheinbar wirkungslos ist.
@Test("Leer heisst nichts gesagt, nicht eine Sprache namens leer")
func emptyIsNotAnAnswer() {
#expect(AudioTrackChooser.resolve(series: "", library: "fr", general: "de").language == "fr")
#expect(AudioTrackChooser.resolve(series: " ", library: "", general: "de").language == "de")
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "").language == nil)
}
/// Die Rangliste ist keine vierte Ebene: sie gilt überall gleich und wird
/// von keiner Ebene überstimmt.
@Test("Rangliste und Schalter gehen unverändert durch")
func theRestPassesThrough() {
let wunsch = AudioTrackChooser.resolve(
series: nil, library: nil, general: "de",
fallbacks: ["gsw", "en"], avoidsAudioDescription: false
)
#expect(wunsch.fallbacks == ["gsw", "en"])
#expect(wunsch.avoidsAudioDescription == false)
}
/// Der ZDF-Fall durch die ganze Kette: die Mediathek sagt Deutsch, die
/// gemerkte Spur sagt etwas anderes — und behält recht.
@Test("Die gemerkte Spur überlebt die Rangfolge")
func rememberedSurvives() {
let spuren = [
MediaStream(id: "0", kind: .audio, language: "ger", languageCode: "ger",
channels: 2, title: "Deutsch (Audiodeskription)", isDefault: true),
MediaStream(id: "1", kind: .audio, language: "eng", languageCode: "eng",
channels: 2, title: "Deutsch"),
]
let wunsch = AudioTrackChooser.resolve(
series: nil, library: "de", general: "en",
remembered: AudioTrackChooser.TrackSignature(spuren[1])
)
#expect(AudioTrackChooser.choose(from: spuren, wish: wunsch)?.id == "1")
}
}
@@ -0,0 +1,128 @@
import Testing
import Foundation
@testable import FlaroPlayback
@Suite("Wann der teure Weg erlaubt ist")
struct DowngradeRuleTests {
/// Der Normalfall, in dem abgesenkt werden soll: keine Leiter da, es
/// hakt dauerhaft, und es läuft lange genug.
private func notlage(
isAutomatic: Bool = true,
hasLadder: Bool = false,
isAtLowestRung: Bool = false,
condition: StreamCondition = .failing,
stalls: Int = 3,
longest: Duration = .seconds(4),
playedFor: Duration = .seconds(120),
sinceLastSeek: Duration? = nil,
sinceLastDowngrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> DowngradeRule.Facts {
.init(
isAutomatic: isAutomatic,
hasLadder: hasLadder,
isAtLowestRung: isAtLowestRung,
condition: condition,
stallsInWindow: stalls,
longestStallInWindow: longest,
playedFor: playedFor,
sinceLastSeek: sinceLastSeek,
sinceLastDowngrade: sinceLastDowngrade,
used: used,
budget: budget
)
}
@Test("Ohne Leiter und mit drei Rücklern: ja")
func downgradesWhenNoLadder() {
#expect(DowngradeRule.verdict(notlage()) == .downgrade)
}
/// Eine ausdrücklich gewählte Stufe ist eine Ansage. `automatic` ist die
/// Erlaubnis einzugreifen, alles andere nicht.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(DowngradeRule.verdict(notlage(isAutomatic: false)) == .notAutomatic)
}
/// Solange es noch eine Sprosse tiefer gibt, kostet der billige Hebel
/// kein schwarzes Bild.
@Test("Solange die Leiter trägt, bleibt es beim billigen Hebel")
func prefersTheCheapLever() {
let facts = notlage(hasLadder: true, isAtLowestRung: false)
#expect(DowngradeRule.verdict(facts) == .cheaperPathRemains)
}
@Test("Auf der untersten Sprosse ist der billige Hebel erschöpft")
func lowestRungAllowsTheExpensivePath() {
let facts = notlage(hasLadder: true, isAtLowestRung: true)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Zwei Rückler genügen nicht")
func twoStallsAreNotEnough() {
#expect(DowngradeRule.verdict(notlage(stalls: 2)) == .notBadEnough)
}
/// Fünfzehn Sekunden Stillstand sind keine Schwankung. Darauf zu warten,
/// bis drei zusammenkommen, hieße viereinhalb Minuten zusehen.
@Test("Ein einzelner langer Rückler genügt allein")
func oneLongStallIsEnough() {
let facts = notlage(stalls: 1, longest: .seconds(20))
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Ein gesunder Stream löst nichts aus")
func healthyNeverTriggers() {
#expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough)
}
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
/// keine.
@Test("In den ersten Sekunden passiert nichts")
func waitsAfterStart() {
#expect(DowngradeRule.verdict(notlage(playedFor: .seconds(5))) == .tooEarly)
}
/// Ein Sprung leert den Puffer immer. Sich dafür zu bestrafen wäre
/// absurd.
@Test("Kurz nach einem Sprung passiert nichts")
func waitsAfterSeek() {
let facts = notlage(sinceLastSeek: .seconds(3))
#expect(DowngradeRule.verdict(facts) == .tooEarly)
}
@Test("Ein länger zurückliegender Sprung stört nicht")
func oldSeekIsFine() {
#expect(DowngradeRule.verdict(notlage(sinceLastSeek: .seconds(30))) == .downgrade)
}
@Test("Innerhalb der Sperrfrist nicht noch einmal")
func respectsCooldown() {
let facts = notlage(sinceLastDowngrade: .seconds(60), used: 1)
#expect(DowngradeRule.verdict(facts) == .cooling)
}
@Test("Nach der Sperrfrist wieder")
func afterCooldownAgain() {
let facts = notlage(sinceLastDowngrade: .seconds(200), used: 1)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
/// Lautlos die ganze Leiter bis 240p hinunterzugehen ist schlimmer, als
/// es zu sagen.
@Test("Das Budget ist endlich")
func budgetIsFinite() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 2, budget: 2)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
/// Bei Live-TV nur einmal: jeder Neuaufbau stimmt den Tuner neu ab, und
/// davon gibt es vier Stück.
@Test("Bei Live-TV ist nach einem Mal Schluss")
func liveBudgetIsOne() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 1, budget: 1)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
}
@@ -0,0 +1,367 @@
import Foundation
import Network
import Testing
@testable import FlaroPlayback
/// Was der lokale Server dem Player wirklich schickt.
///
/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und
/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server
/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an
/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die
/// Antwort **liest**, ist das nicht zu finden.
///
/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher
/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein
/// Gerät, kein Simulator, kein echtes Jellyfin.
@Suite("Der lokale Server", .serialized)
struct LocalStreamServerTests {
// MARK: - Vorlagen
private static let master = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2"
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs"
main.m3u8?api_key=abc
"""
private static let bildListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
hls1/main/0.ts?api_key=abc
#EXTINF:6.000000,
hls1/main/1.ts?api_key=abc
#EXT-X-ENDLIST
"""
private static let untertitelListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
0.vtt?api_key=abc&SubtitleStreamIndex=2
#EXTINF:6.000000,
1.vtt?api_key=abc&SubtitleStreamIndex=2
#EXT-X-ENDLIST
"""
private static let vtt = """
WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
00:00:01.000 --> 00:00:04.000
Hallo Welt.
"""
/// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde.
private static func jellyfin() -> FakeOrigin {
let server = FakeOrigin()
server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master)
server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe)
server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe)
server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0")
server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1")
server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt)
server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt)
return server
}
// MARK: - Der Aufbau
/// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die
/// der Player bekäme.
private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
return (herkunft, server, lokal)
}
/// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück.
private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) {
let (daten, antwort) = try await URLSession.shared.data(from: url)
let http = antwort as? HTTPURLResponse
return (
http?.statusCode ?? 0,
http?.value(forHTTPHeaderField: "Content-Type"),
String(decoding: daten, as: UTF8.self)
)
}
/// Zieht die erste Adresse aus einem `URI="…"` der Liste.
private func renditionAdresse(in liste: String, basis: URL) -> URL? {
guard let start = liste.range(of: "URI=\""),
let ende = liste[start.upperBound...].firstIndex(of: "\"")
else { return nil }
return URL(string: String(liste[start.upperBound..<ende]), relativeTo: basis)
}
/// Zieht die erste Segment-Zeile aus einer Medienliste.
private func ersteSegmentAdresse(in liste: String, basis: URL) -> URL? {
for zeile in liste.components(separatedBy: .newlines) {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue }
return URL(string: getrimmt, relativeTo: basis)
}
return nil
}
// MARK: - Die Tests
/// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`,
/// geht bei AVFoundation an den falschen Parser, findet keine Box, und es
/// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist
/// ausgewählt, und bleibt leer.
@Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück")
func subtitleSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.body.hasPrefix("WEBVTT"))
#expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Gegentest, damit die Reparatur nicht zu breit wird.
@Test("Ein Bild-Schnipsel behält seinen eigenen Typ")
func videoSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
// Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche
// Adresse in der Liste.
let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal))
let stufe = try await hole(stufeURL)
let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch.
/// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche
/// Datei — und niemand sähe es der Antwort an.
@Test("Die Adresse übersteht die Rundreise zeichengenau")
func addressSurvivesTheRoundTrip() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
let gesehen = herkunft.requests
#expect(
gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8")
&& $0.contains("api_key=abc")
&& $0.contains("SubtitleStreamIndex=2") },
"der Herkunftsserver sah: \(gesehen)"
)
}
/// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was
/// danach passiert.
@Test("Die Untertitelspur bleibt in der Master-Liste stehen")
func masterKeepsTheRendition() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let antwort = try await hole(lokal)
#expect(antwort.status == 200)
#expect(antwort.type == "application/vnd.apple.mpegurl")
#expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"))
#expect(antwort.body.contains("GROUP-ID=\"subs\""))
#expect(antwort.body.contains("LANGUAGE=\"ger\""))
#expect(antwort.body.contains("DEFAULT=YES"))
#expect(antwort.body.contains("SUBTITLES=\"subs\""))
}
/// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen,
/// während die Anzeige reichlich Vorrat meldet.
@Test("Die Untertitelspur wird nicht vorgeladen")
func renditionIsNotPrefetched() async throws {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
#expect(await speicher.bufferedAhead == .zero)
}
/// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden
/// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen
/// Stelle.
@Test("Ein Fehler von oben bleibt ein Fehler")
func upstreamFailureStaysAFailure() async throws {
let herkunft = Self.jellyfin()
herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope")
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 504)
}
}
// MARK: - Der Herkunftsserver
/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde
/// — und aufschreibt, wonach gefragt wurde.
///
/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was
/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke
/// hat die Fehlersuche so lange gekostet.
final class FakeOrigin: @unchecked Sendable {
private struct Antwort {
let status: Int
let type: String
let body: String
}
private let lock = NSLock()
private var antworten: [String: Antwort] = [:]
private var gesehen: [String] = []
private var listener: NWListener?
func add(_ path: String, status: Int = 200, type: String, body: String) {
lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) }
}
/// Alles, wonach gefragt wurde — mit vollständiger Abfrage.
var requests: [String] {
lock.withLock { gesehen }
}
func start() async throws -> UInt16 {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
let lauscher = try NWListener(using: parameters)
lauscher.newConnectionHandler = { [weak self] verbindung in
verbindung.start(queue: .global(qos: .userInitiated))
self?.receive(on: verbindung, buffer: Data())
}
let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
let riegel = TestOneShot()
lauscher.stateUpdateHandler = { zustand in
switch zustand {
case .ready:
guard riegel.claim() else { return }
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
case .failed(let fehler):
guard riegel.claim() else { return }
fortsetzen.resume(throwing: fehler)
default:
break
}
}
lauscher.start(queue: .global(qos: .userInitiated))
}
listener = lauscher
return port
}
func stop() {
listener?.cancel()
listener = nil
}
private func receive(on verbindung: NWConnection, buffer: Data) {
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
[weak self] daten, _, abgeschlossen, fehler in
guard let self else { return }
var gesammelt = buffer
if let daten { gesammelt.append(daten) }
guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
if fehler == nil, !abgeschlossen {
self.receive(on: verbindung, buffer: gesammelt)
} else {
verbindung.cancel()
}
return
}
let kopf = String(decoding: gesammelt[..<ende.lowerBound], as: UTF8.self)
self.antworte(auf: kopf, verbindung: verbindung)
}
}
private func antworte(auf kopf: String, verbindung: NWConnection) {
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
let antwort = lock.withLock { () -> Antwort? in
gesehen.append(pfadMitAbfrage)
return antworten[pfad]
}
let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da")
let koerper = Data(a.body.utf8)
let ausgabe = """
HTTP/1.1 \(a.status) OK\r
Content-Type: \(a.type)\r
Content-Length: \(koerper.count)\r
Connection: close\r
\r
"""
var alles = Data(ausgabe.utf8)
alles.append(koerper)
verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() })
}
}
/// Wie im Original: ein Riegel, der genau einmal aufgeht.
private final class TestOneShot: @unchecked Sendable {
private let lock = NSLock()
private var used = false
func claim() -> Bool {
lock.withLock {
guard !used else { return false }
used = true
return true
}
}
}
@@ -0,0 +1,220 @@
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"))
}
}
@@ -0,0 +1,408 @@
import Testing
import MediaKit
@testable import FlaroPlayback
/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet.
///
/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst
/// werden kann: jede Änderung dort schlägt hier sichtbar durch.
@Suite("Wiedergabe-Entscheidung")
struct PlaybackDeciderTests {
/// Beschreibt eine Datei mit den angegebenen Eigenschaften.
///
/// Seit die Entscheidung `MediaProfile` entgegennimmt, steht hier genau
/// das, worauf es ankommt. Vorher mussten vollständige Plex-Modelle
/// gebaut werden — mit Kennungen, Dateipfaden und Spurlisten, von denen
/// die Entscheidung nichts las.
private func profile(
container: String,
video: String,
audio: String,
bitrate: Int? = nil
) -> MediaProfile {
MediaProfile(
container: container,
videoCodec: video,
audioCodec: audio,
bitrate: bitrate
)
}
// MARK: - Was AVPlayer nativ kann
@Test(
"Native Formate gehen an AVPlayer",
arguments: [
("mp4", "h264", "aac"),
("mp4", "hevc", "eac3"),
("mov", "h264", "ac3"),
("m4v", "hevc", "alac"),
// Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist
// darin nicht einheitlich.
("MP4", "H264", "AAC"),
]
)
func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: container, video: video, audio: audio),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.nativelySupported))
}
// MARK: - Was ohne Serverhilfe übrig bleibt
/// **Hier stand einmal VLC.** Eine zweite Engine nahm all das an, was
/// AVPlayer nicht öffnet. Es gibt sie nicht mehr: darf der Server helfen,
/// packt er um oder rechnet um (siehe `RemuxDecisionTests`); darf er es
/// nicht, bleibt nur der Versuch — und der Grund steht in der Begründung.
@Test("Matroska ohne Serverhilfe: der Versuch, mit Begründung")
func matroskaWithoutServerHelp() {
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
// Die Begründung ist der ganze Wert dieses Falls: ohne sie sähe das
// Scheitern aus wie ein Fehler der App.
#expect(decision.rationale.contains(.transcodingUnavailable))
}
@Test(
"Nicht unterstützter Ton ohne Transcoder: der Versuch",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioWithoutServerHelp(codec: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: codec),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
}
@Test(
"Nicht unterstütztes Bild geht an den Transcoder",
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
)
func unsupportedVideoTranscodes(codec: String) {
// Steht das **Bild** im Weg, hilft Umpacken nicht — dieselben Ströme
// in einer anderen Hülle blieben dieselben Ströme.
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: codec, audio: "aac"),
policy: PlaybackPolicy(allowsTranscoding: true)
)
#expect(decision.method == .transcode(maximumBitrate: nil))
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
}
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
func decisionFollowsSelectedAudioTrack() {
// Die Datei hat englisches DTS und deutsches AAC; gewählt ist
// Deutsch. Entschieden wird über die Spur, die klingen soll — wer sie
// aussucht, hat der Aufrufer beim Bauen des Profils entschieden.
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
}
@Test("Unanalysierte Dateien gehen an den Transcoder")
func unknownStreamsTranscode() {
// Der Server hat die Datei noch nicht untersucht: keine Spuren, kein
// bekannter Codec. Dann ist Raten die falsche Antwort — und seit es
// keine tolerantere Engine mehr gibt, ist Umrechnen die sichere.
let decision = PlaybackDecider().decide(
profile: MediaProfile(container: "mp4", hasKnownStreams: false),
policy: PlaybackPolicy(allowsTranscoding: true)
)
#expect(decision.method == .transcode(maximumBitrate: nil))
#expect(decision.rationale.contains(.streamsUnknown))
}
// MARK: - Die Bandbreitengrenze
@Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist")
func exceedingBitrateTranscodesWhenAllowed() {
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: 8_000))
#expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000)))
}
@Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt")
func exceedingBitrateStillPlaysInPhaseOne() {
// Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft
// auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme.
let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
policy: policy
)
#expect(decision.isDirectPlay)
#expect(decision.rationale.contains(.transcodingUnavailable))
}
@Test("Unter der Grenze bleibt alles beim Direktabspielen")
func withinBitrateLimitPlaysDirectly() {
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000),
policy: policy
)
#expect(decision.method == .directPlay)
}
/// Früher landete eine Datei mit DTS-Ton beim vollen Neurechnen — Bild
/// inbegriffen, obwohl mit dem Bild nichts war. Jetzt bleibt es beim
/// Umpacken, und nur der Ton wird angefasst.
@Test("Umpacken reicht, solange das Bild passt")
func remuxIsEnough() {
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "dts"),
policy: policy
)
#expect(decision.method == .remux)
}
/// Erst wenn auch das Bild nicht geht, muss wirklich gerechnet werden.
@Test("Der Transcoder übernimmt, sobald das Bild nicht passt")
func transcodingTakesOver() {
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "av1", audio: "aac"),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: nil))
}
@Test("Der Transcode-Weg läuft über AVPlayer")
func transcodingUsesAVPlayer() {
// HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive
// automatischem Bitratenwechsel.
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "av1", audio: "aac"),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: nil))
}
@Test("Eine gewählte Stufe wird umgerechnet, auch wenn die Datei darunter liegt")
func honoursAnExplicitChoice() {
// Ohne das verpuffte die Wahl im Player: wer für eine 8-Mbit-Datei
// „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja unter der
// Grenze liegt. Gemeint ist aber „rechne es mir auf diese Stufe um".
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
policy: PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: 12_000,
forcesTranscoding: true
)
)
#expect(decision.method == .transcode(maximumBitrate: 12_000))
#expect(decision.rationale.contains(.qualityChosen))
}
@Test("Ohne ausdrückliche Wahl bleibt es bei DirectPlay")
func leavesAutomaticAlone() {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 12_000)
)
#expect(decision.method == .directPlay)
}
}
@Suite("Umpacken statt neu rechnen")
struct RemuxDecisionTests {
private let decider = PlaybackDecider()
private var policy: PlaybackPolicy {
PlaybackPolicy(allowsTranscoding: true)
}
/// Der Blu-ray-Rip: Matroska aussen, lauter Dinge innen, die AVPlayer
/// bestens kann. Im Weg steht allein die Verpackung — und dafür gibt es
/// das Umpacken, und schon gar nicht den Transcoder.
@Test("Matroska mit brauchbarem Inhalt wird umgepackt")
func matroskaIsRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
}
/// Steht der Ton im Weg, bleibt das Bild trotzdem unberührt: der Server
/// kopiert es und rechnet genau die eine Tonspur um. Das ist der Grund,
/// warum DTS auf dem Apple TV nicht mehr das ganze Bild kostet.
@Test(
"Nicht unterstützter Ton wird umgepackt, nicht neu gerechnet",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioIsRemuxed(codec: String) {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: codec,
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
// Die Begründung ist das, woran die Oberfläche später erkennt, dass
// der Ton angefasst wurde — ohne sie stünde dort „ohne Neukodierung".
#expect(entscheidung.rationale.contains(.audioCodecUnsupported(codec)))
}
/// Eine MP4 mit DTS steht mit einem reinen Tonhindernis da. Sie an der
/// Liste umpackbarer Schachteln zu messen, hiesse sie an einer Frage
/// scheitern zu lassen, die sich gar nicht stellt.
@Test("Auch eine MP4 mit DTS wird umgepackt")
func mp4WithDTSIsRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mp4", videoCodec: "h264", audioCodec: "dts",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
}
/// Schachtel und Ton zugleich — der häufigste Fall bei einem Remux mit
/// Tonspur aus der Blu-ray.
@Test("Schachtel und Ton zusammen gehen auch")
func containerAndAudioTogether() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "h264", audioCodec: "truehd",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
#expect(entscheidung.rationale.contains(.containerUnsupported("mkv")))
#expect(entscheidung.rationale.contains(.audioCodecUnsupported("truehd")))
}
/// Der Gegenbeweis, und die Grenze: stimmt das **Bild** nicht, hilft kein
/// Durchreichen — dann müsste jede Bildzeile neu gerechnet werden, und
/// dafür ist dies nicht der Weg.
@Test("Bei falschem Bildcodec wird gerechnet")
func wrongVideoCodecIsNotRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "av1", audioCodec: "aac",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .transcode(maximumBitrate: nil))
}
/// Eine MP4, die AVPlayer ohnehin öffnet, wird nicht angefasst. Umpacken
/// wäre dort Arbeit ohne Wirkung.
@Test("Was schon passt, bleibt unverändert")
func mp4IsUntouched() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mp4", videoCodec: "h264", audioCodec: "aac",
bitrate: 8_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .directPlay)
}
/// Reicht die Leitung nicht, nützt Umpacken nichts: die Datei bliebe
/// gleich gross. Dann muss wirklich gerechnet werden.
@Test("Zu grosse Bitrate geht weiterhin an den Transcoder")
func bitrateStillTranscodes() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 80_000, hasKnownStreams: true
),
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
)
#expect(entscheidung.method == .transcode(maximumBitrate: 8_000))
}
/// Ohne Transcoder gibt es auch kein Umpacken — beides läuft über
/// denselben Weg im Server.
@Test("Ohne Serverhilfe bleibt nur der Versuch")
func withoutServerHelp() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 20_000, hasKnownStreams: true
),
policy: PlaybackPolicy(allowsTranscoding: false)
)
// Mehr als der Versuch bleibt nicht — aber der Grund steht dabei.
#expect(entscheidung.method == .directPlay)
#expect(entscheidung.rationale.contains(.transcodingUnavailable))
}
}
@Suite("Was der Systemplayer öffnet")
struct DeviceCapabilityTests {
private let kann = DeviceCapabilities.avFoundation
@Test("Eine MP4 mit H.264 und AAC geht")
func plainFilePasses() {
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "aac"))
}
@Test("Die Schachtel allein kann im Weg stehen")
func containerBlocks() {
// Inhaltlich tadellos, nur Matroska aussen — genau der Fall, für den
// ein Download umgepackt wird.
#expect(!kann.opens(container: "mkv", videoCodec: "h264", audioCodec: "ac3"))
}
@Test("Der Ton allein kann im Weg stehen")
func audioBlocks() {
#expect(!kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "dts"))
}
@Test("Grossschreibung ändert nichts")
func caseDoesNotMatter() {
#expect(kann.opens(container: "MP4", videoCodec: "H264", audioCodec: "AAC"))
}
@Test("Was der Server nicht nennt, gilt als passend")
func missingValuesPass() {
// Vorsichtshalber umzupacken hiesse, aus einer tadellosen Datei eine
// neu gerechnete zu machen.
#expect(kann.opens(container: nil, videoCodec: nil, audioCodec: nil))
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: ""))
}
}
@@ -0,0 +1,48 @@
import Testing
@testable import FlaroPlayback
/// Die gemerkte Qualitätsstufe über mehrere Filme hinweg.
///
/// Aufbewahrt wird eine Zahl, keine Sprosse: welche Sprossen es gibt, hängt
/// von der Quelle ab. Eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe — und
/// dann stillschweigend auf „automatisch" zurückzufallen wäre eine andere
/// Antwort als die, um die gebeten wurde.
@Suite("Gemerkte Qualität")
struct QualityMemoryTests {
@Test("Null heißt: nichts gemerkt")
func returnsNilForAutomatic() {
#expect(PlayerQuality.automatic.storageValue == 0)
#expect(PlayerQuality.restored(from: 0, forSourceHeight: 1080) == nil)
}
@Test("Original und Nur-Ton kommen unverändert zurück")
func restoresSpecialCases() {
#expect(PlayerQuality.restored(from: PlayerQuality.original.storageValue,
forSourceHeight: 1080) == .original)
#expect(PlayerQuality.restored(from: PlayerQuality.audioOnly.storageValue,
forSourceHeight: 1080) == .audioOnly)
}
@Test("Eine vorhandene Sprosse kommt genau zurück")
func restoresExactRung() {
let chosen = PlayerQuality.limited(.init(bitrate: 3_000, height: 720))
let restored = PlayerQuality.restored(from: chosen.storageValue, forSourceHeight: 1080)
#expect(restored == chosen)
}
@Test("Gibt es die Sprosse nicht, kommt die nächstgelegene")
func fallsBackToClosestRung() {
// Zwanzig Megabit gibt es nur für 1080p. Bei einer 480p-Quelle ist die
// beste erreichbare Stufe eine andere — aber nicht „gar keine".
let restored = PlayerQuality.restored(from: 20_000, forSourceHeight: 480)
guard case .limited(let rung) = restored else {
Issue.record("keine Stufe zurückbekommen")
return
}
#expect(rung.height <= 480)
}
}
@@ -0,0 +1,115 @@
import Testing
import Foundation
@testable import FlaroPlayback
@Suite("Rückler-Buchführung")
struct StallLedgerTests {
/// Der Fall, wegen dem es den Typ gibt.
///
/// Zehn Rückler à dreißig Sekunden lösten beim alten Watchdog nichts aus,
/// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar
/// werden, dass die Wiedergabe unbrauchbar ist.
@Test("Viele kurze Rückler bleiben sichtbar")
func manyShortStallsAreVisible() {
var ledger = StallLedger(window: .seconds(180))
// Alle 20 s ein Rückler von 5 s, drei Minuten lang.
for step in 1...9 {
ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5)))
}
let now = Duration.seconds(180)
#expect(ledger.count(at: now) == 9)
// 45 s Warten in 180 s Fenster.
#expect(ledger.share(at: now) > 0.2)
#expect(ledger.share(at: now) < 0.3)
}
@Test("Was aus dem Fenster fällt, zählt nicht mehr")
func oldEpisodesFallOut() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(10), length: .seconds(30)))
#expect(ledger.count(at: .seconds(60)) == 1)
// Weit hinter dem Fenster: vergessen.
#expect(ledger.count(at: .seconds(300)) == 0)
#expect(ledger.share(at: .seconds(300)) == 0)
}
/// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich
/// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus.
@Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster")
func earlyStallsAreNotDiluted() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(20), length: .seconds(5)))
let share = ledger.share(at: .seconds(20))
// 5 s von 20 s, nicht 5 s von 180 s.
#expect(share > 0.2)
#expect(share < 0.3)
}
@Test("Der laufende Rückler zählt mit")
func ongoingStallCounts() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(10)))
let without = ledger.share(at: .seconds(180))
let with = ledger.share(at: .seconds(180), ongoing: .seconds(20))
#expect(with > without)
}
/// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze.
@Test("Der längste Einzelfall lässt sich getrennt ablesen")
func longestIsSeparate() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(30), length: .seconds(2)))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(18)))
ledger.record(.init(endedAt: .seconds(90), length: .seconds(3)))
#expect(ledger.longest(at: .seconds(120)) == .seconds(18))
#expect(ledger.count(at: .seconds(120)) == 3)
}
@Test("Eine ruhige Wiedergabe meldet nichts")
func quietPlaybackIsQuiet() {
let ledger = StallLedger()
#expect(ledger.count(at: .seconds(600)) == 0)
#expect(ledger.share(at: .seconds(600)) == 0)
#expect(ledger.longest(at: .seconds(600)) == .zero)
}
@Test("Der Anteil übersteigt nie eins")
func shareIsBounded() {
var ledger = StallLedger(window: .seconds(60))
// Absurd: mehr Wartezeit als Fenster.
ledger.record(.init(endedAt: .seconds(30), length: .seconds(200)))
#expect(ledger.share(at: .seconds(60)) == 1)
}
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve für einen Neuaufbau.
@Test("Der letzte Rückler lässt sich datieren")
func findsTheLastStall() {
var buch = StallLedger(window: .seconds(180))
buch.record(.init(endedAt: .seconds(30), length: .seconds(2)))
buch.record(.init(endedAt: .seconds(90), length: .seconds(1)))
#expect(buch.lastStall(at: .seconds(100)) == .seconds(90))
}
/// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer
/// keinen hatte, wartet auf keine Frist.
@Test("Ohne Rückler gibt es kein Datum")
func noStallNoDate() {
let leer = StallLedger(window: .seconds(180))
#expect(leer.lastStall(at: .seconds(100)) == nil)
}
@Test("Was aus dem Fenster gefallen ist, zählt nicht mehr")
func forgetsOldStalls() {
var buch = StallLedger(window: .seconds(60))
buch.record(.init(endedAt: .seconds(10), length: .seconds(2)))
#expect(buch.lastStall(at: .seconds(200)) == nil)
}
}
@@ -0,0 +1,140 @@
import Testing
import Foundation
@testable import FlaroPlayback
@Suite("Zustand des Streams")
struct StreamHealthTests {
/// Baut eine Folge von Proben im Sekundentakt.
private func lauf(
_ health: inout StreamHealth,
von: Int,
bis: Int,
puffer: (Int) -> Double?,
stalls: (Int) -> Int = { _ in 0 },
keepUp: (Int) -> Bool? = { _ in true }
) -> StreamCondition {
var urteil = StreamCondition.healthy
for sekunde in von...bis {
urteil = health.record(StreamSample(
at: .seconds(sekunde),
bufferedAhead: puffer(sekunde).map { .milliseconds(Int($0 * 1000)) },
isLikelyToKeepUp: keepUp(sekunde),
stallCount: stalls(sekunde)
))
}
return urteil
}
@Test("Ein voller, stabiler Puffer ist gesund")
func fullBufferIsHealthy() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 60, puffer: { _ in 30 })
#expect(urteil == .healthy)
}
/// Der Fall, für den das Ganze gebaut ist: der Puffer schrumpft stetig.
/// Das muss auffallen, **bevor** er leer ist.
@Test("Ein schrumpfender Puffer meldet sich vor dem Rückler")
func shrinkingBufferWarnsEarly() {
var health = StreamHealth()
// Start mit 30 s, dann jede Sekunde eine weniger.
_ = lauf(&health, von: 0, bis: 10, puffer: { _ in 30 })
let urteil = lauf(&health, von: 11, bis: 36, puffer: { 30 - Double($0 - 10) })
#expect(urteil == .strained)
// Und zwar bei noch fünf Sekunden Puffer — nicht bei null.
#expect(health.condition != .failing)
}
@Test("Ein kleiner, aber stabiler Puffer ist kein Alarm")
func smallStableBufferIsNotAlarming() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 30, puffer: { _ in 4 })
// Klein, aber er hält. Das ist kein Grund, die Stufe zu senken.
#expect(urteil != .strained)
#expect(urteil != .failing)
}
@Test("Zwei Rückler in einer Minute heißen: trägt nicht")
func twoStallsMeanFailing() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 40,
puffer: { _ in 10 },
stalls: { $0 < 10 ? 0 : ($0 < 25 ? 1 : 2) }
)
#expect(urteil == .failing)
}
@Test("Ein leerer Puffer heißt sofort: trägt nicht")
func emptyBufferIsFailing() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(urteil == .failing)
}
/// „Noch nichts geladen" ist etwas anderes als „Puffer leer". Beim Start
/// darf das nicht als Notlage durchgehen.
@Test("Ohne Messwert kein Alarm")
func unknownBufferIsNotAlarming() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 10, puffer: { _ in nil })
#expect(urteil == .healthy)
}
/// Der Weg zurück ist absichtlich schmal: schnell runter, langsam rauf.
@Test("Zurück nach gesund braucht eine halbe Minute Ruhe")
func recoveryIsSlow() {
var health = StreamHealth()
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(health.condition == .failing)
// Puffer sofort wieder voll — aber zu kurz.
let früh = lauf(&health, von: 21, bis: 40, puffer: { _ in 30 })
#expect(früh != .healthy)
// Nach einer halben Minute durchgehend gut: erholt.
let spät = lauf(&health, von: 41, bis: 90, puffer: { _ in 30 })
#expect(spät == .healthy)
}
/// Ein einzelner Ausschlag von `isLikelyToKeepUp` kommt bei jedem Sprung
/// vor und darf nichts auslösen.
@Test("Ein einzelner Ausschlag löst nichts aus")
func singleKeepUpDipIsIgnored() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 30,
puffer: { _ in 25 },
keepUp: { $0 == 15 ? false : true }
)
#expect(urteil == .healthy)
}
@Test("Anhaltende Sorge des Systems zählt")
func sustainedKeepUpFalseCounts() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 30,
puffer: { _ in 25 },
keepUp: { $0 >= 27 ? false : true }
)
#expect(urteil == .strained)
}
@Test("Zurücksetzen macht einen frischen Anfang")
func resetStartsOver() {
var health = StreamHealth()
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(health.condition == .failing)
health.removeAll()
#expect(health.condition == .healthy)
}
}
@@ -0,0 +1,143 @@
import AVFoundation
import CoreMedia
import Foundation
import Testing
@testable import FlaroPlayback
@Suite("Aussehen der Untertitel")
struct SubtitleStyleTests {
private func attributes(_ style: SubtitleStyle) -> [String: Any]? {
style.textStyleRules.first?.textMarkupAttributes
}
private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? {
(attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue)
}
// MARK: - Dass überhaupt etwas herauskommt
/// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von
/// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt
/// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht
/// „keine Regel" genauso aus wie „Regel ohne Wirkung".
@Test("Jedes Paar ergibt genau eine Regel")
func everyPaletteProducesOneRule() {
for palette in SubtitleStyle.Palette.allCases {
for size in SubtitleStyle.Size.allCases {
let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules
#expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln")
}
}
}
// MARK: - Die Farben
/// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an
/// dieser Stelle, und einer ohne Fehlermeldung.
@Test("Die Farbe steht als A, R, G, B da")
func colourOrderIsARGB() {
let gelb = SubtitleStyle(palette: .yellowOnBlack)
let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB)
#expect(werte?.count == 4)
#expect(werte?[0] == 1) // Alpha
#expect(werte?[1] == 1) // Rot
#expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb
}
@Test("Alle Anteile liegen zwischen null und eins")
func coloursAreInRange() {
for palette in SubtitleStyle.Palette.allCases {
let stil = SubtitleStyle(palette: palette)
for schlüssel in [
kCMTextMarkupAttribute_ForegroundColorARGB,
kCMTextMarkupAttribute_BackgroundColorARGB,
] {
let werte = colour(stil, schlüssel) ?? []
#expect(werte.count == 4, "\(palette): \(werte.count) Anteile")
#expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)")
}
}
}
@Test("Weiss auf Schwarz ist weiss auf schwarz")
func whiteOnBlack() {
let stil = SubtitleStyle(palette: .whiteOnBlack)
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1])
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? []
#expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten
#expect(grund.dropFirst().allSatisfy { $0 == 0 })
}
@Test("Schwarz auf Weiss ist die Umkehrung")
func blackOnWhite() {
let stil = SubtitleStyle(palette: .blackOnWhite)
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0])
#expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5)
}
/// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel
/// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT
/// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne
/// Kasten" wirkungslos, und niemand wüsste warum.
@Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen")
func transparentIsStated() {
for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] {
let stil = SubtitleStyle(palette: palette)
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)
#expect(grund != nil, "\(palette): der Hintergrund fehlt ganz")
#expect(grund?.first == 0, "\(palette): Alpha ist nicht null")
}
}
// MARK: - Die Grösse
/// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und
/// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was
/// der Untertitel mitbringt.
@Test("Die Grösse ist ein Prozentwert, auch bei hundert")
func sizeIsAPercentage() {
for size in SubtitleStyle.Size.allCases {
let wert = attributes(SubtitleStyle(size: size))?[
kCMTextMarkupAttribute_RelativeFontSize as String
] as? Double
#expect(wert == Double(size.rawValue))
}
}
/// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu
/// setzen liesse sie sich streiten.
@Test("Die andere Grössenangabe bleibt weg")
func onlyOneSizeKey() {
let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:]
#expect(gesetzt[
kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String
] == nil)
}
// MARK: - Der Rand
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe.
@Test("Den Schatten gibt es nur ohne Kasten")
func edgeShadowOnlyWithoutBox() {
for palette in SubtitleStyle.Palette.allCases {
let stil = SubtitleStyle(palette: palette)
let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String]
#expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)")
}
}
// MARK: - Die Vorgabe
@Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse")
func defaults() {
let stil = SubtitleStyle()
#expect(stil.size == .normal)
#expect(stil.palette == .whiteOnClear)
#expect(stil.wantsEdgeShadow)
}
}
@@ -0,0 +1,115 @@
import Testing
import MediaKit
@testable import FlaroPlayback
@Suite("Was im Spurenmenü steht")
struct TrackMenuTests {
private func spur(
_ id: String,
kind: MediaStream.Kind = .audio,
sprache: String? = "Deutsch",
code: String? = "deu",
name: String? = nil,
codec: String? = "AC3"
) -> MediaStream {
MediaStream(
id: id, kind: kind, codec: codec, language: sprache, languageCode: code,
displayTitle: name, channels: nil, isSelected: false, isForced: false,
title: nil, isDefault: false, isHearingImpaired: false, isOriginal: false
)
}
// MARK: - Wann es einen Neuaufbau kostet
/// **Der Fall, der bisher nirgends stand.** Rechnet der Server um, liegt
/// im Stream genau eine Tonspur — jede andere Wahl verlangt eine neue
/// Bestellung und damit Sekunden mit schwarzem Bild.
@Test("Beim Umrechnen kostet jede Wahl einen Neuaufbau")
func transcodeAlwaysRebuilds() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2")],
selected: nil, isDirectPlay: false, playerTrackCount: 1
)
#expect(einträge.allSatisfy { $0.rebuilds })
}
@Test("Unverändert gespielt und gleiche Anzahl: kein Neuaufbau")
func directPlayMatchingCountIsCheap() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2")],
selected: nil, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge.allSatisfy { !$0.rebuilds })
}
/// **Die Regel aus `PlayerModel`, zum ersten Mal prüfbar.** Die Zuordnung
/// geht über die Reihenfolge; stimmt die Anzahl nicht, ist die Annahme
/// falsch, und es wäre die falsche Sprache statt einer Unterbrechung.
/// Auslösen liess sich das bisher nur an einem echten Server.
@Test("Abweichende Anzahl heißt Neuaufbau, auch unverändert gespielt")
func directPlayMismatchRebuilds() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2"), spur("3")],
selected: nil, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge.allSatisfy { $0.rebuilds })
}
// MARK: - Untertitel
@Test("„Aus“ steht vorn und kostet nie einen Neuaufbau")
func offComesFirstAndIsFree() {
let einträge = TrackMenu.subtitles(
from: [spur("1", kind: .subtitle)],
selected: nil, isDirectPlay: false, offLabel: "Aus"
)
#expect(einträge.first?.id == nil)
#expect(einträge.first?.title == "Aus")
#expect(einträge.first?.isSelected == true)
#expect(einträge.first?.rebuilds == false)
}
/// Nur was der Player wirklich hat, lässt sich ohne Neuaufbau schalten.
@Test("Eine Untertitelspur, die der Player nicht hat, kostet einen Neuaufbau")
func unmappableSubtitleRebuilds() {
let deutsch = spur("1", kind: .subtitle, sprache: "Deutsch", code: "deu")
let englisch = spur("2", kind: .subtitle, sprache: "English", code: "eng")
let einträge = TrackMenu.subtitles(
from: [deutsch, englisch],
selected: nil, isDirectPlay: true,
playerTrackLanguages: ["de"], offLabel: "Aus"
)
// „deu" und „de" sind dieselbe Sprache — das entscheidet LanguageCode.
#expect(einträge[1].rebuilds == false)
#expect(einträge[2].rebuilds == true)
}
// MARK: - Beschriftung
@Test("Der Name des Servers gewinnt, sonst Sprache und Codec")
func fallsBackToLanguageAndCodec() {
let mitNamen = spur("1", name: "Deutsch (AC3 5.1)")
let ohneNamen = spur("2", sprache: "English", codec: "eac3")
let einträge = TrackMenu.audio(
from: [mitNamen, ohneNamen],
selected: mitNamen, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge[0].title == "Deutsch (AC3 5.1)")
#expect(einträge[1].title == "English · EAC3")
#expect(einträge[0].isSelected)
#expect(!einträge[1].isSelected)
}
/// Ohne alles bleibt ein Strich — besser als eine leere Zeile, bei der
/// niemand weiss, ob sie anklickbar ist.
@Test("Ohne Sprache und Codec bleibt ein Strich")
func namelessStreamStillHasALabel() {
let leer = spur("7", sprache: nil, code: nil, codec: nil)
let einträge = TrackMenu.audio(
from: [leer], selected: nil, isDirectPlay: true, playerTrackCount: 1
)
#expect(einträge[0].title == "—")
#expect(einträge[0].id == 7)
}
}
@@ -0,0 +1,143 @@
import Foundation
import Testing
@testable import FlaroPlayback
@Suite("Wann hinaufgeschaltet wird")
struct UpgradeRuleTests {
/// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in
/// genau einem Punkt ab. So steht in jedem Test nur der Unterschied.
private func ruhig(
isAutomatic: Bool = true,
condition: StreamCondition = .healthy,
hasHigherRung: Bool = false,
ceilingAllowsMore: Bool = true,
bufferedAhead: Duration? = .seconds(30),
playedFor: Duration = .seconds(120),
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> UpgradeRule.Facts {
.init(
isAutomatic: isAutomatic,
condition: condition,
hasHigherRung: hasHigherRung,
ceilingAllowsMore: ceilingAllowsMore,
bufferedAhead: bufferedAhead,
playedFor: playedFor,
sinceLastStall: sinceLastStall,
sinceLastSeek: sinceLastSeek,
sinceLastUpgrade: sinceLastUpgrade,
used: used,
budget: budget
)
}
@Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut")
func calmAndCappedRebuilds() {
#expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher)
}
// MARK: - Was es verbietet
/// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr
/// trüge.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic)
}
/// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat,
/// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre
/// Verschwendung.
@Test("Solange die Leiter trägt, kein Neuaufbau")
func prefersTheCheapPath() {
#expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains)
}
@Test("Über die eingestellte Grenze geht es nicht")
func respectsTheSetting() {
#expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling)
}
@Test("Bei angespannter Lage wird nicht hinaufgegangen")
func notWhenStrained() {
#expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough)
}
/// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante
/// liegen, sonst trägt es den Neuaufbau nicht.
@Test("Ohne Vorrat kein Neuaufbau")
func needsABuffer() {
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough)
}
/// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe
/// Minute lang nach einer hervorragenden Leitung aus.
@Test("Am Anfang nicht, auch wenn alles gut aussieht")
func notRightAfterStart() {
#expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly)
}
@Test("Nach einem Sprung nicht")
func notRightAfterSeek() {
#expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly)
}
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre
/// die zweite Unterbrechung binnen einer Minute.
@Test("Nach einem Rückler lange nicht")
func waitsAfterAStall() {
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly)
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher)
}
@Test("Die Sperrfrist gilt")
func respectsTheCooldown() {
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling)
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher)
}
@Test("Das Budget ist endlich")
func budgetIsFinite() {
#expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent)
#expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher)
}
// MARK: - Die Reihenfolge der Gründe
/// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden —
/// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung
/// das Ende ist, und jemand wartet auf etwas, das nie kommt.
@Test("Der grundsätzlichste Grund gewinnt")
func reportsTheMostFundamentalReason() {
#expect(
UpgradeRule.verdict(ruhig(
isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false
)) == .notAutomatic
)
#expect(
UpgradeRule.verdict(ruhig(
hasHigherRung: true, ceilingAllowsMore: false
)) == .atCeiling
)
#expect(
UpgradeRule.verdict(ruhig(
condition: .failing, hasHigherRung: true
)) == .cheaperPathRemains
)
}
/// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu
/// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein
/// etwas weicheres Bild.
@Test("Zurückhaltender als das Absenken")
func moreCautiousThanDowngrading() {
#expect(UpgradeRule.settleIn > DowngradeRule.settleIn)
#expect(UpgradeRule.cooldown < DowngradeRule.cooldown)
}
}