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:
Scarriffle
2026-10-03 08:32:41 +02:00
co-authored by Claude Opus 5
parent 6b927c21b0
commit a432592271
6 changed files with 111 additions and 20 deletions
+33 -4
View File
@@ -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,
+4 -1
View File
@@ -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)")]
+15 -1
View File
@@ -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 {
+8 -2
View File
@@ -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.
///