import Foundation /// The episodes kept on this device (#425): which file holds each, by the path the page keeps it /// under, `/media/42`, or `/media/42/deadair` for the copy without its dead air. A plain value over /// a folder, so a test can point it at a temporary one. struct EpisodeStore { struct Entry: Codable, Equatable { var file: String var bytes: Int64 } let directory: URL private(set) var entries: [String: Entry] private var indexURL: URL { directory.appendingPathComponent("index.json") } init(directory: URL) { self.directory = directory try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) // The files can be fetched again, so they are not worth the user's iCloud backup. var dir = directory var values = URLResourceValues() values.isExcludedFromBackup = true try? dir.setResourceValues(values) let saved = (try? Data(contentsOf: directory.appendingPathComponent("index.json"))) .flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:] // A file the system took back is not kept, whatever the index says. entries = saved.filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0.value.file).path) } } var paths: [String] { entries.keys.sorted() } func fileURL(for path: String) -> URL? { entries[path].map { directory.appendingPathComponent($0.file) } } /// Only the page's two kinds of file, which also keeps what a path can make of a file name to /// digits and a word: a message from the page is not trusted to name a place on disk. static func isKeepable(_ path: String) -> Bool { path.range(of: #"^/media/[0-9]+(/deadair)?$"#, options: .regularExpression) != nil } static func fileName(for path: String, ext: String) -> String { path.dropFirst().replacingOccurrences(of: "/", with: "-") + "." + ext } /// What AVFoundation can tell a file's type from, since a download has no name of its own. static func ext(forMime mime: String?) -> String { switch mime?.lowercased().split(separator: ";").first.map(String.init) { case "audio/mp4", "audio/x-m4a", "audio/m4a": return "m4a" case "audio/aac", "audio/aacp": return "aac" case "audio/ogg", "audio/opus": return "ogg" case "audio/wav", "audio/x-wav": return "wav" case "audio/flac", "audio/x-flac": return "flac" default: return "mp3" } } /// Takes the file at `temp`, which the system deletes when the delegate returns, as `path`'s. mutating func add(_ path: String, moving temp: URL, ext: String) throws { precondition(Self.isKeepable(path)) let name = Self.fileName(for: path, ext: ext) let dest = directory.appendingPathComponent(name) try? FileManager.default.removeItem(at: dest) try FileManager.default.moveItem(at: temp, to: dest) let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path))?[.size] as? Int64 ?? 0 entries[path] = Entry(file: name, bytes: bytes) save() } mutating func remove(_ path: String) { guard let e = entries.removeValue(forKey: path) else { return } try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) save() } mutating func removeAll() { for e in entries.values { try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) } entries = [:] save() } private func save() { if let data = try? JSONEncoder().encode(entries) { try? data.write(to: indexURL, options: .atomic) } } }