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>
409 lines
16 KiB
Swift
409 lines
16 KiB
Swift
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: ""))
|
|
}
|
|
}
|