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