**Vorbereitung dafür, dass VLC verschwindet.** Online ist der Fall längst
gelöst: steht nur die Schachtel im Weg, packt der Server um — dieselben
Ströme, eine MP4-Hülle. Offline gibt es diesen Ausweg nicht. Was einmal als
Matroska auf dem Gerät liegt, kann hinterher niemand mehr umpacken, und ohne
zweite Engine bliebe die Datei schwarz.
Also entscheidet sich das beim Herunterladen. Fragt jemand das Original an und
AVFoundation könnte die Datei nicht öffnen, wird `remuxDownloadURL` benutzt:
`/Videos/{id}/stream.mp4` mit `allowVideoStreamCopy` und
`allowAudioStreamCopy`. Der Server reicht durch, was passt, und rechnet nur
um, was nicht passt — das Bild bleibt das Original, nur DTS oder TrueHD
werden zu AC3 oder AAC.
Drei Dinge hängen daran und sind mitgezogen:
- Die **Größe** ist dann keine Zusage mehr: dieselben Ströme in einer anderen
Hülle wiegen anders. `expectedIsExact` fällt auf falsch, und es gilt wieder
die weiche Prüfung.
- Der **Container** im Offline-Abzug ist „mp4", nicht die alte Schachtel.
Sonst entschiede der Player anhand einer Datei, die es so nicht mehr gibt.
- Der **Tonkodierer** bleibt leer. Ob aus DTS dann AAC oder AC3 wird, sagt der
Server vorher nicht; eine Behauptung wäre schlechter als die Lücke, und die
Spuren liest der Player ohnehin aus der Datei.
**Die Frage „kann das der Systemplayer?" steht jetzt an einer Stelle.**
`DeviceCapabilities.opens(container:videoCodec:audioCodec:)` — dieselbe Liste,
nach der auch beim Abspielen entschieden wird. Was der Server nicht nennt,
gilt als passend: vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4
eine neu gerechnete Datei zu machen. Fünf Tests.
NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren hier
tatsächlich wählt, verrät erst die Dateigröße des ersten Downloads.
140 Tests in FlixyPlayback, iOS und tvOS bauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
413 lines
16 KiB
Swift
413 lines
16 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")))
|
|
}
|
|
|
|
/// **Nur ohne Serverhilfe.** Darf umgepackt werden, geht dieselbe Datei
|
|
/// an AVPlayer — siehe `RemuxDecisionTests`. Hier ist Umrechnen verboten,
|
|
/// und dann bleibt VLC der einzige Weg.
|
|
@Test(
|
|
"Nicht unterstützter Ton geht ohne Transcoder 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))
|
|
}
|
|
|
|
/// Auf dem Apple TV gibt es VLC nicht. Früher landete eine Datei mit
|
|
/// DTS-Ton deshalb beim vollen Neurechnen — Bild inbegriffen, obwohl mit
|
|
/// dem Bild nichts war. Jetzt bleibt es beim Umpacken, und nur der Ton
|
|
/// wird angefasst.
|
|
@Test("Ohne VLC reicht Umpacken, solange das Bild passt")
|
|
func withoutFallbackEngineRemuxIsEnough() {
|
|
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
|
|
|
|
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("Ohne VLC übernimmt der Transcoder, sobald das Bild nicht passt")
|
|
func withoutFallbackEngineTranscodingTakesOver() {
|
|
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
|
|
|
|
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. Deshalb darf VLC hier nicht greifen.
|
|
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
|
|
|
|
let decision = PlaybackDecider().decide(
|
|
profile: profile(container: "mkv", video: "av1", audio: "aac"),
|
|
policy: policy
|
|
)
|
|
#expect(decision.method == .transcode(maximumBitrate: nil))
|
|
#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)
|
|
}
|
|
|
|
/// 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 an VLC gegeben",
|
|
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)
|
|
#expect(entscheidung.engine == .avPlayer)
|
|
// 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 bleibt es beim Rückfall")
|
|
func wrongVideoCodecIsNotRemuxed() {
|
|
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))
|
|
}
|
|
}
|
|
|
|
@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: ""))
|
|
}
|
|
}
|