tv-anarchy/Sources/TVAnarchyCore/Library/PreviewService.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

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: "'\\''") + "'"
}
}