diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 082a0e9..b1c0f5b 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -6602,6 +6602,23 @@ } } }, + "technical.remux": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Umgepackt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remuxed" + } + } + } + }, "technical.resolution": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/PlaybackDiagnostics.swift b/Flixy/Session/PlaybackDiagnostics.swift index 61a3767..702520a 100644 --- a/Flixy/Session/PlaybackDiagnostics.swift +++ b/Flixy/Session/PlaybackDiagnostics.swift @@ -70,6 +70,7 @@ final class PlaybackDiagnostics { case .avPlayer: "Direkt · AVPlayer" case .vlc: "Direkt · VLC" } + case .remux: "Umgepackt · MP4, ohne Neukodierung" case .transcode(let limit): limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode" } diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift index 2f63a59..951e008 100644 --- a/Flixy/Views/Detail/TechnicalDetails.swift +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -101,6 +101,7 @@ struct TechnicalDetails: View { case .avPlayer: String(localized: "technical.directPlayNative") case .vlc: String(localized: "technical.directPlayVLC") } + case .remux: String(localized: "technical.remux") case .transcode: String(localized: "technical.transcode") } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 5f856b5..2741902 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -240,8 +240,12 @@ final class PlayerModel { /// Durchreichen ist das verschmerzbar: die Datei wird ohnehin als Ganzes /// geholt, wenn man sie zu Ende sieht. private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration { - if case .directPlay = method { return .seconds(600) } - return .seconds(60) + switch method { + // Beides eine fortlaufende Datei über Bereichsanfragen — da lässt sich + // vorausladen, so weit man will. + case .directPlay, .remux: .seconds(600) + case .transcode: .seconds(60) + } } /// Wie oft der Zustand des Streams geprüft wird. @@ -678,6 +682,11 @@ final class PlayerModel { let transcode: TranscodeSettings? = switch decision.method { case .directPlay: nil + // Umpacken heisst: keine Grenze, keine Auflösung, keine Leiter. Die + // Ströme sollen unverändert durchgehen — jede Angabe hier wäre eine + // Aufforderung, doch zu rechnen. + case .remux: + TranscodeSettings(maximumBitrate: nil, resolution: nil, prefersRemux: true) case .transcode(let maximumBitrate): TranscodeSettings( maximumBitrate: maximumBitrate, diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift index 09e2430..d8ee472 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift @@ -32,4 +32,11 @@ public struct DeviceCapabilities: Sendable, Equatable { // tadelloser MP4-Film trotzdem stumm bliebe. audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"] ) + + /// Schachteln, die sich verlustfrei in eine MP4 umpacken lassen. + /// + /// Nicht jede: ein Container kann Dinge enthalten, für die MP4 keinen + /// Platz hat. Diese drei sind die, die in echten Beständen vorkommen und + /// deren Ströme regelmässig MP4-tauglich sind. + public static let remuxableContainers: Set = ["mkv", "matroska", "ts", "m2ts", "avi"] } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift index 6306e98..9ffccf1 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift @@ -70,6 +70,27 @@ public struct PlaybackDecider: Sendable { guard blockers.isEmpty else { rationale.append(contentsOf: blockers) + + // **Steht nur die Schachtel im Weg, wird umgepackt.** + // + // Genau das ist der Blu-ray-Rip: Matroska aussen, H.264 oder HEVC + // innen, AC3 dazu. AVPlayer könnte jeden dieser Ströme — er kommt + // nur nicht an sie heran. Umpacken kostet den Server fast nichts + // und verliert nichts, und der Titel landet danach bei AVPlayer + // statt bei VLC: mit Bild-in-Bild, AirPlay und einer Datei, die + // sich weit vorausladen lässt. + // + // Stimmt dagegen auch nur ein Codec nicht, hilft Umpacken nicht — + // dann fehlt nicht der Zugang, sondern das Können. + let nurDieSchachtel = blockers.allSatisfy { + if case .containerUnsupported = $0 { return true } + return false + } + if nurDieSchachtel, policy.allowsTranscoding, + let container, DeviceCapabilities.remuxableContainers.contains(container) { + return PlaybackDecision(method: .remux, rationale: rationale) + } + return fallback(policy: policy, rationale: rationale) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift index e9977aa..cf55f7f 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift @@ -14,6 +14,23 @@ public struct PlaybackDecision: Sendable, Equatable { public enum Method: Sendable, Equatable { /// Die Datei wird unverändert übertragen und lokal dekodiert. case directPlay(engine: PlaybackEngineKind) + /// Der Server legt dieselben Ströme in eine andere Schachtel. + /// + /// **Der dritte Weg, und der wichtigste für diesen Bestand.** Ein + /// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet + /// Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der + /// Ton AAC oder AC3, also alles, was AVPlayer bestens kann. Im Weg + /// steht allein die Verpackung. + /// + /// Umpacken heisst: dieselben Ströme, Byte für Byte, in eine MP4. Kein + /// Bild wird angefasst, es geht **nichts** an Qualität verloren, und + /// den Server kostet es fast nichts — anders als beim Neurechnen. + /// + /// Der Gewinn ist gross: solche Titel landen nicht mehr bei VLC, + /// sondern bei AVPlayer. Damit gibt es dort Bild-in-Bild, AirPlay, + /// die Systembedienung — und eine fortlaufende Datei, die sich + /// beliebig weit vorausladen lässt. + case remux /// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit. case transcode(maximumBitrate: Int?) } @@ -52,6 +69,8 @@ public struct PlaybackDecision: Sendable, Equatable { // automatischem Bitratenwechsel": den gibt es nur, wenn die Playlist // mehrere Stufen führt, und das muss ausdrücklich bestellt werden. case .transcode: .avPlayer + // Umgepackt kommt eine MP4 heraus — genau das, wofür es AVPlayer gibt. + case .remux: .avPlayer } } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index b2e3386..b42d538 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -41,7 +41,8 @@ extension JellyfinBackend { sourceID: sourceID, transcode: transcode, audioStreamIndex: intent.audioStreamIndex, - subtitleStreamIndex: intent.subtitleStreamIndex + subtitleStreamIndex: intent.subtitleStreamIndex, + remux: transcode.prefersRemux ) let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first @@ -59,7 +60,12 @@ extension JellyfinBackend { } var adresse = Self.limiting(height: transcode.resolution, in: url) - if transcode.prefersAdaptive { adresse = Self.adaptive(adresse) } + // Beim Umpacken **keine** Leiter: es gibt nur eine Fassung, nämlich + // die Datei selbst. Und `master.m3u8` wäre ohnehin das Gegenteil + // dessen, worum es geht — eine Abspielliste statt einer Datei. + if transcode.prefersAdaptive, !transcode.prefersRemux { + adresse = Self.adaptive(adresse) + } return PlaybackTarget( url: adresse, @@ -123,18 +129,40 @@ extension JellyfinBackend { sourceID: String, transcode: TranscodeSettings, audioStreamIndex: Int? = nil, - subtitleStreamIndex: Int? = nil + subtitleStreamIndex: Int? = nil, + remux: Bool = false ) async throws(MediaBackendError) -> JellyfinPlaybackInfo { // Jellyfin rechnet in Bit, die App in Kilobit. let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000 + // **Umpacken: eine fortlaufende MP4, und die Ströme unverändert.** + // + // `Protocol: http` statt `hls` ist der entscheidende Unterschied. Eine + // Abspielliste lässt sich nicht vorausladen — AVPlayer holt Segment + // für Segment und Apple lässt uns dazwischen nicht eingreifen. Eine + // fortlaufende Datei kommt dagegen über gewöhnliche Bereichsanfragen, + // und die kann man so weit vorausholen, wie man will. + // + // Die Codecs stehen genau so breit wie das, was AVPlayer wirklich + // kann: trifft der Server eine Übereinstimmung, kopiert er den Strom, + // statt ihn neu zu rechnen. Genau dafür ist der Weg da. + let umpacken: [String: Any] = [ + "Type": "Video", + "Container": "mp4", + "Protocol": "http", + "VideoCodec": "h264,hevc", + "AudioCodec": "aac,ac3,eac3,mp3", + "Context": "Streaming", + "MaxAudioChannels": "8", + ] + let profile: [String: Any] = [ "MaxStreamingBitrate": bitsPerSecond, // Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits // entschieden, dass umgerechnet werden soll — dem Server die Wahl // zu lassen hieße, die Entscheidung zweimal zu treffen. "DirectPlayProfiles": [], - "TranscodingProfiles": [[ + "TranscodingProfiles": remux ? [umpacken] : [[ "Type": "Video", "Container": "ts", "Protocol": "hls", diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift index 7b50261..e4a0d62 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift @@ -76,11 +76,25 @@ public struct TranscodeSettings: Sendable, Equatable { /// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger" /// unter unverändertem Etikett. public let prefersAdaptive: Bool + /// Nur umpacken, nicht neu rechnen. + /// + /// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine + /// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig: + /// kopieren verliert nichts und kostet den Server fast nichts, und eine + /// fortlaufende Datei lässt sich beliebig weit vorausladen — eine + /// Abspielliste nicht. + public let prefersRemux: Bool - public init(maximumBitrate: Int?, resolution: String?, prefersAdaptive: Bool = false) { + public init( + maximumBitrate: Int?, + resolution: String?, + prefersAdaptive: Bool = false, + prefersRemux: Bool = false + ) { self.maximumBitrate = maximumBitrate self.resolution = resolution self.prefersAdaptive = prefersAdaptive + self.prefersRemux = prefersRemux } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift index df185b5..9041d7c 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -215,3 +215,101 @@ struct PlaybackDeciderTests { #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)) + } +}