diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index c38232d..4fe93e3 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -1,5 +1,6 @@ import Foundation import FlixyDownloads +import FlixyPlayback import MediaKit /// Übersetzt Titel vom Server in Download-Aufträge. @@ -105,8 +106,18 @@ struct DownloadService { guard let part = media.files.first else { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart")) } + // **Was offline liegt, muss der Systemplayer öffnen können.** Online + // springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es + // hinterher niemanden mehr, der das nachholen könnte — und seit es + // keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und + // schwarz. Also wird schon beim Herunterladen umgepackt. + let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part) + let url: URL? switch quality { + case .original where umpacken: + url = backend.remuxDownloadURL(itemID: item.id, versionIndex: mediaIndex) + ?? backend.downloadURL(for: part) case .original: url = backend.downloadURL(for: part) case .fullHD, .hd, .low: @@ -143,8 +154,11 @@ struct DownloadService { // den Metadaten des Servers. Die umgerechneten Stufen sind aus // Bitrate und Dauer hochgerechnet — und was hochgerechnet ist, // darf abweichen, ohne dass die Datei kaputt wäre. - expectedIsExact: quality == .original && part.size != nil, - offlineMedia: offlineMedia(for: item, media: media, part: part) + // Umgepackt stimmt die Größe des Servers nicht mehr: dieselben + // Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder + // eine Schätzung — und die weiche Prüfung die richtige. + expectedIsExact: quality == .original && !umpacken && part.size != nil, + offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken) ) } @@ -239,11 +253,32 @@ struct DownloadService { } /// Der Abzug der Serverangaben, den der Player offline braucht. - private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia { - OfflineMedia( + /// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet. + private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool { + DeviceCapabilities.avFoundation.opens( container: part.container ?? media.container, videoCodec: media.videoCodec, - audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec, + audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec + ) + } + + private func offlineMedia( + for item: MediaItem, + media: MediaVersion, + part: MediaFile, + remuxed: Bool + ) -> OfflineMedia { + OfflineMedia( + // Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde + // hier weiter die alte Schachtel, entschiede der Player anhand + // einer Datei, die es so nicht mehr gibt. + container: remuxed ? "mp4" : (part.container ?? media.container), + videoCodec: media.videoCodec, + // **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur + // um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird, + // sagt er vorher nicht. Eine Behauptung wäre schlechter als die + // Lücke: der Player liest die Spuren ohnehin aus der Datei. + audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec), videoResolution: media.videoResolution, bitrate: media.bitrate, audioLanguages: part.audioStreams.compactMap(\.language), diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift index d8ee472..191d2e9 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift @@ -39,4 +39,25 @@ public struct DeviceCapabilities: Sendable, Equatable { /// 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"] + + /// Ob diese Datei so, wie sie ist, geöffnet werden kann. + /// + /// **Hier und nicht beim Aufrufer**, weil es die Frage inzwischen zweimal + /// gibt: beim Abspielen entscheidet sie `PlaybackDecider`, beim + /// Herunterladen muss sie vorher beantwortet sein — was offline liegt, + /// kann hinterher niemand mehr umpacken. + /// + /// **Was fehlt, zählt als passend.** Nennt der Server einen Codec nicht, + /// ist das keine Aussage über seine Untauglichkeit. Vorsichtshalber + /// umzupacken hiesse, aus einer tadellosen MP4 eine neu gerechnete Datei + /// zu machen. + public func opens(container: String?, videoCodec: String?, audioCodec: String?) -> Bool { + func passt(_ wert: String?, in liste: Set) -> Bool { + guard let wert, !wert.isEmpty else { return true } + return liste.contains(wert.lowercased()) + } + return passt(container, in: containers) + && passt(videoCodec, in: videoCodecs) + && passt(audioCodec, in: audioCodecs) + } } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index b840ac1..095fbba 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -378,6 +378,37 @@ public func isInLocalNetwork() async -> Bool { ) } + /// Dieselbe Datei in einer MP4-Hülle. + /// + /// **Für Downloads, die sonst offline unspielbar wären.** Eine Matroska + /// mit H.264 und AC3 ist inhaltlich tadellos — nur die Schachtel öffnet + /// AVFoundation nicht. Online hilft derselbe Weg schon + /// (`PlaybackDecision.remux`); auf dem Gerät gibt es hinterher niemanden + /// mehr, der umpacken könnte. Also beim Herunterladen. + /// + /// `allowVideoStreamCopy`/`allowAudioStreamCopy` sind der ganze Punkt: der + /// Server soll die Ströme **durchreichen**, wenn sie in die Liste passen, + /// und nur umrechnen, was nicht passt. Das Bild bleibt damit das Original; + /// nur DTS oder TrueHD werden zu AC3. + /// + /// NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren + /// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die + /// Dateigröße verrät es. + public nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? { + client.url( + path: "/Videos/\(itemID)/stream.mp4", + query: [ + URLQueryItem(name: "videoCodec", value: "h264,hevc"), + URLQueryItem(name: "audioCodec", value: "aac,ac3,eac3"), + URLQueryItem(name: "allowVideoStreamCopy", value: "true"), + URLQueryItem(name: "allowAudioStreamCopy", value: "true"), + URLQueryItem(name: "TranscodingContainer", value: "mp4"), + URLQueryItem(name: "TranscodingMaxAudioChannels", value: "6"), + ], + includingToken: account.token + ) + } + /// Eine verkleinerte Fassung zum Herunterladen. /// /// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 26a82dc..4e1d682 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -89,6 +89,17 @@ public protocol MediaBackend: Sendable { resolution: String? ) -> URL? + /// Die Adresse für dieselbe Datei in einer **anderen Schachtel**. + /// + /// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle, + /// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst + /// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der + /// nachträglich helfen könnte. + /// + /// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original, + /// und der Titel ist offline nur so gut wie der Systemplayer. + nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? + // MARK: - Abspielen /// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse. @@ -178,6 +189,12 @@ public struct MediaPage: Sendable, Equatable { } public extension MediaBackend { + /// Vorgabe: der Server kann es nicht. + /// + /// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht + /// gibt — und wer es anbietet, sagt es ausdrücklich. + nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? { nil } + /// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen. /// /// Langsam, aber überall gültig — wer es besser kann, überschreibt es. diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift index a0955a4..8d893bb 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -375,3 +375,38 @@ struct RemuxDecisionTests { #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: "")) + } +}