Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 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.flaro.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 = "flaro.downloads.wifiOnly"
static let storageLimit = "flaro.downloads.storageLimit"
static let deleteAfter = "flaro.downloads.deleteWatchedAfter"
static let keepUnwatched = "flaro.downloads.keepUnwatched"
static let maximumConcurrent = "flaro.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)
}