import Testing import MediaKit @testable import HaloPlayback /// 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: "")) } }