tv-anarchy/Sources/TVAnarchyCore/Metadata/ArtworkService.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

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