Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie schnell ein Download gerade läuft — und wie lange er noch braucht.
|
||||
///
|
||||
/// **Ein Wertetyp mit Uhr von außen.** Die Rückmeldungen der Übertragung
|
||||
/// kommen mehrmals je Sekunde; was daraus eine Zahl macht, ist reine Rechnung
|
||||
/// und gehört nicht in den Koordinator. Die Zeitpunkte kommen als Argument
|
||||
/// herein, damit sich eine Übertragung ohne echte Uhr nachstellen lässt — eine
|
||||
/// Rate, die man nur im Betrieb beurteilen kann, ist keine prüfbare Zahl.
|
||||
///
|
||||
/// **Warum geglättet.** Zwischen zwei Rückmeldungen schwankt die rohe
|
||||
/// Differenz um ein Vielfaches: ein Puffer läuft voll, der Funk hat ein Loch,
|
||||
/// das System hält die Hintergrundsitzung kurz an. Ungeglättet stünde dort
|
||||
/// eine Zahl, die zehnmal je Sekunde springt — und eine springende Zahl ist
|
||||
/// keine Auskunft, sondern Unruhe.
|
||||
public struct DownloadRate: Sendable, Equatable {
|
||||
/// Wie stark ein neuer Messwert gegenüber dem bisherigen zählt.
|
||||
///
|
||||
/// Ein Drittel: schnell genug, dass ein echter Einbruch binnen weniger
|
||||
/// Sekunden ankommt, träge genug, dass einzelne Ausreißer untergehen.
|
||||
private static let weight = 1.0 / 3.0
|
||||
|
||||
/// Kürzere Abstände werden nicht gemessen.
|
||||
///
|
||||
/// Über fünfzig Millisekunden gerechnet entscheidet der Zufall, wann das
|
||||
/// System den Puffer leert — nicht die Leitung.
|
||||
static let minimumInterval: Duration = .milliseconds(500)
|
||||
|
||||
/// Ab dieser Lücke beginnt die Messung von vorn.
|
||||
///
|
||||
/// So lange ohne eine einzige Rückmeldung heißt nicht „langsam", sondern
|
||||
/// „stand still": die App war angehalten, das Gerät gesperrt, das Netz
|
||||
/// weg. Diese Pause in die Rate einzurechnen ergäbe eine Zahl nahe null
|
||||
/// und eine Restzeit von Stunden.
|
||||
static let maximumInterval: Duration = .seconds(10)
|
||||
|
||||
/// Ein Messpunkt: wie viel war wann da.
|
||||
///
|
||||
/// Als Typ und nicht als Tupel — ein Tupel kann `Equatable` nicht erben,
|
||||
/// und ohne das wäre der ganze Wertetyp nicht vergleichbar.
|
||||
private struct Anchor: Equatable {
|
||||
var bytes: Int64
|
||||
var at: ContinuousClock.Instant
|
||||
}
|
||||
|
||||
/// Der letzte Messpunkt, gegen den gerechnet wird.
|
||||
private var anchor: Anchor?
|
||||
private var smoothed: Double?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bytes je Sekunde — `nil`, solange nichts gemessen wurde.
|
||||
///
|
||||
/// **Nicht null.** Null wäre eine Auskunft („es geht nichts durch"), und
|
||||
/// die stimmt in der ersten halben Sekunde nie.
|
||||
public var bytesPerSecond: Double? { smoothed }
|
||||
|
||||
/// Nimmt den Stand der Übertragung entgegen.
|
||||
public mutating func record(bytes: Int64, at now: ContinuousClock.Instant) {
|
||||
guard let anchor else {
|
||||
self.anchor = Anchor(bytes: bytes, at: now)
|
||||
return
|
||||
}
|
||||
|
||||
let elapsed = now - anchor.at
|
||||
guard elapsed >= Self.minimumInterval else { return }
|
||||
defer { self.anchor = Anchor(bytes: bytes, at: now) }
|
||||
|
||||
// Rückwärts heißt: eine neue Aufgabe fängt von vorn an. Dann ist jede
|
||||
// bisherige Messung ungültig — und eine negative Rate wäre Unsinn.
|
||||
guard bytes >= anchor.bytes, elapsed <= Self.maximumInterval else {
|
||||
smoothed = nil
|
||||
return
|
||||
}
|
||||
|
||||
let seconds = elapsed.inSeconds
|
||||
guard seconds > 0 else { return }
|
||||
|
||||
let gemessen = Double(bytes - anchor.bytes) / seconds
|
||||
smoothed = smoothed.map { $0 + (gemessen - $0) * Self.weight } ?? gemessen
|
||||
}
|
||||
|
||||
/// Wie lange es bei dieser Rate noch dauert.
|
||||
///
|
||||
/// `nil`, solange keine Rate feststeht oder die Gesamtgröße unbekannt ist.
|
||||
/// Eine Restzeit ohne Rate wäre geraten, und geraten sieht man ihr nicht
|
||||
/// an.
|
||||
public func remaining(received: Int64, total: Int64) -> Duration? {
|
||||
guard let smoothed, smoothed > 0, total > received else { return nil }
|
||||
return .seconds(Double(total - received) / smoothed)
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Die Dauer als Sekunden.
|
||||
///
|
||||
/// `components` liefert ganze Sekunden und Attosekunden getrennt; für eine
|
||||
/// Division braucht es beides in einer Zahl.
|
||||
var inSeconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Der Lebenslauf eines Downloads.
|
||||
///
|
||||
/// Bewusst als geschlossene Aufzählung mit einer geprüften Übergangstabelle:
|
||||
/// die Engine läuft im Hintergrund weiter, während die App beendet ist, und
|
||||
/// beim nächsten Start muss aus dem gespeicherten Zustand eindeutig
|
||||
/// hervorgehen, was zu tun ist.
|
||||
public enum DownloadState: String, Sendable, Equatable, Codable, CaseIterable {
|
||||
/// Steht in der Warteschlange, hat noch keinen Platz bekommen.
|
||||
case queued
|
||||
/// Überträgt gerade.
|
||||
case downloading
|
||||
/// Vom Nutzer angehalten — der Wiederaufnahme-Datensatz liegt bereit.
|
||||
case paused
|
||||
/// Wartet auf Bedingungen: WLAN fehlt, Speicherlimit erreicht.
|
||||
case waiting
|
||||
case completed
|
||||
case failed
|
||||
|
||||
/// Ob aus diesem Zustand heraus übertragen werden darf.
|
||||
public var isTransferable: Bool {
|
||||
switch self {
|
||||
case .queued, .waiting: true
|
||||
case .downloading, .paused, .completed, .failed: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob der Eintrag Platz auf dem Gerät belegt.
|
||||
public var occupiesStorage: Bool {
|
||||
switch self {
|
||||
// Angehaltene und laufende Downloads haben bereits Daten auf der
|
||||
// Platte. Wer sie beim Speicherlimit nicht mitzählt, wundert sich,
|
||||
// warum das Gerät trotz gesetzter Grenze volläuft.
|
||||
case .downloading, .paused, .completed: true
|
||||
case .queued, .waiting, .failed: false
|
||||
}
|
||||
}
|
||||
|
||||
public func canTransition(to next: DownloadState) -> Bool {
|
||||
switch (self, next) {
|
||||
case (.queued, .downloading), (.queued, .waiting), (.queued, .failed):
|
||||
true
|
||||
case (.waiting, .downloading), (.waiting, .queued), (.waiting, .failed):
|
||||
true
|
||||
case (.downloading, .paused), (.downloading, .completed),
|
||||
(.downloading, .failed), (.downloading, .waiting):
|
||||
true
|
||||
case (.paused, .queued), (.paused, .downloading), (.paused, .failed):
|
||||
true
|
||||
// Ein gescheiterter Download darf neu in die Warteschlange — das ist
|
||||
// der Wiederholen-Knopf.
|
||||
case (.failed, .queued):
|
||||
true
|
||||
case (.completed, _):
|
||||
false
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
|
||||
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
|
||||
// verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
// `@preconcurrency`, weil `Activity` und `ActivityContent` keine
|
||||
// Sendable-Zusage tragen. Beide bleiben hier ohnehin auf dem Hauptakteur —
|
||||
// die Klasse ist darauf festgelegt, und ActivityKit erwartet seine Aufrufe
|
||||
// von dort. Ohne diese Zeile lehnt Swift 6 jeden Aufruf ab.
|
||||
@preconcurrency import ActivityKit
|
||||
|
||||
/// Zeigt den Download-Fortschritt im Sperrbildschirm.
|
||||
///
|
||||
/// Getrennt vom Koordinator, weil Live Activities eine Reihe eigener Fallen
|
||||
/// haben — sie können vom Nutzer abgeschaltet sein, ihre Zahl ist begrenzt,
|
||||
/// und eine nicht beendete Activity bleibt stundenlang stehen. Der Koordinator
|
||||
/// meldet nur „das läuft gerade"; was daraus wird, entscheidet sich hier.
|
||||
@MainActor
|
||||
public final class DownloadActivityPresenter {
|
||||
private var activity: Activity<DownloadActivityAttributes>?
|
||||
/// Wann zuletzt aktualisiert wurde.
|
||||
///
|
||||
/// ActivityKit drosselt häufige Aktualisierungen und verwirft sie
|
||||
/// irgendwann kommentarlos. Der Fortschritt kommt mehrmals je Sekunde —
|
||||
/// ungebremst weitergereicht bliebe die Anzeige irgendwann stehen.
|
||||
private var lastUpdate = Date.distantPast
|
||||
private let minimumInterval: TimeInterval = 1.5
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bringt die Anzeige auf den neuesten Stand.
|
||||
///
|
||||
/// `nil` beendet sie — das ist der Fall, wenn nichts mehr läuft.
|
||||
/// `async`, nicht `Task { … }` im Inneren: `Activity` und
|
||||
/// `ActivityContent` sind nicht `Sendable`, und eine Aufgabe würde sie über
|
||||
/// die Isolationsgrenze schicken. So bleibt alles auf dem Hauptakteur.
|
||||
public func update(with state: DownloadActivityAttributes.ContentState?) async {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
guard let state else {
|
||||
await end()
|
||||
return
|
||||
}
|
||||
|
||||
if let activity {
|
||||
guard Date().timeIntervalSince(lastUpdate) >= minimumInterval else { return }
|
||||
lastUpdate = Date()
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
} else {
|
||||
start(with: state)
|
||||
}
|
||||
}
|
||||
|
||||
private func start(with state: DownloadActivityAttributes.ContentState) {
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: DownloadActivityAttributes(),
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
lastUpdate = Date()
|
||||
} catch {
|
||||
// Scheitert meist, weil die Höchstzahl gleichzeitiger Activities
|
||||
// erreicht ist. Kein Grund, den Download anzuhalten — die Anzeige
|
||||
// ist Beiwerk.
|
||||
activity = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Beendet die Anzeige.
|
||||
public func end() async {
|
||||
guard let activity else { return }
|
||||
self.activity = nil
|
||||
// `.immediate`: eine Download-Anzeige, die nach dem letzten Byte noch
|
||||
// eine Viertelstunde im Sperrbildschirm klebt, ist Müll.
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,801 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
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
|
||||
/// Ob `expectedBytes` die genaue Größe ist — siehe `DownloadItem`.
|
||||
public var expectedIsExact: Bool
|
||||
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,
|
||||
expectedIsExact: Bool = false,
|
||||
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.expectedIsExact = expectedIsExact
|
||||
self.offlineMedia = offlineMedia
|
||||
}
|
||||
}
|
||||
|
||||
/// Warum ein Download nicht angenommen wurde.
|
||||
public enum DownloadRefusal: Error, Equatable, Sendable {
|
||||
case alreadyExists
|
||||
/// Zu diesem Titel gibt es keine Datei, die sich herunterladen ließe.
|
||||
///
|
||||
/// Eigener Fall statt eines stillen `nil`: eine Funktion, die kommentarlos
|
||||
/// nichts tut, ist selbst der Fehler — der Knopf sah aus, als wäre er
|
||||
/// kaputt, dabei fehlte nur die Angabe, woran es liegt.
|
||||
case noDownloadableFile(reason: String)
|
||||
/// 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.halo.downloads"
|
||||
|
||||
public private(set) var items: [DownloadItem] = []
|
||||
/// Wie viel die Downloads insgesamt belegen.
|
||||
public private(set) var usedBytes: Int64 = 0
|
||||
|
||||
/// Das gemessene Tempo je laufender Übertragung.
|
||||
///
|
||||
/// **Im Arbeitsspeicher, nicht in der Datenbank** — aus demselben Grund,
|
||||
/// aus dem `handleProgress` kein `save()` ruft: die Messpunkte kommen
|
||||
/// mehrmals je Sekunde, und jedes Schreiben wäre eine Plattenoperation.
|
||||
/// Über einen Neustart hinweg wäre eine alte Rate ohnehin wertlos.
|
||||
private var rates: [String: DownloadRate] = [:]
|
||||
|
||||
/// Für welche Titel der Server die Größe **angekündigt** hat.
|
||||
///
|
||||
/// Der Unterschied entscheidet, wann eine Datei als abgerissen gilt. Bei
|
||||
/// einer angekündigten Größe ist jedes fehlende Byte ein Abriss; bei einer
|
||||
/// hochgerechneten (Transcoder: Bitrate × Dauer) wäre dieselbe Strenge
|
||||
/// falsch, denn die Schätzung weicht von Haus aus ab.
|
||||
///
|
||||
/// Woran man es erkennt: `URLSession` meldet die erwartete Größe nur, wenn
|
||||
/// die Antwort eine `Content-Length` trägt — sonst kommt −1.
|
||||
///
|
||||
/// Ebenfalls im Arbeitsspeicher: nach einem Neustart wird die Übertragung
|
||||
/// neu eingereiht, und dann meldet sich der Server wieder.
|
||||
private var announcedSizes: Set<String> = []
|
||||
|
||||
/// 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: Int {
|
||||
didSet { defaults.set(maximumConcurrentDownloads, forKey: Key.maximumConcurrent) }
|
||||
}
|
||||
|
||||
/// Die Regeln fürs automatische Löschen und Nachladen.
|
||||
public var rules: DownloadRules {
|
||||
didSet {
|
||||
defaults.set(rules.deleteWatchedAfter?.seconds ?? -1, forKey: Key.deleteAfter)
|
||||
defaults.set(rules.keepUnwatchedEpisodes ?? -1, forKey: Key.keepUnwatched)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let wifiOnly = "halo.downloads.wifiOnly"
|
||||
static let storageLimit = "halo.downloads.storageLimit"
|
||||
static let deleteAfter = "halo.downloads.deleteWatchedAfter"
|
||||
static let keepUnwatched = "halo.downloads.keepUnwatched"
|
||||
static let maximumConcurrent = "halo.downloads.maximumConcurrent"
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Die Hintergrundsitzung — erst beim ersten Gebrauch angelegt.
|
||||
///
|
||||
/// `URLSession(configuration: .background(…))` spricht über XPC mit
|
||||
/// nsurlsessiond und braucht dafür spürbar Zeit. Im Initialisierer der App
|
||||
/// aufgerufen verzögerte das den allerersten Bildaufbau: die App stand,
|
||||
/// bevor überhaupt etwas zu sehen war. Wer keine Downloads hat, braucht
|
||||
/// die Sitzung nie.
|
||||
private var createdSession: URLSession?
|
||||
private var sessionConfiguration: URLSessionConfiguration?
|
||||
private let delegate = DownloadSessionDelegate()
|
||||
|
||||
private var session: URLSession {
|
||||
if let createdSession { return createdSession }
|
||||
let created = URLSession(
|
||||
configuration: sessionConfiguration
|
||||
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
|
||||
delegate: delegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
createdSession = created
|
||||
return created
|
||||
}
|
||||
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber
|
||||
// ActivityKit gibt es nicht.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
private let activityPresenter = DownloadActivityPresenter()
|
||||
#endif
|
||||
|
||||
/// 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
|
||||
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden
|
||||
// teilt die Bandbreite so auf, dass keine Folge fertig wird.
|
||||
self.maximumConcurrentDownloads = defaults.object(forKey: Key.maximumConcurrent) as? Int ?? 2
|
||||
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
||||
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
||||
|
||||
// -1 steht für „abgeschaltet". Ein fehlender Schlüssel liefert 0, was
|
||||
// bei der Löschfrist „sofort" hieße — der gefährlichste Wert als
|
||||
// stille Voreinstellung.
|
||||
let deleteAfter = defaults.object(forKey: Key.deleteAfter) as? Double ?? -1
|
||||
let keepUnwatched = defaults.object(forKey: Key.keepUnwatched) as? Int ?? -1
|
||||
self.rules = DownloadRules(
|
||||
deleteWatchedAfter: deleteAfter >= 0 ? .seconds(deleteAfter) : nil,
|
||||
keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil
|
||||
)
|
||||
|
||||
self.sessionConfiguration = sessionConfiguration
|
||||
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()
|
||||
|
||||
// Ohne Einträge gibt es nichts abzugleichen — und vor allem gibt es
|
||||
// keinen Grund, die Hintergrundsitzung überhaupt anzulegen. Bei einer
|
||||
// frischen Installation ist das der Normalfall.
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
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()
|
||||
applyRules()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
/// Wendet die Regeln auf den aktuellen Bestand an.
|
||||
///
|
||||
/// Nur das Löschen — das Nachladen braucht den Server und liegt deshalb
|
||||
/// eine Schicht höher. Läuft beim Start und nach dem Ansehen, nicht auf
|
||||
/// einem Zeitgeber: eine App, die im Hintergrund Filme löscht, wäre
|
||||
/// schwer nachvollziehbar.
|
||||
public func applyRules() {
|
||||
let doomed = rules.itemsToDelete(from: items)
|
||||
guard !doomed.isEmpty else { return }
|
||||
for item in doomed { remove(item) }
|
||||
}
|
||||
|
||||
// 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) },
|
||||
expectedIsExact: plan.expectedIsExact
|
||||
)
|
||||
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
|
||||
forgetTransfer(item.ratingKey)
|
||||
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) {
|
||||
forgetTransfer(item.ratingKey)
|
||||
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()
|
||||
// Bei der Frist „sofort" wird der Titel hier bereits entfernt. Das ist
|
||||
// gewollt: wer das einstellt, will nach dem Abspann keinen Platz mehr
|
||||
// belegen.
|
||||
applyRules()
|
||||
}
|
||||
|
||||
/// 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
|
||||
// Hier und nur hier ist belegt, dass die Größe vom Server kommt
|
||||
// und nicht hochgerechnet ist. Siehe `announcedSizes`.
|
||||
announcedSizes.insert(ratingKey)
|
||||
}
|
||||
|
||||
var rate = rates[ratingKey] ?? DownloadRate()
|
||||
rate.record(bytes: received, at: .now)
|
||||
rates[ratingKey] = rate
|
||||
|
||||
updateActivity()
|
||||
// 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.
|
||||
}
|
||||
|
||||
/// Das gemessene Tempo einer laufenden Übertragung.
|
||||
///
|
||||
/// `nil`, solange noch nichts gemessen wurde — und das ist eine ehrlichere
|
||||
/// Antwort als „0 MB/s".
|
||||
public func rate(forRatingKey key: String) -> DownloadRate? {
|
||||
rates[key]
|
||||
}
|
||||
|
||||
/// Vergisst, was nur zu einer laufenden Übertragung gehört.
|
||||
///
|
||||
/// Beim nächsten Anlauf wird neu gemessen: eine Rate von vorhin gälte
|
||||
/// sonst für eine Leitung, die es inzwischen nicht mehr gibt.
|
||||
private func forgetTransfer(_ ratingKey: String) {
|
||||
rates[ratingKey] = nil
|
||||
announcedSizes.remove(ratingKey)
|
||||
}
|
||||
|
||||
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)
|
||||
let written = fileStore.size(ofFileName: name)
|
||||
|
||||
// Die erwartete Größe NICHT überschreiben. Genau das hat den
|
||||
// Fehler verdeckt: kamen nur ein paar Kilobyte an, wurden sie als
|
||||
// empfangen *und* als erwartet notiert — und die Anzeige stand auf
|
||||
// hundert Prozent.
|
||||
let problem = Self.implausibility(
|
||||
written: written,
|
||||
expected: item.totalBytes,
|
||||
announced: item.expectedIsExact || announcedSizes.contains(ratingKey)
|
||||
)
|
||||
if let problem {
|
||||
fileStore.remove(fileName: name)
|
||||
fail(item, message: problem)
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
forgetTransfer(ratingKey)
|
||||
item.localFileName = name
|
||||
item.receivedBytes = written
|
||||
if item.totalBytes <= 0 { item.totalBytes = written }
|
||||
item.state = .completed
|
||||
item.completedAt = Date()
|
||||
save()
|
||||
refreshUsage()
|
||||
updateActivity()
|
||||
} catch {
|
||||
fail(item, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
/// Prüft, ob die fertige Datei plausibel ist.
|
||||
///
|
||||
/// Gibt eine Begründung zurück, wenn nicht. Zwei Fälle fallen darunter:
|
||||
/// eine winzige Datei (fast immer eine Fehlerseite) und eine, die unter
|
||||
/// der erwarteten Größe liegt (abgebrochene Übertragung, die als
|
||||
/// erfolgreich gemeldet wurde).
|
||||
///
|
||||
/// **`announced` entscheidet, wie streng gemessen wird** — und daran hing
|
||||
/// ein Fehler, der Dateien kaputt machte und als Erfolg verbuchte. Hier
|
||||
/// stand früher für jede Größe dieselbe Regel: unter der Hälfte ist es ein
|
||||
/// Abriss, darüber in Ordnung. Eine bei sechzig Prozent abgerissene Datei
|
||||
/// kam damit durch, bekam einen Haken — und brach beim Abspielen
|
||||
/// mittendrin ab.
|
||||
///
|
||||
/// Die Hälfte-Regel war nie für diesen Fall gedacht, sondern für die
|
||||
/// **hochgerechnete** Größe der Transcoder-Stufen (Bitrate × Dauer). Dort
|
||||
/// ist Abweichung normal. Hat der Server die Größe dagegen angekündigt,
|
||||
/// ist jedes fehlende Byte ein Abriss, und zwar genau einer.
|
||||
static func implausibility(written: Int64, expected: Int64, announced: Bool) -> String? {
|
||||
// Unter hundert Kilobyte ist keine Videodatei. Dieser Wert liegt
|
||||
// bewusst niedrig: eine sehr kurze Folge kann klein sein, eine
|
||||
// HTML-Fehlerseite ist es immer.
|
||||
if written < 100_000 {
|
||||
return String(
|
||||
localized: "downloads.failed.tooSmall \(written.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
// Angekündigt: auf das Byte genau. Hochgerechnet: die Hälfte, denn der
|
||||
// Transcoder kennt die Endgröße vorab nicht.
|
||||
let untergrenze = announced ? Double(expected) : Double(expected) * 0.5
|
||||
if expected > 0, Double(written) < untergrenze {
|
||||
return String(
|
||||
localized: "downloads.failed.truncated \(written.formatted(.byteCount(style: .file))) \(expected.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Der Server hat die Anfrage abgelehnt.
|
||||
func handleServerRejection(ratingKey: String, status: Int) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||
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
|
||||
}
|
||||
|
||||
// **Den Wiederaufnahme-Datensatz retten.**
|
||||
//
|
||||
// Hier lag der zweite Teil des Fehlers. Es gab ihn nur beim Pausieren
|
||||
// von Hand (`pause(_:)`); nach einem Netzabbruch war er weg, und
|
||||
// „Fortsetzen" lud eine vierzig Gigabyte große Datei wieder ab Byte
|
||||
// null — während der Knopf daneben „Fortsetzen" hieß. Eine
|
||||
// Beschriftung, die der Code nicht halten konnte.
|
||||
//
|
||||
// iOS legt den Datensatz dem Fehler bei, wenn die Übertragung
|
||||
// unterbrochen wurde und fortsetzbar ist. Fehlt er, war es keine
|
||||
// Unterbrechung, sondern eine Ablehnung — und `resumeInterrupted()`
|
||||
// unterscheidet die beiden genau daran.
|
||||
item.resumeData = (error as NSError)
|
||||
.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||
|
||||
fail(item, message: error.localizedDescription)
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
/// Nimmt wieder auf, was nur das Netz unterbrochen hat.
|
||||
///
|
||||
/// **Weil niemand sonst merkt, dass das WLAN zurück ist.** Ein
|
||||
/// gescheiterter Download lag bisher, bis man ihn von Hand antippte — auch
|
||||
/// dann, wenn die Verbindung seit einer Stunde wieder stand.
|
||||
///
|
||||
/// Nur Einträge mit Wiederaufnahme-Datensatz: den gibt es bei einer
|
||||
/// unterbrochenen Übertragung, nicht bei einer abgelehnten. Ein Titel, den
|
||||
/// der Server mit „nicht gefunden" beantwortet, versucht sich damit nicht
|
||||
/// im Kreis.
|
||||
public func resumeInterrupted() async {
|
||||
let unterbrochen = items.filter { $0.state == .failed && $0.resumeData != nil }
|
||||
guard !unterbrochen.isEmpty else { return }
|
||||
|
||||
for item in unterbrochen {
|
||||
item.state = .queued
|
||||
item.failureMessage = nil
|
||||
}
|
||||
save()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
private func fail(_ item: DownloadItem, message: String) {
|
||||
forgetTransfer(item.ratingKey)
|
||||
item.state = .failed
|
||||
item.failureMessage = message
|
||||
save()
|
||||
updateActivity()
|
||||
}
|
||||
|
||||
/// Hält die Anzeige im Sperrbildschirm auf dem Stand.
|
||||
///
|
||||
/// Zeigt den am weitesten fortgeschrittenen Download — bei zwei
|
||||
/// gleichzeitigen ist das der, der zuerst fertig wird, und damit der, auf
|
||||
/// den man wartet.
|
||||
private func updateActivity() {
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber
|
||||
// ActivityKit gibt es nicht — die Typen sind zwar da, und jeder
|
||||
// Aufruf ist als nicht verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let running = items.filter { $0.state == .downloading }
|
||||
guard let leading = running.max(by: { ($0.progress ?? 0) < ($1.progress ?? 0) }) else {
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: nil) }
|
||||
return
|
||||
}
|
||||
|
||||
// Der Zustand wird hier gebaut, nicht in der Aufgabe: SwiftData-Objekte
|
||||
// gehören dem Hauptakteur, der reine Wertetyp darf wandern.
|
||||
let state = DownloadActivityAttributes.ContentState(
|
||||
title: leading.title,
|
||||
progress: leading.progress,
|
||||
receivedBytes: leading.receivedBytes,
|
||||
totalBytes: leading.totalBytes,
|
||||
remainingCount: items.filter { $0.state.isTransferable }.count + running.count - 1
|
||||
)
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: state) }
|
||||
#endif
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||
// Sekunde „heruntergeladen".
|
||||
if let response = downloadTask.response as? HTTPURLResponse,
|
||||
!(200..<300).contains(response.statusCode) {
|
||||
let status = response.statusCode
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleServerRejection(ratingKey: key, status: status)
|
||||
}
|
||||
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,145 @@
|
||||
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.
|
||||
///
|
||||
/// **Auf tvOS gibt es die Auskunft nicht** —
|
||||
/// `volumeAvailableCapacityForImportantUsage` ist dort als nicht
|
||||
/// verfügbar markiert, und ein Apple TV hat auch keinen Speicher, auf den
|
||||
/// man etwas legt. Das Paket wird dort trotzdem mitverlinkt, damit
|
||||
/// `PlayerModel` den Koordinator **nennen** darf, ohne dass zweitausend
|
||||
/// Zeilen Wiedergabelogik von `#if` durchzogen werden. Angelegt wird nie
|
||||
/// einer; diese Zeile läuft also nie. Null heisst „kein Platz", und das
|
||||
/// ist die richtige Antwort für ein Gerät, auf dem nichts liegen soll.
|
||||
public func availableCapacity() -> Int64 {
|
||||
#if os(tvOS)
|
||||
0
|
||||
#else
|
||||
let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return values?.volumeAvailableCapacityForImportantUsage ?? 0
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 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,86 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Regeln, nach denen Downloads von selbst entstehen und verschwinden.
|
||||
///
|
||||
/// Bewusst als reine Rechnung ohne Zugriff auf Datenbank, Dateien oder Netz:
|
||||
/// sie bekommt den Bestand hereingereicht und gibt zurück, was zu tun wäre.
|
||||
/// Dadurch lässt sich das Verhalten prüfen, ohne etwas zu löschen — und beim
|
||||
/// automatischen Löschen von Filmen ist das keine akademische Vorsicht.
|
||||
public struct DownloadRules: Sendable, Equatable {
|
||||
/// Gesehene Titel nach dieser Frist entfernen. `nil` schaltet es ab.
|
||||
///
|
||||
/// Eine Frist statt sofortigem Löschen: wer eine Folge zu Ende sieht und
|
||||
/// gleich noch einmal die letzte Szene sucht, soll sie nicht schon
|
||||
/// verloren haben.
|
||||
public var deleteWatchedAfter: Duration?
|
||||
|
||||
/// Wie viele ungesehene Folgen je Serie vorgehalten werden. `nil`
|
||||
/// schaltet das Nachladen ab.
|
||||
public var keepUnwatchedEpisodes: Int?
|
||||
|
||||
public init(deleteWatchedAfter: Duration? = nil, keepUnwatchedEpisodes: Int? = nil) {
|
||||
self.deleteWatchedAfter = deleteWatchedAfter
|
||||
self.keepUnwatchedEpisodes = keepUnwatchedEpisodes
|
||||
}
|
||||
|
||||
public static let disabled = DownloadRules()
|
||||
|
||||
/// Welche Einträge nach den Regeln zu entfernen sind.
|
||||
public func itemsToDelete(
|
||||
from items: [DownloadItem],
|
||||
now: Date = Date()
|
||||
) -> [DownloadItem] {
|
||||
guard let deleteWatchedAfter else { return [] }
|
||||
|
||||
return items.filter { item in
|
||||
// Nur Fertiges: ein laufender Download hat nichts, was gesehen
|
||||
// worden sein könnte.
|
||||
guard item.state == .completed, let watchedAt = item.watchedAt else { return false }
|
||||
return now.timeIntervalSince(watchedAt) >= deleteWatchedAfter.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie viele Folgen je Serie noch fehlen, um den Vorrat zu füllen.
|
||||
///
|
||||
/// Gezählt wird, was offline und ungesehen ist. Gesehene Folgen zählen
|
||||
/// nicht mit — sonst bliebe der Vorrat bei drei gesehenen Folgen stehen
|
||||
/// und es käme nie etwas Neues nach.
|
||||
public func missingEpisodeCount(
|
||||
forSeries seriesKey: String,
|
||||
in items: [DownloadItem]
|
||||
) -> Int {
|
||||
guard let keepUnwatchedEpisodes else { return 0 }
|
||||
|
||||
let unwatchedOffline = items.filter {
|
||||
$0.seriesKey == seriesKey
|
||||
&& $0.watchedAt == nil
|
||||
// Wartende und laufende zählen mit: sonst würde bei jedem
|
||||
// Aufruf erneut nachgelegt, bis die ganze Serie auf dem Gerät
|
||||
// liegt.
|
||||
&& $0.state != .failed
|
||||
}.count
|
||||
|
||||
return max(0, keepUnwatchedEpisodes - unwatchedOffline)
|
||||
}
|
||||
|
||||
/// Die Serien, für die nachgeladen werden müsste.
|
||||
public func seriesNeedingRefill(in items: [DownloadItem]) -> [String: Int] {
|
||||
guard keepUnwatchedEpisodes != nil else { return [:] }
|
||||
|
||||
let seriesKeys = Set(items.compactMap(\.seriesKey))
|
||||
var result: [String: Int] = [:]
|
||||
for key in seriesKeys {
|
||||
let missing = missingEpisodeCount(forSeries: key, in: items)
|
||||
if missing > 0 { result[key] = missing }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Gängige Fristen fürs automatische Löschen.
|
||||
public static let immediately = Duration.zero
|
||||
public static let afterOneDay = Duration.seconds(24 * 60 * 60)
|
||||
public static let afterOneWeek = Duration.seconds(7 * 24 * 60 * 60)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"downloads.failed.tooSmall %@": {
|
||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat nur %@ geliefert — das ist keine Videodatei."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server returned only %@ — that isn't a video file."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.truncated %@ %@": {
|
||||
"comment": "Die Übertragung brach ab, wurde aber als erfolgreich gemeldet",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Übertragung unvollständig: %@ von %@ angekommen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Transfer incomplete: %@ of %@ received."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.failed.status %lld": {
|
||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused the download with error %lld."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
|
||||
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
|
||||
// Prüfung auf den Import allein übersetzt deshalb nicht.
|
||||
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
|
||||
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
|
||||
// verfügbar markiert.
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
import ActivityKit
|
||||
|
||||
/// Was im Sperrbildschirm und in der Dynamic Island steht.
|
||||
///
|
||||
/// Liegt in HaloDownloads, weil App und Widget-Erweiterung denselben Typ
|
||||
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
|
||||
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
|
||||
/// Typen über ihren vollen Namen ab.
|
||||
public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
/// Der Titel, der gerade lädt.
|
||||
public var title: String
|
||||
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
||||
public var progress: Double?
|
||||
public var receivedBytes: Int64
|
||||
public var totalBytes: Int64
|
||||
/// Wie viele weitere in der Warteschlange stehen.
|
||||
public var remainingCount: Int
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
progress: Double?,
|
||||
receivedBytes: Int64,
|
||||
totalBytes: Int64,
|
||||
remainingCount: Int
|
||||
) {
|
||||
self.title = title
|
||||
self.progress = progress
|
||||
self.receivedBytes = receivedBytes
|
||||
self.totalBytes = totalBytes
|
||||
self.remainingCount = remainingCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
|
||||
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
|
||||
public init() {}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,174 @@
|
||||
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
|
||||
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
|
||||
///
|
||||
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
|
||||
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
|
||||
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
|
||||
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
|
||||
/// dort wäre dieselbe Strenge falsch.
|
||||
///
|
||||
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
|
||||
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
|
||||
public var expectedIsExact: Bool = false
|
||||
|
||||
/// 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,
|
||||
expectedIsExact: Bool = false
|
||||
) {
|
||||
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.expectedIsExact = expectedIsExact
|
||||
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 die Engines 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// Warum gerade nichts übertragen wird, obwohl etwas in der Schlange steht.
|
||||
///
|
||||
/// Die Hintergrund-Sitzung wartet in diesen Fällen von sich aus — sie meldet
|
||||
/// keinen Fehler, sie tut nur nichts. Für den Betrachter sieht das aus wie
|
||||
/// ein hängender Download: der Balken steht, und nichts sagt, worauf.
|
||||
public enum TransferHold: Sendable, Equatable {
|
||||
case none
|
||||
/// „Nur über WLAN" ist an, und das Gerät hängt am Mobilfunk.
|
||||
case waitingForWiFi
|
||||
/// Gar keine Verbindung.
|
||||
case offline
|
||||
|
||||
/// Was die Übertragung gerade aufhält.
|
||||
///
|
||||
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert
|
||||
/// deckt auch den persönlichen Hotspot ab — technisch WLAN, in der
|
||||
/// Abrechnung Mobilfunk. Wer dort lädt, zahlt es trotzdem.
|
||||
public static func current(
|
||||
isWiFiOnly: Bool,
|
||||
isReachable: Bool,
|
||||
isExpensive: Bool
|
||||
) -> TransferHold {
|
||||
// Ohne Verbindung hilft auch ein ausgeschalteter WLAN-Zwang nicht —
|
||||
// deshalb zuerst geprüft.
|
||||
if !isReachable { return .offline }
|
||||
if isWiFiOnly, isExpensive { return .waitingForWiFi }
|
||||
return .none
|
||||
}
|
||||
|
||||
public var isHolding: Bool { self != .none }
|
||||
}
|
||||
Reference in New Issue
Block a user