From a432592271f51f7122f688e39d2652e10bfa1c8c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 3 Oct 2026 08:32:41 +0200 Subject: [PATCH] =?UTF-8?q?Beim=20Umpacken=20f=C3=A4llt=20die=20Spurwahl?= =?UTF-8?q?=20an=20=E2=80=94=20also=20wird=20sie=20dort=20getroffen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dein Befund an „Notruf Hafenkante": keine Spuren, nichts auswählbar. Die technischen Angaben derselben Folge sagen, warum: Container MKV Tonspur AAC - Stereo - Standard Tonspur Deutsch - German - AAC - Stereo - Standard Untertitel German - Standard - SUBRIP Die Quelle ist Matroska. AVFoundation öffnet das nicht, also lief der Download bisher über die zweite Engine — und die meldete die Spurliste leer. Offline wählen ging damit nie. **Umpacken allein hätte es nicht gelöst.** Jellyfin bildet in die neue Hülle genau **eine** Tonspur ab und übernimmt den Untertitel nur, wenn man ihn nennt. Ohne Angabe nimmt der Server seine eigene Vorgabe — und das ist bei deutschen Aufnahmen regelmäßig die Spur mit Audiodeskription. Also fällt die Wahl jetzt beim Herunterladen, mit derselben Abwägung wie beim Abspielen (`AudioTrackChooser`, der für genau diesen Fall gebaut wurde: die als deutsch ausgezeichnete Spur ist die mit Audiodeskription). Der Untertitel geht als `subtitleMethod=Embed` mit — als **Spur**, nicht ins Bild gebrannt, sonst liesse er sich nicht abschalten. Die Mediathek bleibt bei der Abwägung aussen vor: sie nachzuschlagen kostet eine Anfrage je Folge, und Serie und allgemeine Einstellung tragen den Wunsch ohnehin. Der Offline-Abzug behauptet nicht mehr, alle Sprachen seien dabei — beim Umpacken bleibt je eine übrig, und alle zu notieren wäre ein Versprechen, das die Datei nicht hält. **Nebenbei richtiggestellt:** die technischen Angaben rechneten mit `.directPlayOnly` und zeigten deshalb „Direkt (Systemplayer)" direkt über „iOS öffnet den Container MKV nicht" — zwei Angaben, die sich widersprechen. Das war früher richtig (ohne Serverhilfe übernahm die zweite Engine und hiess auch so); jetzt wird mit Serverhilfe gerechnet. NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT. iOS und tvOS bauen, 494 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Session/DownloadService.swift | 37 ++++++++++++++++-- Flixy/Views/Detail/SeriesDetailView.swift | 5 ++- Flixy/Views/Detail/TechnicalDetails.swift | 16 +++++++- Flixy/Views/Downloads/DownloadButton.swift | 10 ++++- .../Sources/JellyfinKit/JellyfinBackend.swift | 39 ++++++++++++++----- .../Sources/MediaKit/MediaBackend.swift | 24 +++++++++++- 6 files changed, 111 insertions(+), 20 deletions(-) diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index 4fe93e3..e363730 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -82,6 +82,16 @@ enum DownloadQuality: String, CaseIterable, Identifiable { struct DownloadService { let backend: (any MediaBackend)? let coordinator: DownloadCoordinator + /// Welche Sprache gewünscht ist — nur fürs Umpacken. + /// + /// **Weil beim Umpacken eine Tonspur übrig bleibt.** Online wählt man im + /// Player; offline gibt es nach dem Herunterladen nichts mehr zu wählen, + /// also fällt die Entscheidung hier. Dieselbe Abwägung wie beim Abspielen + /// (`AudioTrackChooser`) — eine zweite Regel liefe irgendwann auseinander. + /// + /// Optional, damit Aufrufer ohne Spracheinstellungen weiter bauen: dann + /// gilt, was der Server als Standard meldet. + var languages: LanguagePreferences? /// Baut den Auftrag für einen einzelnen Titel. /// @@ -116,8 +126,24 @@ struct DownloadService { let url: URL? switch quality { case .original where umpacken: - url = backend.remuxDownloadURL(itemID: item.id, versionIndex: mediaIndex) - ?? backend.downloadURL(for: part) + let ton = languages.flatMap { + // Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet + // eine Anfrage je Folge, und die Ebene darüber — Serie und + // allgemein — trägt den Wunsch ohnehin. + AudioTrackChooser.choose( + from: part.streams, + wish: $0.wish(seriesID: item.seriesID, libraryID: nil) + ) + } ?? part.selectedAudioStream + let untertitel = part.subtitleStreams.first { $0.isSelected } + ?? part.subtitleStreams.first + + url = backend.remuxDownloadURL( + itemID: item.id, + versionIndex: mediaIndex, + audioStreamIndex: ton?.index, + subtitleStreamIndex: untertitel?.index + ) ?? backend.downloadURL(for: part) case .original: url = backend.downloadURL(for: part) case .fullHD, .hd, .low: @@ -281,8 +307,11 @@ struct DownloadService { audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec), videoResolution: media.videoResolution, bitrate: media.bitrate, - audioLanguages: part.audioStreams.compactMap(\.language), - subtitleLanguages: part.subtitleStreams.compactMap(\.language), + // **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre + // ein Versprechen, das die Datei nicht hält — und offline ist es + // nicht nachprüfbar, bevor der Player sie öffnet. + audioLanguages: remuxed ? [] : part.audioStreams.compactMap(\.language), + subtitleLanguages: remuxed ? [] : part.subtitleStreams.compactMap(\.language), markers: item.markers.map { OfflineMarker( type: $0.kind.rawValue, diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 2bbdd90..53c62ae 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -12,6 +12,7 @@ struct SeriesDetailView: View { @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads + @Environment(LanguagePreferences.self) private var languages @Environment(\.flixyAccent) private var accent @Environment(\.horizontalSizeClass) private var sizeClass @State private var children: LoadState<[MediaItem]> = .loading @@ -295,7 +296,9 @@ struct SeriesDetailView: View { isWorking = true defer { isWorking = false } - let service = DownloadService(backend: session.backend, coordinator: downloads) + let service = DownloadService( + backend: session.backend, coordinator: downloads, languages: languages + ) let result = await service.download(selectedEpisodes, quality: quality) var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")] diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift index 841f5c8..2d1cdd5 100644 --- a/Flixy/Views/Detail/TechnicalDetails.swift +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -14,8 +14,22 @@ struct TechnicalDetails: View { let part: MediaFile @Binding var isExpanded: Bool + /// Was mit dieser Datei passieren würde. + /// + /// **Mit Serverhilfe gerechnet, nicht ohne.** Hier stand `.directPlayOnly` + /// — damals eine brauchbare Auskunft, weil ohne Serverhilfe die zweite + /// Engine übernahm und auch so hiess. Seit es sie nicht mehr gibt, kam + /// dort „Direkt (Systemplayer)" heraus, direkt über der Zeile „iOS öffnet + /// den Container MKV nicht" — zwei Angaben, die sich widersprechen, und + /// die zweite stimmte. + /// + /// Ohne Bandbreitengrenze, weil sie von der Verbindung abhängt und diese + /// Seite sie nicht kennt. Gezeigt wird damit der Fall im WLAN. private var decision: PlaybackDecision { - PlaybackDecider().decide(profile: media.profile(file: part), policy: .directPlayOnly) + PlaybackDecider().decide( + profile: media.profile(file: part), + policy: PlaybackPolicy(allowsTranscoding: true) + ) } var body: some View { diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 654781f..6cb7147 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -28,6 +28,7 @@ struct DownloadButton: View { @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads + @Environment(LanguagePreferences.self) private var languages @Environment(\.flixyAccent) private var accent @State private var refusal: DownloadRefusal? @@ -158,7 +159,9 @@ struct DownloadButton: View { } private func enqueue(quality: DownloadQuality) { - let service = DownloadService(backend: session.backend, coordinator: downloads) + let service = DownloadService( + backend: session.backend, coordinator: downloads, languages: languages + ) do { _ = try service.download(source, mediaIndex: mediaIndex, quality: quality) } catch { @@ -189,6 +192,7 @@ struct DownloadAllButton: View { @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads + @Environment(LanguagePreferences.self) private var languages @Environment(\.flixyAccent) private var accent @State private var isWorking = false @@ -224,7 +228,9 @@ struct DownloadAllButton: View { isWorking = true defer { isWorking = false } - let service = DownloadService(backend: session.backend, coordinator: downloads) + let service = DownloadService( + backend: session.backend, coordinator: downloads, languages: languages + ) let result = await service.downloadAll(in: container) var lines = [String(localized: "downloads.batch.queued \(result.accepted)")] diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 095fbba..d392c94 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -394,17 +394,36 @@ public func isInLocalNetwork() async -> Bool { /// 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( + public nonisolated func remuxDownloadURL( + itemID: String, + versionIndex: Int, + audioStreamIndex: Int?, + subtitleStreamIndex: Int? + ) -> URL? { + var 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"), + ] + // **Eine Tonspur, nicht alle.** Jellyfin bildet beim Umpacken genau + // eine ab; welche, muss hier stehen. Ohne diese Zeile nimmt der Server + // seine eigene Vorgabe — und das ist bei deutschen Aufnahmen + // regelmäßig die mit Audiodeskription. + if let audioStreamIndex { + query.append(URLQueryItem(name: "audioStreamIndex", value: String(audioStreamIndex))) + } + // `Embed` legt den Untertitel als **Spur** in die Datei, statt ihn ins + // Bild zu brennen. Nur so bleibt er abschaltbar. + if let subtitleStreamIndex { + query.append(URLQueryItem(name: "subtitleStreamIndex", value: String(subtitleStreamIndex))) + query.append(URLQueryItem(name: "subtitleMethod", value: "Embed")) + } + return 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"), - ], + query: query, includingToken: account.token ) } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 4e1d682..f4b4512 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -98,7 +98,17 @@ public protocol MediaBackend: Sendable { /// /// `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? + /// + /// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server + /// legt in die neue Hülle genau eine Tonspur und höchstens einen + /// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das + /// nicht nachholen. + nonisolated func remuxDownloadURL( + itemID: String, + versionIndex: Int, + audioStreamIndex: Int?, + subtitleStreamIndex: Int? + ) -> URL? // MARK: - Abspielen @@ -193,7 +203,17 @@ public extension MediaBackend { /// /// 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 } + /// + /// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server + /// legt in die neue Hülle genau eine Tonspur und höchstens einen + /// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das + /// nicht nachholen. + nonisolated func remuxDownloadURL( + itemID: String, + versionIndex: Int, + audioStreamIndex: Int?, + subtitleStreamIndex: Int? + ) -> URL? { nil } /// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen. ///