tv-anarchy/Sources/TVAnarchyCore/Library/DownloadsIndex.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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