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) -> 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 } }