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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -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)
}