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>
83 lines
4.5 KiB
Swift
83 lines
4.5 KiB
Swift
import Foundation
|
|
|
|
/// Fetches the library index that black maintains out-of-band (`build_index.sh`
|
|
/// writes a flat `size⇥mtime⇥path` 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 (`size⇥mtime⇥path`). 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: "'\\''") + "'"
|
|
}
|
|
}
|