Files
Flaro/Flixy/Session/DownloadService.swift
T
GuidoandClaude Opus 5 6150b018ec Vier Punkte: Player-Hänger, kaputte Downloads, Stufenliste, Kopfleiste
Player hing sich auf
  Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins
  Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps
  schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht
  mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem
  Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist
  neu gestartet.

Downloads galten als fertig, waren aber leer
  Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch
  bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab.
  Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen
  — womit die Anzeige auf hundert Prozent stand und der Beweis weg war.
  873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur
  „Wiedergabe wurde unterbrochen".

  Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen,
  und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB
  ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine
  abgebrochene Übertragung. Sechs Tests dazu.

Stufenliste ergab keinen Sinn
  Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben
  geschätzten Größe untereinander — das sah aus, als wäre die Schätzung
  kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur
  eine größere Datei. Angeboten wird jetzt nur noch, was unter der
  Auflösung der Datei liegt, und beim Original steht dabei, was es ist:
  „Original · 720p · 873 MB".

Kopfleiste
  Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe
  bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App
  gehört auf den Homescreen, nicht über jeden Bildschirm.

145 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 22:32:19 +02:00

251 lines
9.3 KiB
Swift

import Foundation
import FlixyDownloads
import PlexKit
/// Ü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 server: PlexServer?
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: PlexItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadPlan {
guard let server else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.media.indices.contains(mediaIndex)
? item.media[mediaIndex]
: item.preferredMedia
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.parts.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
let url: URL?
switch quality {
case .original:
url = server.downloadURL(part: 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 = server.transcodedDownloadURL(
ratingKey: item.ratingKey,
mediaIndex: 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.ratingKey,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.grandparentRatingKey,
seriesTitle: item.grandparentTitle,
seasonKey: item.parentRatingKey,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
expectedBytes: quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: part.size ?? 0
),
offlineMedia: offlineMedia(for: item, media: media, part: part)
)
}
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: PlexItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
}
/// Lädt alle Folgen einer Staffel oder Serie.
///
/// Holt die Metadaten jeder Folge einzeln nach: die Kinderliste enthält
/// keine Dateiangaben, und ohne Dateigröße ließe sich weder das
/// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen.
func downloadAll(
in container: PlexItem
) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) {
guard let server else { return (0, [], 0) }
let episodes: [PlexItem]
do {
episodes = try await allEpisodes(in: container, server: server)
} catch {
return (0, [], 0)
}
var accepted = 0
var refused: [(PlexItem, 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.ratingKey }) {
continue
}
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
do {
_ = try coordinator.enqueue(try plan(for: detailed))
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: PlexItem, server: PlexServer) async throws -> [PlexItem] {
switch container.kind {
case .season:
return try await server.children(of: container.ratingKey)
case .show:
var episodes: [PlexItem] = []
for season in try await server.children(of: container.ratingKey) {
episodes.append(contentsOf: try await server.children(of: season.ratingKey))
}
return episodes
default:
return [container]
}
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia {
OfflineMedia(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
audioCodec: 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.type,
startMilliseconds: $0.startTimeOffset,
endMilliseconds: $0.endTimeOffset
)
}
)
}
}
extension PlexItem {
/// 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)
}
}