Beim Umpacken wird nach der Tonspur gefragt, nicht geraten
Mein Fehler, und ein vermeidbarer. Beim Umpacken legt der Server genau eine Tonspur in die Datei, und welche, hat bisher allein die Abwägung entschieden — bei einer Aufnahme mit Audiodeskription die falsche. Nach dem Herunterladen gibt es nichts mehr zu korrigieren; drei Gigabyte Datenvolumen waren weg. Wo eine Spur verlorengeht, wird jetzt gefragt. Nur dort: bei einer Datei, die der Systemplayer ohnehin öffnet, kommt alles mit, und eine Frage ohne Folgen ist nur ein Klick mehr. **Der Vorschlag kommt aus dem, was schon bekannt ist.** Die gemerkte Sprache der Serie zuerst, dann die Serieneinstellung, dann die allgemeine — dieselbe Abwägung wie beim Abspielen (`AudioTrackChooser`), über denselben Weg. Sie ist jetzt Vorschlag statt Entscheidung und steht als „· empfohlen" dabei. Beim Stapel aus der Mehrfachauswahl wird einmal gefragt, nicht je Folge: die Folgen einer Staffel sind gleich aufgebaut, und zwanzigmal dieselbe Frage wäre keine Wahl, sondern eine Prüfung der Geduld. **Was am Server gemessen wurde, und warum es nicht besser geht:** - `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska; die Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht. - `stream.mp4` bildet genau eine Tonspur ab. - Die HLS-Liste des Servers führt **eine** Variante und **keine** `EXT-X-MEDIA`-Tonspuren. Mehrere Tonspuren offline gibt dieser Server auf keinem seiner Wege her. Das liegt nicht an MP4 — das kann mehrere — und nicht an AVFoundation, sondern am Transcoder, der je Sitzung eine Spur abbildet. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
24d80e5c95
commit
93695dc332
@@ -1087,6 +1087,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.audio.hint": {
|
||||
"comment": "Erklärt, warum die Wahl jetzt fällt",
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Beim Umpacken kommt genau eine mit — danach lässt sie sich nicht mehr wechseln."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Repackaging keeps exactly one — it cannot be changed afterwards."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.audio.recommended %@": {
|
||||
"comment": "Markiert den Vorschlag",
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ · empfohlen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ · recommended"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.audio.title": {
|
||||
"comment": "Überschrift der Tonspurwahl beim Herunterladen",
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Welche Tonspur?"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Which audio track?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.available": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -102,7 +102,8 @@ struct DownloadService {
|
||||
func plan(
|
||||
for item: MediaItem,
|
||||
mediaIndex: Int = 0,
|
||||
quality: DownloadQuality = .original
|
||||
quality: DownloadQuality = .original,
|
||||
audioStream: MediaStream? = nil
|
||||
) throws(DownloadRefusal) -> DownloadPlan {
|
||||
guard let backend else {
|
||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
||||
@@ -126,15 +127,13 @@ struct DownloadService {
|
||||
let url: URL?
|
||||
switch quality {
|
||||
case .original where umpacken:
|
||||
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
|
||||
// **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus
|
||||
// `AudioTrackChooser` — und die hat bei einer Aufnahme mit
|
||||
// Audiodeskription die falsche Spur genommen, ohne zu fragen.
|
||||
// Nach dem Download gibt es nichts mehr zu korrigieren; drei
|
||||
// Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde,
|
||||
// und die Abwägung ist nur noch der Vorschlag.
|
||||
let ton = audioStream ?? preferredAudioStream(in: part, of: item)
|
||||
let untertitel = part.subtitleStreams.first { $0.isSelected }
|
||||
?? part.subtitleStreams.first
|
||||
|
||||
@@ -193,9 +192,30 @@ struct DownloadService {
|
||||
func download(
|
||||
_ item: MediaItem,
|
||||
mediaIndex: Int = 0,
|
||||
quality: DownloadQuality = .original
|
||||
quality: DownloadQuality = .original,
|
||||
audioStream: MediaStream? = nil
|
||||
) throws(DownloadRefusal) -> DownloadItem {
|
||||
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
|
||||
try coordinator.enqueue(
|
||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
||||
)
|
||||
}
|
||||
|
||||
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
||||
/// werden muss.
|
||||
///
|
||||
/// **Die Frage gehört vor den Download, nicht danach.** Wird umgepackt,
|
||||
/// legt der Server genau eine Tonspur in die neue Hülle; was nicht
|
||||
/// mitkommt, ist offline nicht mehr zu holen. Automatisch zu wählen hiess
|
||||
/// hier: dem Nutzer die Entscheidung abnehmen und ihn mit dem Ergebnis
|
||||
/// leben lassen — bei einer Aufnahme mit Audiodeskription genau der
|
||||
/// falsche Weg.
|
||||
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
||||
guard quality == .original else { return false }
|
||||
guard let media = mediaIndex < item.versions.count
|
||||
? item.versions[mediaIndex] : item.preferredVersion,
|
||||
let part = media.files.first
|
||||
else { return false }
|
||||
return !systemPlayerOpens(media: media, part: part) && part.audioStreams.count > 1
|
||||
}
|
||||
|
||||
/// Was ein Stapel ergeben hat.
|
||||
@@ -226,7 +246,8 @@ struct DownloadService {
|
||||
/// stillschweigend.
|
||||
func download(
|
||||
_ episodes: [MediaItem],
|
||||
quality: DownloadQuality = .original
|
||||
quality: DownloadQuality = .original,
|
||||
audioStreamIndex: Int? = nil
|
||||
) async -> BatchResult {
|
||||
guard let backend else { return (0, [], 0) }
|
||||
|
||||
@@ -245,7 +266,16 @@ struct DownloadService {
|
||||
let detailed = (try? await backend.item(id: episode.id)) ?? episode
|
||||
|
||||
do {
|
||||
_ = try coordinator.enqueue(try plan(for: detailed, quality: quality))
|
||||
// Die Nummer, nicht die Spur: innerhalb einer Staffel sind
|
||||
// die Spuren gleich angeordnet, und die Folge selbst kennt
|
||||
// ihre eigene Liste.
|
||||
let ton = audioStreamIndex.flatMap { nummer in
|
||||
detailed.preferredVersion?.files.first?.audioStreams
|
||||
.first { $0.index == nummer }
|
||||
}
|
||||
_ = try coordinator.enqueue(
|
||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||
)
|
||||
accepted += 1
|
||||
} catch DownloadRefusal.noDownloadableFile {
|
||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||
@@ -279,6 +309,25 @@ struct DownloadService {
|
||||
}
|
||||
|
||||
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
||||
/// Welche Tonspur vorgeschlagen wird.
|
||||
///
|
||||
/// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf
|
||||
/// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und
|
||||
/// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen
|
||||
/// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über
|
||||
/// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und
|
||||
/// die allgemeine Sprache in dieser Reihenfolge.
|
||||
///
|
||||
/// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine
|
||||
/// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin.
|
||||
func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? {
|
||||
guard let languages else { return part.selectedAudioStream }
|
||||
return AudioTrackChooser.choose(
|
||||
from: part.streams,
|
||||
wish: languages.wish(seriesID: item.seriesID, libraryID: nil)
|
||||
) ?? part.selectedAudioStream
|
||||
}
|
||||
|
||||
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
|
||||
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
|
||||
DeviceCapabilities.avFoundation.opens(
|
||||
|
||||
@@ -43,6 +43,8 @@ struct QuickPlayTile<Card: View>: View {
|
||||
/// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter
|
||||
/// Zustand, der mit diesem gleichziehen müsste.
|
||||
@State private var downloadChoice: MediaItem?
|
||||
/// Stufe steht, Tonspur fehlt — siehe `DownloadButton`.
|
||||
@State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)?
|
||||
|
||||
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
||||
/// hat. Siehe `ItemActionEntries`.
|
||||
@@ -147,13 +149,40 @@ struct QuickPlayTile<Card: View>: View {
|
||||
if let ziel = downloadChoice {
|
||||
let wahl = DownloadQualityChoice(item: ziel, backend: session.backend)
|
||||
ForEach(wahl.available) { quality in
|
||||
Button(wahl.label(for: quality)) { enqueue(ziel, quality: quality) }
|
||||
Button(wahl.label(for: quality)) { chose(ziel, quality: quality) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let offen = pendingQuality {
|
||||
let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? []
|
||||
let empfohlen = offen.item.preferredVersion?.files.first.flatMap {
|
||||
service.preferredAudioStream(in: $0, of: offen.item)
|
||||
}
|
||||
ForEach(spuren) { stream in
|
||||
Button(
|
||||
stream == empfohlen
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
) {
|
||||
enqueue(offen.item, quality: offen.quality, audioStream: stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("error.title"),
|
||||
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
||||
@@ -232,12 +261,24 @@ struct QuickPlayTile<Card: View>: View {
|
||||
downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item
|
||||
}
|
||||
|
||||
private func enqueue(_ ziel: MediaItem, quality: DownloadQuality) {
|
||||
let service = DownloadService(
|
||||
backend: session.backend, coordinator: downloads, languages: languages
|
||||
)
|
||||
private var service: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
|
||||
private func chose(_ ziel: MediaItem, quality: DownloadQuality) {
|
||||
downloadChoice = nil
|
||||
if service.needsAudioChoice(for: ziel, quality: quality) {
|
||||
pendingQuality = (ziel, quality)
|
||||
} else {
|
||||
enqueue(ziel, quality: quality, audioStream: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) {
|
||||
pendingQuality = nil
|
||||
do {
|
||||
_ = try service.download(ziel, quality: quality)
|
||||
_ = try service.download(ziel, quality: quality, audioStream: audioStream)
|
||||
} catch {
|
||||
errorMessage = error.explanation
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ struct SeriesDetailView: View {
|
||||
@State private var selection: Set<String> = []
|
||||
@State private var sample: MediaItem?
|
||||
@State private var showsQualityChoice = false
|
||||
/// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel.
|
||||
@State private var pendingQuality: DownloadQuality?
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
@@ -78,14 +80,41 @@ struct SeriesDetailView: View {
|
||||
backend: session.backend
|
||||
)
|
||||
ForEach(choice.available) { quality in
|
||||
Button(choice.label(for: quality)) {
|
||||
Task { await loadSelected(quality: quality) }
|
||||
}
|
||||
Button(choice.label(for: quality)) { chose(quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
// **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich
|
||||
// aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern
|
||||
// eine Prüfung der Geduld.
|
||||
if let muster = sample?.preferredVersion?.files.first {
|
||||
let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) }
|
||||
ForEach(muster.audioStreams) { stream in
|
||||
Button(
|
||||
stream == empfohlen
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
) {
|
||||
if let pendingQuality {
|
||||
Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("downloads.batch.title"),
|
||||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||||
@@ -292,14 +321,27 @@ struct SeriesDetailView: View {
|
||||
showsQualityChoice = true
|
||||
}
|
||||
|
||||
private func loadSelected(quality: DownloadQuality) async {
|
||||
private var downloadService: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
|
||||
private func chose(_ quality: DownloadQuality) {
|
||||
if let muster = sample, downloadService.needsAudioChoice(for: muster, quality: quality) {
|
||||
pendingQuality = quality
|
||||
} else {
|
||||
Task { await loadSelected(quality: quality, audioStreamIndex: nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelected(quality: DownloadQuality, audioStreamIndex: Int?) async {
|
||||
pendingQuality = nil
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
let service = DownloadService(
|
||||
backend: session.backend, coordinator: downloads, languages: languages
|
||||
let result = await downloadService.download(
|
||||
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
|
||||
)
|
||||
let result = await service.download(selectedEpisodes, quality: quality)
|
||||
|
||||
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||
if let erster = result.refused.first {
|
||||
|
||||
@@ -36,6 +36,14 @@ struct DownloadButton: View {
|
||||
/// Der nachgeholte Titel — gilt ab dann statt `item`.
|
||||
@State private var detailed: MediaItem?
|
||||
@State private var isLoadingDetails = false
|
||||
/// Die Stufe steht fest, die Tonspur fehlt noch.
|
||||
///
|
||||
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
|
||||
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
|
||||
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
|
||||
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
|
||||
/// zurück.
|
||||
@State private var pendingQuality: DownloadQuality?
|
||||
|
||||
private var existing: DownloadItem? {
|
||||
downloads.items.first { $0.ratingKey == item.id }
|
||||
@@ -76,12 +84,29 @@ struct DownloadButton: View {
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(choice.available) { quality in
|
||||
Button(choice.label(for: quality)) { enqueue(quality: quality) }
|
||||
Button(choice.label(for: quality)) { chose(quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(audioStreams) { stream in
|
||||
Button(audioLabel(stream)) {
|
||||
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("downloads.refused.title"),
|
||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||
@@ -158,12 +183,45 @@ struct DownloadButton: View {
|
||||
showsQualityChoice = true
|
||||
}
|
||||
|
||||
private func enqueue(quality: DownloadQuality) {
|
||||
let service = DownloadService(
|
||||
backend: session.backend, coordinator: downloads, languages: languages
|
||||
)
|
||||
private var service: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
private var audioStreams: [MediaStream] {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
return media?.files.first?.audioStreams ?? []
|
||||
}
|
||||
|
||||
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
|
||||
private var recommended: MediaStream? {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
guard let part = media?.files.first else { return nil }
|
||||
return service.preferredAudioStream(in: part, of: source)
|
||||
}
|
||||
|
||||
private func audioLabel(_ stream: MediaStream) -> String {
|
||||
stream == recommended
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
}
|
||||
|
||||
/// Nach der Stufe: entweder fragen oder einreihen.
|
||||
private func chose(_ quality: DownloadQuality) {
|
||||
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
|
||||
pendingQuality = quality
|
||||
} else {
|
||||
enqueue(quality: quality, audioStream: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
|
||||
pendingQuality = nil
|
||||
do {
|
||||
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
|
||||
_ = try service.download(
|
||||
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
|
||||
)
|
||||
} catch {
|
||||
refusal = error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user