Phase 3: Download-Engine und Offline-Wiedergabe

Engine
- Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze
  Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes
  Gedächtnis gibt.
- Pausieren über cancel(byProducingResumeData:) — nur so lässt sich
  weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied
  zwischen brauchbar und unbrauchbar.
- Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge.
  Alles parallel zu laden hieße: keine Folge wird fertig.
- Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein
  Gigabyte bleibt als Puffer frei.
- Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die
  App genau beim Abschluss beendet wird.
- Downloads liegen in Application Support, nicht in Caches (iOS räumt
  den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen.

Oberfläche
- Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf.
- „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel
  stoppt den Stapel nicht.
- Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen,
  Löschen — nach Serie gruppiert.

Offline
- Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server.
- Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert:
  offline lässt sich nichts nachfragen, und der PlaybackDecider braucht
  Container und Codecs.

Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur
den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen
Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder
Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset
statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator.

102 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:34:57 +02:00
co-authored by Claude Opus 5
parent 55bcfe2fbf
commit 62158303a2
14 changed files with 2175 additions and 6 deletions
@@ -0,0 +1,520 @@
import Foundation
import Observation
import SwiftData
/// Was ein Download braucht, um angelegt zu werden.
public struct DownloadPlan: Sendable {
public var ratingKey: String
public var title: String
public var subtitle: String?
public var sourceURL: URL
public var seriesKey: String?
public var seriesTitle: String?
public var seasonKey: String?
public var seasonIndex: Int?
public var episodeIndex: Int?
public var durationMilliseconds: Int?
public var expectedBytes: Int64
public var offlineMedia: OfflineMedia?
public init(
ratingKey: String,
title: String,
subtitle: String? = nil,
sourceURL: URL,
seriesKey: String? = nil,
seriesTitle: String? = nil,
seasonKey: String? = nil,
seasonIndex: Int? = nil,
episodeIndex: Int? = nil,
durationMilliseconds: Int? = nil,
expectedBytes: Int64 = 0,
offlineMedia: OfflineMedia? = nil
) {
self.ratingKey = ratingKey
self.title = title
self.subtitle = subtitle
self.sourceURL = sourceURL
self.seriesKey = seriesKey
self.seriesTitle = seriesTitle
self.seasonKey = seasonKey
self.seasonIndex = seasonIndex
self.episodeIndex = episodeIndex
self.durationMilliseconds = durationMilliseconds
self.expectedBytes = expectedBytes
self.offlineMedia = offlineMedia
}
}
/// Warum ein Download nicht angenommen wurde.
public enum DownloadRefusal: Error, Equatable, Sendable {
case alreadyExists
/// Das eingestellte Speicherlimit wäre überschritten.
case storageLimitReached(limitBytes: Int64, usedBytes: Int64)
/// Auf dem Gerät ist zu wenig Platz.
case insufficientDeviceSpace(requiredBytes: Int64, availableBytes: Int64)
}
/// Führt Downloads aus und hält ihren Zustand.
///
/// Die Übertragung selbst macht eine Hintergrund-`URLSession`: sie läuft
/// weiter, wenn die App geschlossen oder das Gerät gesperrt ist, und iOS
/// startet die App zum Abschluss kurz wieder. Deshalb steht der gesamte
/// Zustand in SwiftData und nicht im Arbeitsspeicher — nach einem Neustart
/// gibt es kein anderes Gedächtnis.
@MainActor
@Observable
public final class DownloadCoordinator {
/// Die Kennung der Hintergrundsitzung. Muss über App-Starts hinweg gleich
/// bleiben, sonst findet iOS die laufenden Übertragungen nicht wieder.
public static let sessionIdentifier = "com.scarriffleservices.flixy.downloads"
public private(set) var items: [DownloadItem] = []
/// Wie viel die Downloads insgesamt belegen.
public private(set) var usedBytes: Int64 = 0
/// Nur über WLAN übertragen.
public var isWiFiOnly: Bool {
didSet { defaults.set(isWiFiOnly, forKey: Key.wifiOnly) }
}
/// Obergrenze in Bytes; `nil` heißt unbegrenzt.
public var storageLimitBytes: Int64? {
didSet { defaults.set(storageLimitBytes ?? 0, forKey: Key.storageLimit) }
}
/// Wie viele Übertragungen gleichzeitig laufen dürfen.
///
/// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt
/// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist
/// eine fertige Folge mehr wert als zehn halbe.
public var maximumConcurrentDownloads = 2
private enum Key {
static let wifiOnly = "flixy.downloads.wifiOnly"
static let storageLimit = "flixy.downloads.storageLimit"
}
/// Der Container, nicht nur der Kontext.
///
/// Ein `ModelContext` hält seinen Container nicht am Leben. Wird der
/// anderswo freigegeben, setzt sich der Kontext zurück und jeder bereits
/// geholte Eintrag stürzt beim nächsten Zugriff ab — mit einer Meldung,
/// die auf `ModelContext.reset` zeigt und nichts über die wahre Ursache
/// sagt. Deshalb liegt die Zuständigkeit hier.
private let container: ModelContainer
private var context: ModelContext { container.mainContext }
private let fileStore: DownloadFileStore
private let defaults: UserDefaults
private var session: URLSession!
private let delegate = DownloadSessionDelegate()
/// Die Konfiguration der Hintergrundsitzung.
///
/// Als eigene Funktion, damit Tests stattdessen eine flüchtige Sitzung
/// einsetzen können. Eine echte Hintergrundsitzung verlangt eine laufende
/// App mit Bundle-Kennung — im Testprozess gibt es die nicht.
public static func backgroundConfiguration(allowsCellularAccess: Bool) -> URLSessionConfiguration {
let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
// `false`, weil der Nutzer den Download selbst angestoßen hat. Auf
// `true` würde iOS ihn auf einen günstigen Zeitpunkt verschieben —
// manchmal erst nachts am Ladegerät.
configuration.isDiscretionary = false
configuration.sessionSendsLaunchEvents = true
configuration.allowsCellularAccess = allowsCellularAccess
return configuration
}
public init(
container: ModelContainer,
fileStore: DownloadFileStore,
defaults: UserDefaults = .standard,
sessionConfiguration: URLSessionConfiguration? = nil
) {
self.container = container
self.fileStore = fileStore
self.defaults = defaults
self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
self.session = URLSession(
configuration: sessionConfiguration
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
delegate: delegate,
delegateQueue: nil
)
delegate.coordinator = self
}
// MARK: - Start
/// Liest den gespeicherten Zustand und bringt ihn mit der Wirklichkeit in
/// Übereinstimmung.
///
/// Beim Start unerlässlich: während die App weg war, können Übertragungen
/// fertig geworden, gescheitert oder vom System abgebrochen worden sein.
public func restore() async {
reloadItems()
let runningKeys = Set(await session.allTasks.compactMap(\.taskDescription))
for item in items where item.state == .downloading {
// Als „läuft" gespeichert, aber keine Aufgabe dazu: die Übertragung
// ist verloren gegangen. Zurück in die Warteschlange, statt einen
// Fortschrittsbalken zu zeigen, der sich nie bewegt.
if !runningKeys.contains(item.ratingKey) {
item.state = .queued
}
}
// Dateien ohne Eintrag entfernen — sie entstehen, wenn die App genau
// beim Abschluss beendet wird.
let known = Set(items.compactMap(\.localFileName))
_ = fileStore.removeOrphans(keeping: known)
save()
refreshUsage()
await startNextIfPossible()
}
// MARK: - Hinzufügen
@discardableResult
public func enqueue(_ plan: DownloadPlan) throws(DownloadRefusal) -> DownloadItem {
if let existing = items.first(where: { $0.ratingKey == plan.ratingKey }) {
// Ein gescheiterter Download darf erneut in die Warteschlange —
// das ist der Wiederholen-Knopf.
guard existing.state == .failed else { throw .alreadyExists }
existing.state = .queued
existing.failureMessage = nil
save()
Task { await startNextIfPossible() }
return existing
}
try checkCapacity(for: plan.expectedBytes)
let item = DownloadItem(
ratingKey: plan.ratingKey,
title: plan.title,
subtitle: plan.subtitle,
sourceURLString: plan.sourceURL.absoluteString,
totalBytes: plan.expectedBytes,
seriesKey: plan.seriesKey,
seriesTitle: plan.seriesTitle,
seasonKey: plan.seasonKey,
seasonIndex: plan.seasonIndex,
episodeIndex: plan.episodeIndex,
durationMilliseconds: plan.durationMilliseconds,
offlineMediaData: plan.offlineMedia.flatMap { try? JSONEncoder().encode($0) }
)
context.insert(item)
save()
reloadItems()
Task { await startNextIfPossible() }
return item
}
/// Mehrere Titel auf einmal — eine Staffel oder eine ganze Serie.
///
/// Gibt zurück, was angenommen wurde und was nicht. Ein einzelner
/// abgelehnter Titel darf den Rest des Stapels nicht verhindern: bei einer
/// Staffel mit einer bereits geladenen Folge wäre sonst gar nichts
/// passiert.
@discardableResult
public func enqueueBatch(
_ plans: [DownloadPlan]
) -> (accepted: [DownloadItem], refused: [(DownloadPlan, DownloadRefusal)]) {
var accepted: [DownloadItem] = []
var refused: [(DownloadPlan, DownloadRefusal)] = []
for plan in plans {
do {
accepted.append(try enqueue(plan))
} catch {
refused.append((plan, error))
}
}
return (accepted, refused)
}
private func checkCapacity(for expectedBytes: Int64) throws(DownloadRefusal) {
if let limit = storageLimitBytes, usedBytes + expectedBytes > limit {
throw .storageLimitReached(limitBytes: limit, usedBytes: usedBytes)
}
let available = fileStore.availableCapacity()
// Ein Puffer von einem Gigabyte bleibt frei. Ein Gerät bis zum letzten
// Byte vollzuschreiben legt auch alles andere lahm.
let reserve: Int64 = 1_000_000_000
if expectedBytes > 0, expectedBytes + reserve > available {
throw .insufficientDeviceSpace(requiredBytes: expectedBytes, availableBytes: available)
}
}
// MARK: - Steuern
public func pause(_ item: DownloadItem) async {
guard item.state == .downloading else { return }
guard let task = await task(for: item.ratingKey) as? URLSessionDownloadTask else {
item.state = .paused
save()
return
}
// `cancel(byProducingResumeData:)` statt `cancel()`: nur so gibt iOS
// den Datensatz heraus, mit dem später weitergeladen werden kann.
// Sonst begänne der Download wieder bei null — bei 40 GB fatal.
let data = await withCheckedContinuation { continuation in
task.cancel { continuation.resume(returning: $0) }
}
item.resumeData = data
item.state = .paused
save()
await startNextIfPossible()
}
public func resume(_ item: DownloadItem) async {
guard item.state == .paused || item.state == .failed else { return }
item.state = .queued
item.failureMessage = nil
save()
await startNextIfPossible()
}
public func cancel(_ item: DownloadItem) async {
await task(for: item.ratingKey)?.cancel()
remove(item)
await startNextIfPossible()
}
/// Entfernt Eintrag und Datei.
public func remove(_ item: DownloadItem) {
fileStore.remove(fileName: item.localFileName)
fileStore.remove(fileName: item.artworkFileName)
context.delete(item)
save()
reloadItems()
refreshUsage()
}
/// Merkt sich, dass ein Titel durchgesehen wurde.
public func markWatched(ratingKey: String) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
item.watchedAt = Date()
save()
}
/// Die lokale Datei eines fertigen Downloads.
public func localURL(forRatingKey key: String) -> URL? {
guard let item = items.first(where: { $0.ratingKey == key }),
item.state == .completed,
let fileName = item.localFileName,
fileStore.exists(fileName: fileName)
else { return nil }
return fileStore.url(forFileName: fileName)
}
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
}
// MARK: - Warteschlange
/// Startet den nächsten wartenden Download, sofern Platz in der
/// Warteschlange ist.
func startNextIfPossible() async {
let running = items.filter { $0.state == .downloading }.count
guard running < maximumConcurrentDownloads else { return }
for item in transferOrder.prefix(maximumConcurrentDownloads - running) {
start(item)
}
}
/// Die Reihenfolge, in der wartende Downloads abgearbeitet werden.
///
/// Älteste zuerst — außer innerhalb einer Serie, dort nach Staffel und
/// Folge. Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig, und
/// unterwegs nützt die siebte wenig, wenn die erste fehlt.
var transferOrder: [DownloadItem] {
items
.filter { $0.state.isTransferable }
.sorted { lhs, rhs in
if lhs.seriesKey == rhs.seriesKey {
let left = (lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0)
let right = (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
if left != right { return left < right }
}
return lhs.addedAt < rhs.addedAt
}
}
private func start(_ item: DownloadItem) {
guard let url = URL(string: item.sourceURLString) else {
fail(item, message: "Ungültige Adresse")
return
}
let task: URLSessionDownloadTask
if let resumeData = item.resumeData {
task = session.downloadTask(withResumeData: resumeData)
item.resumeData = nil
} else {
var request = URLRequest(url: url)
request.allowsCellularAccess = !isWiFiOnly
task = session.downloadTask(with: request)
}
// Über die Beschreibung findet der Delegat den Eintrag wieder — auch
// nach einem Neustart der App, denn iOS stellt sie mit wieder her.
task.taskDescription = item.ratingKey
item.state = .downloading
save()
task.resume()
}
private func task(for ratingKey: String) async -> URLSessionTask? {
await session.allTasks.first { $0.taskDescription == ratingKey }
}
// MARK: - Rückmeldungen des Delegaten
func handleProgress(ratingKey: String, received: Int64, expected: Int64) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
item.receivedBytes = received
if expected > 0 { item.totalBytes = expected }
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
// die Beobachtung; gespeichert wird bei Zustandswechseln.
}
func handleCompletion(ratingKey: String, temporaryFile: URL) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
let sourceURL = URL(string: item.sourceURLString)
else { return }
let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL)
do {
_ = try fileStore.adopt(temporaryFile: temporaryFile, as: name)
item.localFileName = name
item.receivedBytes = fileStore.size(ofFileName: name)
item.totalBytes = item.receivedBytes
item.state = .completed
item.completedAt = Date()
save()
refreshUsage()
} catch {
fail(item, message: error.localizedDescription)
}
Task { await startNextIfPossible() }
}
func handleFailure(ratingKey: String, error: any Error) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
// Ein Abbruch durch den Nutzer ist kein Fehler. Ihn als solchen zu
// melden hieße, dass jedes Pausieren einen roten Hinweis erzeugt.
if (error as? URLError)?.code == .cancelled {
Task { await startNextIfPossible() }
return
}
fail(item, message: error.localizedDescription)
Task { await startNextIfPossible() }
}
private func fail(_ item: DownloadItem, message: String) {
item.state = .failed
item.failureMessage = message
save()
}
// MARK: - Innereien
private func reloadItems() {
let descriptor = FetchDescriptor<DownloadItem>(
sortBy: [SortDescriptor(\.addedAt, order: .forward)]
)
items = (try? context.fetch(descriptor)) ?? []
}
func refreshUsage() {
usedBytes = items.reduce(into: Int64(0)) { $0 += $1.occupiedBytes }
}
private func save() {
try? context.save()
}
}
/// Nimmt die Rückrufe der Hintergrundsitzung entgegen.
///
/// Eine eigene Klasse, weil `URLSessionDelegate` von `NSObject` erben muss und
/// seine Rückrufe auf einer eigenen Warteschlange kommen. Sie reicht alles an
/// den Koordinator auf dem Hauptakteur weiter — dort liegen die Daten.
final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
weak var coordinator: DownloadCoordinator?
/// Wird von der App gesetzt, wenn iOS sie zum Abschluss von Downloads
/// aufgeweckt hat. Muss aufgerufen werden, sobald alles verarbeitet ist —
/// sonst beendet iOS die App hart und merkt sich das negativ.
var backgroundCompletionHandler: (@Sendable () -> Void)?
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
guard let key = downloadTask.taskDescription else { return }
// Die temporäre Datei verschwindet, sobald dieser Aufruf zurückkehrt.
// Sie muss deshalb hier und jetzt in Sicherheit gebracht werden, nicht
// erst nach einem Sprung auf den Hauptakteur.
let temporary = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString)
try? FileManager.default.moveItem(at: location, to: temporary)
Task { @MainActor [weak coordinator] in
coordinator?.handleCompletion(ratingKey: key, temporaryFile: temporary)
}
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard let key = downloadTask.taskDescription else { return }
Task { @MainActor [weak coordinator] in
coordinator?.handleProgress(
ratingKey: key,
received: totalBytesWritten,
expected: totalBytesExpectedToWrite
)
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: (any Error)?
) {
guard let error, let key = task.taskDescription else { return }
Task { @MainActor [weak coordinator] in
coordinator?.handleFailure(ratingKey: key, error: error)
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
let handler = backgroundCompletionHandler
backgroundCompletionHandler = nil
Task { @MainActor in handler?() }
}
}
@@ -0,0 +1,132 @@
import Foundation
/// Verwaltet die heruntergeladenen Dateien auf der Platte.
///
/// Getrennt von der Datenbank, damit beides einzeln prüfbar bleibt: hier geht
/// es nur um Dateien, Pfade und Platz.
public struct DownloadFileStore: Sendable {
private let root: URL
/// Nicht gespeichert, sondern bei jedem Zugriff frisch geholt.
/// `FileManager` ist nicht Sendable; `.default` ist aber
/// threadsicher für die hier verwendeten Operationen, und ein eigener
/// Verweis pro Aufruf kostet nichts.
private var fileManager: FileManager { .default }
public init(root: URL? = nil) throws {
if let root {
self.root = root
} else {
// „Application Support", nicht „Caches": iOS räumt den Zwischen-
// speicher bei knappem Platz eigenmächtig leer. Ein Film, der auf
// dem Flug verschwindet, wäre das Gegenteil von Offline-Nutzung.
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
self.root = base.appending(path: "Downloads", directoryHint: .isDirectory)
}
try createRootIfNeeded()
}
private func createRootIfNeeded() throws {
guard !fileManager.fileExists(atPath: root.path(percentEncoded: false)) else { return }
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
// Ein 40-GB-Remux gehört nicht in die iCloud-Sicherung. Ohne diese
// Markierung zählt Apple ihn zum Speicherbedarf des Kontos — und die
// Sicherung selbst würde tagelang laufen.
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var mutableRoot = root
try mutableRoot.setResourceValues(resourceValues)
}
public func url(forFileName name: String) -> URL {
root.appending(path: name)
}
/// Ein Dateiname aus Titelschlüssel und ursprünglicher Endung.
///
/// Der Schlüssel statt des Titels: Titel enthalten Schrägstriche,
/// Doppelpunkte und Umlaute, und die Endung entscheidet mit darüber, ob
/// AVFoundation die Datei überhaupt zu öffnen versucht.
public func fileName(forRatingKey key: String, sourceURL: URL) -> String {
let ext = sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension
return "\(key).\(ext)"
}
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
///
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
/// Rückruf zurückkehrt. Ein Kopiervorgang für 40 GB wäre zudem ein zweites
/// Mal derselbe Platzbedarf.
public func adopt(temporaryFile: URL, as name: String) throws -> URL {
let destination = url(forFileName: name)
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
try fileManager.removeItem(at: destination)
}
try fileManager.moveItem(at: temporaryFile, to: destination)
return destination
}
public func remove(fileName: String?) {
guard let fileName else { return }
try? fileManager.removeItem(at: url(forFileName: fileName))
}
public func exists(fileName: String?) -> Bool {
guard let fileName else { return false }
return fileManager.fileExists(atPath: url(forFileName: fileName).path(percentEncoded: false))
}
public func size(ofFileName fileName: String) -> Int64 {
let attributes = try? fileManager.attributesOfItem(
atPath: url(forFileName: fileName).path(percentEncoded: false)
)
return (attributes?[.size] as? NSNumber)?.int64Value ?? 0
}
/// Was die Downloads insgesamt belegen.
public func totalSize() -> Int64 {
guard let entries = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
return entries.reduce(into: Int64(0)) { total, entry in
let values = try? entry.resourceValues(forKeys: [.fileSizeKey])
total += Int64(values?.fileSize ?? 0)
}
}
/// Wie viel auf dem Gerät noch frei ist.
///
/// Vor jedem Download geprüft: ein Download, der das Gerät vollschreibt,
/// legt auch alles andere lahm.
public func availableCapacity() -> Int64 {
let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values?.volumeAvailableCapacityForImportantUsage ?? 0
}
/// Entfernt Dateien, zu denen es keinen Eintrag mehr gibt.
///
/// Verwaiste Dateien entstehen, wenn die App während des Abschlusses
/// beendet wird: die Datei liegt schon da, der Eintrag ist noch nicht
/// aktualisiert. Ohne Aufräumen belegen sie dauerhaft Platz, ohne dass
/// die Downloads-Ansicht sie zeigt.
public func removeOrphans(keeping knownFileNames: Set<String>) -> Int {
guard let entries = try? fileManager.contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil
) else { return 0 }
var removed = 0
for entry in entries where !knownFileNames.contains(entry.lastPathComponent) {
if (try? fileManager.removeItem(at: entry)) != nil { removed += 1 }
}
return removed
}
}
@@ -0,0 +1,161 @@
import Foundation
import SwiftData
/// Ein heruntergeladener oder herunterzuladender Titel.
///
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
@Model
public final class DownloadItem {
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
/// soll nicht zweimal in der Warteschlange stehen.
@Attribute(.unique) public var ratingKey: String
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
public var stateRaw: String
public var title: String
public var subtitle: String?
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
public var seriesKey: String?
public var seriesTitle: String?
public var seasonKey: String?
public var seasonIndex: Int?
public var episodeIndex: Int?
public var totalBytes: Int64
public var receivedBytes: Int64
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
/// absoluter Pfad zeigt danach ins Leere.
public var localFileName: String?
public var artworkFileName: String?
public var addedAt: Date
public var completedAt: Date?
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
public var watchedAt: Date?
public var failureMessage: String?
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
/// App-Container und nicht in den Voreinstellungen.
public var sourceURLString: String
public var durationMilliseconds: Int?
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
public var offlineMediaData: Data?
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
public var resumeData: Data?
public init(
ratingKey: String,
title: String,
subtitle: String? = nil,
sourceURLString: String,
state: DownloadState = .queued,
totalBytes: Int64 = 0,
seriesKey: String? = nil,
seriesTitle: String? = nil,
seasonKey: String? = nil,
seasonIndex: Int? = nil,
episodeIndex: Int? = nil,
durationMilliseconds: Int? = nil,
offlineMediaData: Data? = nil
) {
self.ratingKey = ratingKey
self.title = title
self.subtitle = subtitle
self.sourceURLString = sourceURLString
self.stateRaw = state.rawValue
self.totalBytes = totalBytes
self.receivedBytes = 0
self.seriesKey = seriesKey
self.seriesTitle = seriesTitle
self.seasonKey = seasonKey
self.seasonIndex = seasonIndex
self.episodeIndex = episodeIndex
self.durationMilliseconds = durationMilliseconds
self.offlineMediaData = offlineMediaData
self.addedAt = Date()
}
public var state: DownloadState {
get { DownloadState(rawValue: stateRaw) ?? .failed }
set { stateRaw = newValue.rawValue }
}
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
///
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
/// die ehrlichere Anzeige.
public var progress: Double? {
guard totalBytes > 0 else { return nil }
return min(Double(receivedBytes) / Double(totalBytes), 1)
}
public var isComplete: Bool { state == .completed }
/// Wie viel Platz dieser Eintrag gerade belegt.
public var occupiedBytes: Int64 {
state.occupiesStorage ? max(receivedBytes, 0) : 0
}
}
/// Was der Player offline über eine Datei wissen muss.
///
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
/// um zwischen den Motoren zu wählen.
public struct OfflineMedia: Sendable, Codable, Equatable {
public var container: String?
public var videoCodec: String?
public var audioCodec: String?
public var videoResolution: String?
public var bitrate: Int?
public var audioLanguages: [String]
public var subtitleLanguages: [String]
/// Intro und Abspann, in Millisekunden.
public var markers: [OfflineMarker]
public init(
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
bitrate: Int? = nil,
audioLanguages: [String] = [],
subtitleLanguages: [String] = [],
markers: [OfflineMarker] = []
) {
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.bitrate = bitrate
self.audioLanguages = audioLanguages
self.subtitleLanguages = subtitleLanguages
self.markers = markers
}
}
public struct OfflineMarker: Sendable, Codable, Equatable {
public var type: String
public var startMilliseconds: Int
public var endMilliseconds: Int
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
self.type = type
self.startMilliseconds = startMilliseconds
self.endMilliseconds = endMilliseconds
}
}