import Foundation import ManaCore /// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden /// einmal vom Server geladen und danach lokal serviert — der Server /// setzt `Cache-Control: private, immutable`, das honorieren wir hier. /// /// LRU-Verdrängung mit Soft-Limit (Default 200 MB). actor MediaCache { private let root: URL private let api: CardsAPI private let maxBytes: Int init(api: CardsAPI, maxBytes: Int = 200 * 1024 * 1024) { self.api = api self.maxBytes = maxBytes let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] root = caches.appendingPathComponent("cards-media", isDirectory: true) try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } /// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls /// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert. func localURL(for mediaId: String) async throws -> URL { let target = root.appendingPathComponent(mediaId) if FileManager.default.fileExists(atPath: target.path) { try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path) return target } let data = try await api.fetchMedia(id: mediaId) try data.write(to: target, options: .atomic) try? await pruneIfNeeded() return target } /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). func data(for mediaId: String) async throws -> Data { try await Data(contentsOf: localURL(for: mediaId)) } /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. private struct CacheEntry { let url: URL let size: Int let date: Date } private func pruneIfNeeded() async throws { let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] guard let items = try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: Array(resourceKeys) ) else { return } let withMeta = items.compactMap { url -> CacheEntry? in let values = try? url.resourceValues(forKeys: resourceKeys) guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } return CacheEntry(url: url, size: size, date: date) } let totalBytes = withMeta.reduce(0) { $0 + $1.size } guard totalBytes > maxBytes else { return } let sortedOldestFirst = withMeta.sorted { $0.date < $1.date } var remaining = totalBytes for item in sortedOldestFirst { if remaining <= maxBytes { break } try? FileManager.default.removeItem(at: item.url) remaining -= item.size let name = item.url.lastPathComponent let size = item.size Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)") } } /// Wipe — für Sign-out o.ä. func clear() { try? FileManager.default.removeItem(at: root) try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } }