Files
Flaro/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift
T
ScarriffleandClaude Opus 5 62691fc2bc Matroska geht jetzt an AVPlayer, nicht mehr an VLC
Ein Blu-ray-Rip liegt als Matroska vor, und AVFoundation oeffnet Matroska
nicht — obwohl das Bild darin H.264 oder HEVC ist und der Ton AC3, also
alles, was AVPlayer bestens kann. Im Weg stand allein die Verpackung.

Dafuer gibt es jetzt einen dritten Weg neben Durchreichen und Neurechnen:
umpacken. Dieselben Stroeme, Byte fuer Byte, in eine MP4. Kein Bild wird
angefasst, es geht nichts an Qualitaet verloren, und den Server kostet es
fast nichts.

Entscheidend ist dabei `Protocol: http` statt `hls`. Eine Abspielliste
laesst sich nicht vorausladen — AVPlayer holt Segment fuer Segment, und
Apple laesst uns dazwischen nicht eingreifen. Eine fortlaufende Datei kommt
ueber gewoehnliche Bereichsanfragen, und die kann man so weit vorausholen,
wie man will. Der Vorratswunsch steht dort deshalb bei zehn Minuten statt
bei einer.

Der Gewinn ist gross: solche Titel bekommen Bild-in-Bild, AirPlay und die
Systembedienung, die es bei VLC nicht gibt.

Umgepackt wird nur, wenn **allein** die Schachtel im Weg steht. Stimmt auch
nur ein Codec nicht — DTS, TrueHD, AV1 —, fehlt nicht der Zugang zur Datei,
sondern das Koennen; dann bleibt es bei VLC. Sechs Tests halten die Grenze
fest, in beide Richtungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 20:00:44 +02:00

316 lines
12 KiB
Swift

import Testing
import MediaKit
@testable import FlixyPlayback
/// 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(engine: .avPlayer))
#expect(decision.rationale.contains(.nativelySupported))
}
// MARK: - Was VLC übernehmen muss
@Test("Matroska geht an VLC, auch bei nativen Codecs")
func matroskaFallsBackToVLC() {
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
}
@Test(
"Nicht unterstützter Ton geht an VLC",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioFallsBackToVLC(codec: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: codec),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
}
@Test(
"Nicht unterstütztes Bild geht an VLC",
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
)
func unsupportedVideoFallsBackToVLC(codec: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: codec, audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
}
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
func decisionFollowsSelectedAudioTrack() {
// Deutsches AAC ist ausgewählt, englisches DTS liegt daneben. Würde
// der Decider nur die erste Tonspur ansehen, ginge diese Datei
// unnötig an VLC — und damit ohne Bild-in-Bild.
// 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(engine: .avPlayer))
}
@Test("Unanalysierte Dateien gehen an den toleranteren Engine")
func unknownStreamsFallBackToVLC() {
// Der Server hat die Datei noch nicht untersucht: keine Spuren, kein
// bekannter Codec. Dann ist Raten die falsche Antwort.
let decision = PlaybackDecider().decide(
profile: MediaProfile(container: "mp4", hasKnownStreams: false),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay(engine: .vlc))
#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(engine: .avPlayer))
}
@Test("Ohne VLC übernimmt der Transcoder")
func withoutFallbackEngineTranscodingTakesOver() {
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "dts"),
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. Deshalb darf VLC hier nicht greifen.
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "dts"),
policy: policy
)
#expect(decision.engine == .avPlayer)
}
@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,
allowsFallbackEngine: false,
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(engine: .avPlayer))
}
}
@Suite("Umpacken statt neu rechnen")
struct RemuxDecisionTests {
private let decider = PlaybackDecider()
private var policy: PlaybackPolicy {
PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: 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, nicht VLC 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)
// Und damit AVPlayer: Bild-in-Bild, AirPlay, weites Vorausladen.
#expect(entscheidung.engine == .avPlayer)
}
/// Der Gegenbeweis. Stimmt der Ton nicht, fehlt nicht der Zugang zur
/// Datei, sondern das Können — dann hilft eine andere Schachtel nichts.
@Test("Bei DTS hilft Umpacken nicht")
func dtsStillGoesToVLC() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "dts",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .directPlay(engine: .vlc))
}
@Test("Bei AV1 ebenso wenig")
func av1StillGoesToVLC() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "av1", audioCodec: "aac",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .directPlay(engine: .vlc))
}
/// 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(engine: .avPlayer))
}
/// 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, allowsFallbackEngine: 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 es bei VLC")
func withoutServerHelpVLC() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 20_000, hasKnownStreams: true
),
policy: PlaybackPolicy(
allowsTranscoding: false, allowsFallbackEngine: true
)
)
#expect(entscheidung.method == .directPlay(engine: .vlc))
}
}