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>
68 lines
3.5 KiB
Swift
68 lines
3.5 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
/// Builds + fetches short hover-preview clips. The clip is made ON black (ffmpeg:
|
|
/// blackdetect to skip the intro, then an 8s muted 480p extract — ~4s of work,
|
|
/// cheap because video reads are sequential even though black's metadata is slow),
|
|
/// cached on black AND locally on plum. Lazy + single-flight: one build per show,
|
|
/// only when actually hovered, gated by the `hoverPreviews` setting.
|
|
public actor PreviewService {
|
|
public static let shared = PreviewService()
|
|
|
|
private static var host: String { DevicesConfig.storageSSHHost() }
|
|
private static let builder = "/bigdisk/_/media/_tools/make_preview.sh"
|
|
private static let remoteDir = "/bigdisk/_/media/_tools/previews"
|
|
private static let control = ["-o", "ControlPath=/tmp/tva-cm-%r@%h:%p", "-o", "ConnectTimeout=12", "-o", "BatchMode=yes"]
|
|
|
|
private static var cacheDir: URL {
|
|
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/state/tv-anarchy/previews")
|
|
}
|
|
|
|
/// In-flight builds, so concurrent hovers on the same poster coalesce.
|
|
private var inFlight: [String: Task<URL?, Never>] = [:]
|
|
|
|
/// Local file URL of the preview for `plumPath` (a representative episode/file),
|
|
/// building it on black + fetching if not cached. nil on any failure (caller
|
|
/// just keeps showing the static poster).
|
|
public func preview(forPlumPath plumPath: String) async -> URL? {
|
|
let digest = Self.digest(plumPath)
|
|
let local = Self.cacheDir.appendingPathComponent("\(digest).mp4")
|
|
if FileManager.default.fileExists(atPath: local.path) { return local }
|
|
if let running = inFlight[digest] { return await running.value }
|
|
|
|
let task = Task<URL?, Never> { [plumPath] in
|
|
await Self.buildAndFetch(plumPath: plumPath, digest: digest, local: local)
|
|
}
|
|
inFlight[digest] = task
|
|
let result = await task.value
|
|
inFlight[digest] = nil
|
|
return result
|
|
}
|
|
|
|
private static func buildAndFetch(plumPath: String, digest: String, local: URL) async -> URL? {
|
|
let remotePath = MediaPaths.toRemote(plumPath)
|
|
let remoteOut = "\(remoteDir)/\(digest).mp4"
|
|
return await Task.detached(priority: .utility) { () -> URL? in
|
|
// Build on black (idempotent — the script no-ops if the clip exists),
|
|
// low-priority so it never fights playback/seeding.
|
|
let build = "nice -n 10 ionice -c2 -n6 sh \(shq(builder)) \(shq(remotePath)) \(shq(remoteOut)) 8"
|
|
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, build])
|
|
guard r.ok else { Log.warn("preview build failed: \(r.stderr.suffix(120))"); return nil }
|
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
|
// Fetch the binary clip with scp (reuses the warm ControlMaster socket).
|
|
let scp = ProcessRunner.run("/usr/bin/scp", control + ["\(host):\(remoteOut)", local.path])
|
|
guard scp.ok, FileManager.default.fileExists(atPath: local.path) else {
|
|
Log.warn("preview fetch failed for \((plumPath as NSString).lastPathComponent)")
|
|
return nil
|
|
}
|
|
return local
|
|
}.value
|
|
}
|
|
|
|
private static func digest(_ s: String) -> String {
|
|
SHA256.hash(data: Data(s.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
private static func shq(_ s: String) -> String {
|
|
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
}
|