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 {
|
struct DownloadService {
|
||||||
let backend: (any MediaBackend)?
|
let backend: (any MediaBackend)?
|
||||||
let coordinator: DownloadCoordinator
|
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.
|
/// Baut den Auftrag für einen einzelnen Titel.
|
||||||
///
|
///
|
||||||
@@ -116,8 +126,24 @@ struct DownloadService {
|
|||||||
let url: URL?
|
let url: URL?
|
||||||
switch quality {
|
switch quality {
|
||||||
case .original where umpacken:
|
case .original where umpacken:
|
||||||
url = backend.remuxDownloadURL(itemID: item.id, versionIndex: mediaIndex)
|
let ton = languages.flatMap {
|
||||||
?? backend.downloadURL(for: part)
|
// 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:
|
case .original:
|
||||||
url = backend.downloadURL(for: part)
|
url = backend.downloadURL(for: part)
|
||||||
case .fullHD, .hd, .low:
|
case .fullHD, .hd, .low:
|
||||||
@@ -281,8 +307,11 @@ struct DownloadService {
|
|||||||
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
|
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
|
||||||
videoResolution: media.videoResolution,
|
videoResolution: media.videoResolution,
|
||||||
bitrate: media.bitrate,
|
bitrate: media.bitrate,
|
||||||
audioLanguages: part.audioStreams.compactMap(\.language),
|
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
|
||||||
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
|
// 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 {
|
markers: item.markers.map {
|
||||||
OfflineMarker(
|
OfflineMarker(
|
||||||
type: $0.kind.rawValue,
|
type: $0.kind.rawValue,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SeriesDetailView: View {
|
|||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@State private var children: LoadState<[MediaItem]> = .loading
|
@State private var children: LoadState<[MediaItem]> = .loading
|
||||||
@@ -295,7 +296,9 @@ struct SeriesDetailView: View {
|
|||||||
isWorking = true
|
isWorking = true
|
||||||
defer { isWorking = false }
|
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)
|
let result = await service.download(selectedEpisodes, quality: quality)
|
||||||
|
|
||||||
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||||
|
|||||||
@@ -14,8 +14,22 @@ struct TechnicalDetails: View {
|
|||||||
let part: MediaFile
|
let part: MediaFile
|
||||||
@Binding var isExpanded: Bool
|
@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 {
|
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 {
|
var body: some View {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct DownloadButton: View {
|
|||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var refusal: DownloadRefusal?
|
@State private var refusal: DownloadRefusal?
|
||||||
@@ -158,7 +159,9 @@ struct DownloadButton: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(quality: DownloadQuality) {
|
private func enqueue(quality: DownloadQuality) {
|
||||||
let service = DownloadService(backend: session.backend, coordinator: downloads)
|
let service = DownloadService(
|
||||||
|
backend: session.backend, coordinator: downloads, languages: languages
|
||||||
|
)
|
||||||
do {
|
do {
|
||||||
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
|
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -189,6 +192,7 @@ struct DownloadAllButton: View {
|
|||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@@ -224,7 +228,9 @@ struct DownloadAllButton: View {
|
|||||||
isWorking = true
|
isWorking = true
|
||||||
defer { isWorking = false }
|
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)
|
let result = await service.downloadAll(in: container)
|
||||||
|
|
||||||
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
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
|
/// 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
|
/// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die
|
||||||
/// Dateigröße verrät es.
|
/// Dateigröße verrät es.
|
||||||
public nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? {
|
public nonisolated func remuxDownloadURL(
|
||||||
client.url(
|
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",
|
path: "/Videos/\(itemID)/stream.mp4",
|
||||||
query: [
|
query: 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
|
includingToken: account.token
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,17 @@ public protocol MediaBackend: Sendable {
|
|||||||
///
|
///
|
||||||
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
|
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
|
||||||
/// und der Titel ist offline nur so gut wie der Systemplayer.
|
/// 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
|
// MARK: - Abspielen
|
||||||
|
|
||||||
@@ -193,7 +203,17 @@ public extension MediaBackend {
|
|||||||
///
|
///
|
||||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||||
/// gibt — und wer es anbietet, sagt es ausdrücklich.
|
/// 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.
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user