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)) } }