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>
104 lines
5.7 KiB
Swift
104 lines
5.7 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
/// Last-resort cover art: a single frame grabbed from the video file itself with
|
|
/// ffmpeg. Keyless and always available, so movies/porn/unsorted (and anything a
|
|
/// database can't match) still get a thumbnail instead of an initials placeholder.
|
|
/// The frame is cached as a JPEG under the app state dir; the cached path is
|
|
/// returned as a local `posterPath` (ShowPoster already renders file URLs).
|
|
///
|
|
/// No NFS: a downloaded local copy is grabbed in-process; otherwise the frame is
|
|
/// grabbed ON black over SSH (where the file is local — same approach as
|
|
/// `PreviewService`) and fetched with scp. The library is black-side, so the
|
|
/// remote path needs no mount.
|
|
public final class ArtworkService: Sendable {
|
|
private let ffmpeg: String
|
|
|
|
public init(ffmpeg: String? = nil) {
|
|
self.ffmpeg = ffmpeg
|
|
?? ProcessInfo.processInfo.environment["FFMPEG_BIN"]
|
|
?? "/opt/homebrew/bin/ffmpeg"
|
|
}
|
|
|
|
private static var cacheDir: URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".local/state/tv-anarchy/posters")
|
|
}
|
|
|
|
// Remote frame-grab on black — mirrors PreviewService's host/socket so both
|
|
// reuse the same warm ControlMaster connection.
|
|
private static var host: String { DevicesConfig.storageSSHHost() }
|
|
private static let remoteFFmpeg = ProcessInfo.processInfo.environment["BLACK_FFMPEG_BIN"] ?? "ffmpeg"
|
|
private static let remoteTmpDir = "/bigdisk/_/media/_tools/posters"
|
|
// ControlMaster reuse so a bulk enrich (one grab per unmatched item, 3 at a
|
|
// time) multiplexes over ONE SSH connection instead of a fresh handshake each.
|
|
private static let control = ["-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/tva-cm-%r@%h:%p",
|
|
"-o", "ControlPersist=30s", "-o", "ConnectTimeout=12", "-o", "BatchMode=yes"]
|
|
|
|
/// Grab a frame from `videoPath` (seeking past the cold open) into a cached
|
|
/// JPEG, returning its path. Returns a prior cache hit without re-running
|
|
/// ffmpeg. nil if ffmpeg isn't present or the grab fails.
|
|
public func frameGrab(videoPath: String) async -> String? {
|
|
guard FileManager.default.fileExists(atPath: ffmpeg) else { return nil }
|
|
// Key the cache on the canonical (black-side) path so the hit is stable
|
|
// whether the file was grabbed locally or on black.
|
|
let remotePath = MediaPaths.toRemote(videoPath)
|
|
let digest = SHA256.hash(data: Data(remotePath.utf8))
|
|
.prefix(8).map { String(format: "%02x", $0) }.joined()
|
|
let out = Self.cacheDir.appendingPathComponent("\(digest).jpg")
|
|
if FileManager.default.fileExists(atPath: out.path) { return out.path }
|
|
try? FileManager.default.createDirectory(at: Self.cacheDir, withIntermediateDirectories: true)
|
|
|
|
let name = (videoPath as NSString).lastPathComponent
|
|
// Fast path: a downloaded local copy — grab in-process, no network.
|
|
if let local = MediaPaths.localCopy(of: videoPath) {
|
|
return await Self.grabLocal(ffmpeg: ffmpeg, input: local, out: out.path, name: name)
|
|
}
|
|
// Otherwise grab on black, where the file is local.
|
|
return await Self.grabRemote(remotePath: remotePath, digest: digest, out: out.path, name: name)
|
|
}
|
|
|
|
/// In-process ffmpeg against a local file. `-ss` before `-i` = fast seek; one
|
|
/// frame; scale to poster width. runShell drains ffmpeg's stderr concurrently
|
|
/// and bounds the grab; `-nostdin` so it can't block on input.
|
|
private static func grabLocal(ffmpeg: String, input: String, out: String, name: String) async -> String? {
|
|
let argv = [ffmpeg, "-nostdin", "-y", "-ss", "120", "-i", input,
|
|
"-frames:v", "1", "-vf", "scale=400:-2", out]
|
|
let command = argv.map(shq).joined(separator: " ")
|
|
let r: ProcessResult = await Task.detached(priority: .utility) {
|
|
ProcessRunner.runShell(command, timeout: 30)
|
|
}.value
|
|
guard r.ok, FileManager.default.fileExists(atPath: out) else {
|
|
Log.error("frame-grab (local) failed (exit \(r.status)) for \(name): \(r.stderr.suffix(200))")
|
|
return nil
|
|
}
|
|
Log.info("frame-grab (local) ok for \(name)")
|
|
return out
|
|
}
|
|
|
|
/// Grab the frame on black (file is local there) into a remote tmp JPEG, then
|
|
/// scp it to the local cache and remove the remote tmp. Low priority so it never
|
|
/// fights playback/seeding. Reuses the ControlMaster socket like PreviewService.
|
|
private static func grabRemote(remotePath: String, digest: String, out: String, name: String) async -> String? {
|
|
let remoteOut = "\(remoteTmpDir)/\(digest).jpg"
|
|
return await Task.detached(priority: .utility) { () -> String? in
|
|
let build = "mkdir -p \(shq(remoteTmpDir)) && nice -n 10 ionice -c2 -n6 "
|
|
+ "\(shq(remoteFFmpeg)) -nostdin -y -ss 120 -i \(shq(remotePath)) "
|
|
+ "-frames:v 1 -vf scale=400:-2 \(shq(remoteOut))"
|
|
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, build])
|
|
guard r.ok else { Log.warn("frame-grab (black) failed for \(name): \(r.stderr.suffix(160))"); return nil }
|
|
let scp = ProcessRunner.run("/usr/bin/scp", control + ["\(host):\(remoteOut)", out])
|
|
_ = ProcessRunner.run("/usr/bin/ssh", control + [host, "rm -f \(shq(remoteOut))"])
|
|
guard scp.ok, FileManager.default.fileExists(atPath: out) else {
|
|
Log.warn("frame-grab (black) fetch failed for \(name)")
|
|
return nil
|
|
}
|
|
Log.info("frame-grab (black) ok for \(name)")
|
|
return out
|
|
}.value
|
|
}
|
|
|
|
private static func shq(_ s: String) -> String {
|
|
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
}
|