Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
4.7 KiB
Swift
102 lines
4.7 KiB
Swift
import Foundation
|
|
|
|
/// Index of locally downloaded media, keyed by filename so a black-side library
|
|
/// path can be matched to its local copy even though the layouts don't agree:
|
|
/// `OfflineCacheController` rsyncs episodes into `destRoot/<show>/…`, which doesn't
|
|
/// mirror the black media tree, but it preserves each file's name.
|
|
///
|
|
/// Built by walking `OfflineCacheController.destRoot`; refreshed whenever the
|
|
/// library is refreshed (and after a cache run), and persisted so a cold start
|
|
/// (before the first scan) still knows what's downloaded. Lookups are O(1) and
|
|
/// synchronous so the main-actor playback router (`PlayerController.routedTarget`)
|
|
/// can consult it without awaiting.
|
|
public final class DownloadsIndex: @unchecked Sendable {
|
|
public static let shared = DownloadsIndex(byName: loadPersisted())
|
|
|
|
private static let videoExt: Set<String> = ["mkv", "mp4", "m4v", "avi", "mov", "webm"]
|
|
|
|
public static func isVideoFilename(_ name: String) -> Bool {
|
|
videoExt.contains((name as NSString).pathExtension.lowercased())
|
|
}
|
|
private static let maxDepth = 5
|
|
|
|
private let lock = NSLock()
|
|
private var byName: [String: String] // lowercased filename → local absolute path
|
|
|
|
init(byName: [String: String]) { self.byName = byName }
|
|
|
|
/// Local path of a downloaded copy for `libraryPath` (only its filename is
|
|
/// used), or nil. Re-checks existence so a moved/deleted download never routes
|
|
/// playback to a dead path. O(1).
|
|
public func localPath(for libraryPath: String) -> String? {
|
|
let key = (libraryPath as NSString).lastPathComponent.lowercased()
|
|
guard !key.isEmpty else { return nil }
|
|
lock.lock(); let hit = byName[key]; lock.unlock()
|
|
guard let hit, FileManager.default.fileExists(atPath: hit) else { return nil }
|
|
return hit
|
|
}
|
|
|
|
/// Rebuild from the downloads dir(s). Walks the filesystem — call off the main
|
|
/// actor. Persists the result and returns the entry count. A scan that comes
|
|
/// back empty while we already had entries is treated as a transient read
|
|
/// failure and ignored (don't wipe a good index → route everything to black).
|
|
@discardableResult
|
|
public func refresh() -> Int {
|
|
let map = Self.scan(roots: Self.roots())
|
|
lock.lock()
|
|
let kept = byName.count
|
|
if map.isEmpty && kept > 0 { lock.unlock(); return kept }
|
|
byName = map
|
|
lock.unlock()
|
|
Self.persist(map)
|
|
return map.count
|
|
}
|
|
|
|
// MARK: build
|
|
|
|
private static func roots() -> [String] { [OfflineCacheController.destRoot.path] }
|
|
|
|
/// Walk each root (bounded depth) collecting `filename → path` for video files.
|
|
/// Last writer wins on a duplicate filename — rare given media-fetch's
|
|
/// descriptive `Show SxxEyy …` names; logged is not worth the bookkeeping.
|
|
static func scan(roots: [String]) -> [String: String] {
|
|
let fm = FileManager.default
|
|
var out: [String: String] = [:]
|
|
for root in roots {
|
|
var stack: [(url: URL, depth: Int)] = [(URL(fileURLWithPath: root, isDirectory: true), 0)]
|
|
while let top = stack.popLast() {
|
|
guard let entries = try? fm.contentsOfDirectory(
|
|
at: top.url, includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles]) else { continue }
|
|
for url in entries {
|
|
if (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
|
|
if top.depth < maxDepth { stack.append((url, top.depth + 1)) }
|
|
continue
|
|
}
|
|
let name = url.lastPathComponent
|
|
guard videoExt.contains((name as NSString).pathExtension.lowercased()) else { continue }
|
|
out[name.lowercased()] = url.path
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MARK: persistence
|
|
|
|
private static var storeURL: URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/tv-anarchy/downloads-index.json")
|
|
}
|
|
private static func loadPersisted() -> [String: String] {
|
|
guard let data = try? Data(contentsOf: storeURL),
|
|
let map = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
|
|
return map
|
|
}
|
|
private static func persist(_ map: [String: String]) {
|
|
try? FileManager.default.createDirectory(at: storeURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
guard let data = try? JSONEncoder().encode(map) else { return }
|
|
try? data.write(to: storeURL, options: .atomic)
|
|
}
|
|
}
|