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. ///