Files
Flaro/Flixy/Session/DownloadService.swift
T
ScarriffleandClaude Opus 5 a432592271 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>
2026-10-03 08:32:41 +02:00

335 lines
14 KiB
Swift

import Foundation
import FlixyDownloads
import FlixyPlayback
import MediaKit
/// Übersetzt Titel vom Server in Download-Aufträge.
///
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
/// In welcher Qualität heruntergeladen wird.
enum DownloadQuality: String, CaseIterable, Identifiable {
/// Die Datei, wie sie auf dem Server liegt.
case original
case fullHD
case hd
case low
var id: String { rawValue }
var maximumBitrate: Int? {
switch self {
case .original: nil
case .fullHD: 8_000
case .hd: 4_000
case .low: 2_000
}
}
var resolution: String? {
switch self {
case .original: nil
case .fullHD: "1920x1080"
case .hd: "1280x720"
case .low: "854x480"
}
}
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
var pixelHeight: Int? {
switch self {
case .original: nil
case .fullHD: 1080
case .hd: 720
case .low: 480
}
}
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
///
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
guard let height else { return allCases }
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
let threshold = Double(height) * 0.9
return allCases.filter { quality in
guard let target = quality.pixelHeight else { return true }
return Double(target) < threshold
}
}
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
///
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
/// variabler Bitrate —, aber genau genug für die Frage, ob es aufs Gerät
/// passt. Bei `original` steht die echte Größe zur Verfügung.
func estimatedBytes(durationMilliseconds: Int?, originalBytes: Int64) -> Int64 {
guard let maximumBitrate, let durationMilliseconds, durationMilliseconds > 0 else {
return originalBytes
}
let seconds = Double(durationMilliseconds) / 1000
let estimate = Int64(Double(maximumBitrate) * 1000 / 8 * seconds)
// Nie mehr schätzen als das Original groß ist.
return originalBytes > 0 ? min(estimate, originalBytes) : estimate
}
}
@MainActor
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.
///
/// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
/// noch nicht analysiert hat.
func plan(
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadPlan {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.versions.indices.contains(mediaIndex)
? item.versions[mediaIndex]
: item.preferredVersion
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
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
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:
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
url = backend.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: quality.maximumBitrate ?? 8_000,
resolution: quality.resolution
)
}
guard let url else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
}
return DownloadPlan(
ratingKey: item.id,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.seriesID,
seriesTitle: item.seriesTitle,
seasonKey: item.parentID,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
expectedBytes: quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: part.size ?? 0
),
// **Nur beim Original ist die Größe eine Tatsache.** Sie steht in
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
// darf abweichen, ohne dass die Datei kaputt wäre.
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
// eine Schätzung — und die weiche Prüfung die richtige.
expectedIsExact: quality == .original && !umpacken && part.size != nil,
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
)
}
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
}
/// Was ein Stapel ergeben hat.
typealias BatchResult = (
accepted: Int,
refused: [(MediaItem, DownloadRefusal)],
unavailable: Int
)
/// Lädt alle Folgen einer Staffel oder Serie.
func downloadAll(
in container: MediaItem,
quality: DownloadQuality = .original
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
guard let episodes = try? await allEpisodes(in: container, backend: backend) else {
return (0, [], 0)
}
return await download(episodes, quality: quality)
}
/// Lädt genau die übergebenen Folgen.
///
/// **Holt die Metadaten jeder Folge einzeln nach.** Die Kinderliste einer
/// Staffel enthält keine Dateiangaben; ohne Dateigröße ließe sich weder
/// das Speicherlimit prüfen noch ein Fortschrittsbalken zeigen — und eine
/// erwartete Größe von null überspringt die Prüfung auf freien Platz
/// stillschweigend.
func download(
_ episodes: [MediaItem],
quality: DownloadQuality = .original
) async -> BatchResult {
guard let backend else { return (0, [], 0) }
var accepted = 0
var refused: [(MediaItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.id }) {
continue
}
let detailed = (try? await backend.item(id: episode.id)) ?? episode
do {
_ = try coordinator.enqueue(try plan(for: detailed, quality: quality))
accepted += 1
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
// werden am Ende nur gezählt.
unavailable += 1
} catch {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
if case .storageLimitReached = error { break }
if case .insufficientDeviceSpace = error { break }
}
}
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: MediaItem, backend: any MediaBackend) async throws -> [MediaItem] {
switch container.kind {
case .season:
return try await backend.children(of: container.id)
case .show:
var episodes: [MediaItem] = []
for season in try await backend.children(of: container.id) {
episodes.append(contentsOf: try await backend.children(of: season.id))
}
return episodes
default:
return [container]
}
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
DeviceCapabilities.avFoundation.opens(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
)
}
private func offlineMedia(
for item: MediaItem,
media: MediaVersion,
part: MediaFile,
remuxed: Bool
) -> OfflineMedia {
OfflineMedia(
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
// hier weiter die alte Schachtel, entschiede der Player anhand
// einer Datei, die es so nicht mehr gibt.
container: remuxed ? "mp4" : (part.container ?? media.container),
videoCodec: media.videoCodec,
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
videoResolution: media.videoResolution,
bitrate: media.bitrate,
// **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,
startMilliseconds: $0.startMilliseconds,
endMilliseconds: $0.endMilliseconds
)
}
)
}
}
extension MediaItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)"
}
return year.map(String.init)
}
}