Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename the auto-commit service couldn't stage — it git-add'd the old, now-gone paths and aborted every cycle), and commits the accumulated work: - Library: black-built index fast path (LibraryIndex + scanFromIndex) with NFS-walk fallback; incremental --add on download-complete; mtime staleness gate; loose-file series-collapse fix; determinate scan/index progress. - Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year). - Player: sleep timer (timed + end-of-episode); visibility-gated polling. - Home: Continue Watching cover art + live refresh; Recently Added; adult gate. - Logs: multi-line selection + copy; truncated giant tx-list errors. - Hover previews (opt-in) via black ffmpeg + scp. Also gitignores foreign project trees (governor/mcp/fleet/recommender) that sit in this directory but belong to their own repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
66 lines
3.6 KiB
Swift
66 lines
3.6 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 { ProcessInfo.processInfo.environment["BLACK_SSH_HOST"] ?? "lilith@10.0.0.11" }
|
|
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",
|
|
]
|
|
|
|
/// 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: "'\\''") + "'"
|
|
}
|
|
}
|