tv-anarchy/Sources/TVAnarchyCore/Library/LibraryIndex.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
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>
2026-06-08 22:04:22 -07:00

66 lines
3.6 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 { 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: "'\\''") + "'"
}
}