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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user