Beim Umpacken fällt die Spurwahl an — also wird sie dort getroffen
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b927c21b0
commit
a432592271
@@ -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,
|
||||
|
||||
@@ -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)")]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user