Files
Flaro/Flixy/Session/DownloadService.swift
T
ScarriffleandClaude Opus 5 ef2596e2bb Was heruntergeladen wird, muss der Systemplayer öffnen können
**Vorbereitung dafür, dass VLC verschwindet.** Online ist der Fall längst
gelöst: steht nur die Schachtel im Weg, packt der Server um — dieselben
Ströme, eine MP4-Hülle. Offline gibt es diesen Ausweg nicht. Was einmal als
Matroska auf dem Gerät liegt, kann hinterher niemand mehr umpacken, und ohne
zweite Engine bliebe die Datei schwarz.

Also entscheidet sich das beim Herunterladen. Fragt jemand das Original an und
AVFoundation könnte die Datei nicht öffnen, wird `remuxDownloadURL` benutzt:
`/Videos/{id}/stream.mp4` mit `allowVideoStreamCopy` und
`allowAudioStreamCopy`. Der Server reicht durch, was passt, und rechnet nur
um, was nicht passt — das Bild bleibt das Original, nur DTS oder TrueHD
werden zu AC3 oder AAC.

Drei Dinge hängen daran und sind mitgezogen:

- Die **Größe** ist dann keine Zusage mehr: dieselben Ströme in einer anderen
  Hülle wiegen anders. `expectedIsExact` fällt auf falsch, und es gilt wieder
  die weiche Prüfung.
- Der **Container** im Offline-Abzug ist „mp4", nicht die alte Schachtel.
  Sonst entschiede der Player anhand einer Datei, die es so nicht mehr gibt.
- Der **Tonkodierer** bleibt leer. Ob aus DTS dann AAC oder AC3 wird, sagt der
  Server vorher nicht; eine Behauptung wäre schlechter als die Lücke, und die
  Spuren liest der Player ohnehin aus der Datei.

**Die Frage „kann das der Systemplayer?" steht jetzt an einer Stelle.**
`DeviceCapabilities.opens(container:videoCodec:audioCodec:)` — dieselbe Liste,
nach der auch beim Abspielen entschieden wird. Was der Server nicht nennt,
gilt als passend: vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4
eine neu gerechnete Datei zu machen. Fünf Tests.

NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren hier
tatsächlich wählt, verrät erst die Dateigröße des ersten Downloads.

140 Tests in FlixyPlayback, iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:54:46 +02:00

306 lines
12 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
/// 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:
url = backend.remuxDownloadURL(itemID: item.id, versionIndex: mediaIndex)
?? 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,
audioLanguages: part.audioStreams.compactMap(\.language),
subtitleLanguages: 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)
}
}