A WKWebView runs no service worker, so the page's Keep button (#412) was not shown in the app and everything played from the server. Now the page asks the app to keep the file (keep, unkeep, clearKept), and the app answers with what it holds (kept, keepFailed) whether or not anything plays. The app fetches with a background URLSession, so a download goes on with the app closed, into Application Support (not backed up; the files can be fetched again), and Playback plays from the file when there is one. The DeadAir copy is kept under its own path, as the browser keeps it. Places and finishes reached offline wait in an outbox, newest per address, and go when the network is back. Checked in the simulator against a local server: kept /media/42 and /media/42/deadair, then with the server refusing /media, a fresh launch played each from the phone and asked the server for nothing. The page itself still needs a connection to open (#435). Deploy the app (build 6) before the page: a new page in an old app shows a Keep button nothing answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
3.7 KiB
Swift
90 lines
3.7 KiB
Swift
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) }
|
|
}
|
|
}
|