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

83 lines
4.5 KiB
Swift

import Foundation
/// Fetches the library index that black maintains out-of-band (`build_index.sh`
/// writes a flat `sizemtimepath` TSV), so a "scan" on plum is one instant SSH
/// `cat` instead of a minutes-long NFS walk. The walk is disk-bound on black's
/// ZFS pool (~3.5 min) regardless of where it runs, so it must NOT happen in the
/// app's hot path black builds the index on a finished-download trigger; plum
/// just reads it (and falls back to a live walk when black is unreachable).
public enum LibraryIndex {
/// Matches the transmission bridge default + the ControlMaster it keeps warm.
static var host: String { DevicesConfig.storageSSHHost() }
static let indexPath = "/bigdisk/_/media/_tools/index.tsv"
static let builder = "/bigdisk/_/media/_tools/build_index.sh"
private static let control = [
"-o", "ControlPath=/tmp/tva-cm-%r@%h:%p",
"-o", "ConnectTimeout=12",
"-o", "BatchMode=yes",
]
private static let videoFind = #"""
-type f \( -iname '*.mkv' -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.avi' -o -iname '*.mov' -o -iname '*.webm' \)
"""#
/// Walk specific finished-download folders on black (one SSH `find`) and return
/// index-shaped TSV lines (`sizemtimepath`). Used to land completed torrents
/// in the library immediately no waiting for `build_index.sh --add`.
public static func fetchFolderLines(_ folders: [String]) -> String? {
guard !folders.isEmpty else { return nil }
let finds = folders.map { "find \(shq($0)) \(videoFind) -printf '%s\\t%T@\\t%p\\n' 2>/dev/null" }
let cmd = finds.joined(separator: "; ")
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, cmd])
guard r.ok else { return nil }
let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return out.isEmpty ? nil : out
}
/// The prebuilt index TSV, or nil when black is unreachable or it hasn't been
/// built yet (caller falls back to a local walk).
public static func fetch() -> String? {
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, "cat \(indexPath)"])
guard r.ok, !r.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return r.stdout
}
/// Kick a background, low-priority (nice/ionice) rebuild on black fire and
/// forget. `addDir` appends one finished-download folder cheaply; nil rebuilds
/// the whole index ("overscan"). Returns false if the SSH itself failed.
@discardableResult
public static func rebuild(addDir: String? = nil) -> Bool {
let args = addDir.map { "--add \(shq($0))" } ?? ""
// best-effort-LOW I/O (ionice -c2 -n6), NOT idle (-c3): black seeds 200+
// torrents at load ~13, where idle-class I/O is starved to a near-halt
// (a full rebuild made ~17% in 15 min). `setsid </dev/null` so the ssh
// channel closes cleanly instead of hanging on the backgrounded job.
let cmd = "nice -n 10 ionice -c2 -n6 sh \(builder) \(args) >/tmp/tva-index.log 2>&1"
let remote = "setsid sh -c \(shq(cmd)) </dev/null >/dev/null 2>&1 &"
return ProcessRunner.run("/usr/bin/ssh", control + [host, remote]).ok
}
/// Line count of the current (last-good) index the denominator for a rebuild
/// progress bar. nil if unreachable / not built.
public static func indexCount() -> Int? {
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, "wc -l < \(indexPath) 2>/dev/null"])
return r.ok ? Int(r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) : nil
}
/// Live full-rebuild progress in ONE round-trip: lines written to the temp file
/// so far + whether the build is still running. nil if unreachable. Drives the
/// determinate progress bar (lines-so-far / prior `indexCount`).
public static func buildStatus() -> (lines: Int, building: Bool)? {
let dir = (indexPath as NSString).deletingLastPathComponent
let cmd = "L=$(cat \(dir)/index.tsv.tmp.* 2>/dev/null | wc -l); " +
"if ls \(dir)/index.tsv.tmp.* >/dev/null 2>&1; then echo \"$L 1\"; else echo \"$L 0\"; fi"
let r = ProcessRunner.run("/usr/bin/ssh", control + [host, cmd])
let parts = r.stdout.split(separator: " ")
guard r.ok, parts.count == 2, let lines = Int(parts[0]) else { return nil }
return (lines, parts[1] == "1")
}
private static func shq(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}