feat(library): optimize scan merging with cached metadata

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 22:16:25 -07:00
parent 0a4cde36d1
commit 83a21ca105
5 changed files with 73 additions and 25 deletions

View file

@ -451,17 +451,22 @@ public final class LibraryController {
let scanned = await scan.value
spinnerBound.cancel()
applyScan(scanned, previous: previous)
// Merge off-main: it may read one cached `.meta` JSON per show.
let merged = scanned.isEmpty ? scanned : await Task.detached(priority: .utility) {
LibraryScanner.mergeEnrichment(scanned, from: previous)
}.value
applyScan(merged)
refreshing = false
scanInFlight = false
await combineSplitShows() // re-combine after a fresh scan (cached decisions reused)
}
/// Fold a completed scan into the UI state (or fall back to the registry when
/// the mount yielded nothing and we have no cache). Persists the snapshot.
private func applyScan(_ scanned: [CachedShow], previous: [CachedShow]) {
/// Fold a completed scan (already enrichment-merged) into the UI state (or fall
/// back to the registry when the mount yielded nothing and we have no cache).
/// Persists the snapshot.
private func applyScan(_ scanned: [CachedShow]) {
if !scanned.isEmpty {
shows = LibraryScanner.mergeEnrichment(scanned, from: previous)
shows = scanned
source = "scan"
lastRefresh = Date()
LibraryStore.save(LibrarySnapshot(shows: shows, capturedAt: Date(), source: "scan"))

View file

@ -194,19 +194,33 @@ public enum LibraryScanner {
return lhs.episode < rhs.episode
}
/// Carry forward poster/overview from a prior snapshot onto a fresh scan,
/// keyed by rootDir, so a rescan never drops Phase-4 enrichment.
/// Carry forward poster/overview from a prior snapshot onto a fresh scan, then
/// backfill anything still missing from the `.meta` cache. Keys are normalized
/// to black-side form so enrichment survives the legacy-plum black-side path
/// switch (a raw rootDir match would silently drop every poster). The `.meta`
/// cache is the durable record: re-folding it here means one bad snapshot can
/// never lose artwork permanently.
public static func mergeEnrichment(_ scanned: [CachedShow], from previous: [CachedShow]) -> [CachedShow] {
let prior = Dictionary(previous.map { ($0.rootDir, $0) }, uniquingKeysWith: { a, _ in a })
let prior = Dictionary(previous.map { (MediaPaths.toRemote($0.rootDir), $0) },
uniquingKeysWith: { a, _ in a })
return scanned.map { show in
guard let old = prior[show.rootDir] else { return show }
var s = show
s.posterPath = old.posterPath
s.overview = old.overview
// re-attach per-episode metaPath by episode path
let oldMeta = Dictionary(old.episodes.map { ($0.path, $0.metaPath) }, uniquingKeysWith: { a, _ in a })
s.episodes = s.episodes.map { ep in
var e = ep; if let m = oldMeta[ep.path] ?? nil { e.metaPath = m }; return e
let key = MediaPaths.toRemote(show.rootDir)
if let old = prior[key] {
s.posterPath = old.posterPath
s.overview = old.overview
// re-attach per-episode metaPath by (normalized) episode path
let oldMeta = Dictionary(old.episodes.map { (MediaPaths.toRemote($0.path), $0.metaPath) },
uniquingKeysWith: { a, _ in a })
s.episodes = s.episodes.map { ep in
var e = ep
if let m = oldMeta[MediaPaths.toRemote(ep.path)] ?? nil { e.metaPath = m }
return e
}
}
if s.posterPath == nil || s.overview == nil, let meta = MetaWriter.loadCache(forPath: key) {
if s.posterPath == nil { s.posterPath = meta.posterURL }
if s.overview == nil { s.overview = meta.overview }
}
return s
}

View file

@ -11,8 +11,11 @@ public enum MetaWriter {
.appendingPathComponent(".local/state/tv-anarchy/meta")
}
/// Cache key is the sha256 of the CANONICAL (black-side) path, so lookups hit
/// regardless of whether the caller holds a legacy plum mount path or the
/// black-side form the scanner now emits.
public static func cacheURL(forPath path: String) -> URL {
let digest = SHA256.hash(data: Data(path.utf8))
let digest = SHA256.hash(data: Data(MediaPaths.toRemote(path).utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined()
return metaDir().appendingPathComponent("\(hex).json")
}

View file

@ -1,9 +1,10 @@
#!/usr/bin/env bash
# black-tv — drive mpv on black's HDMI TV (NVIDIA GTX 660 Ti / nouveau / DRM).
#
# Source of truth: plum-control-mcp/src/blacktv/black-tv.sh
# Deployed to /usr/local/bin/black-tv on black; invoked over SSH by the
# plum-control MCP `blacktv` module (mirrors transmission-remote-over-ssh).
# Vendored identically in tv-anarchy (mcp/src/blacktv) and plum-control-mcp
# (src/blacktv); deployed to /usr/local/bin/black-tv on black and invoked over
# SSH by the plum-control MCP `blacktv` module. Keep all three copies in sync —
# the tv-anarchy app's Devices tab flags a deploy as stale by comparing shas.
#
# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`/
# `restart` goes through its JSON IPC socket, so volume/seek/pause never restart
@ -141,10 +142,26 @@ build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
| sort > "$PLAYLIST"
wc -l < "$PLAYLIST"
}
resolve_show() { # <query> -> shortest-named matching show dir under tv/cartoons/anime
find "$MEDIA_ROOT"/tv "$MEDIA_ROOT"/cartoons "$MEDIA_ROOT"/anime \
# Some shows are stored as one sibling dir per season ("Foo Season 3 Complete
# 720p ..."), which a single substring match can't tell apart. names_season
# tests whether a dir name designates a season: "Season 3", "Season 03", "S03".
names_season() { # <name> <season> -> exit 0 when the name designates that season
printf '%s' "$1" | grep -qiE "(season[ ._-]*|s)0*$2([^0-9]|\$)"
}
resolve_show() { # <query> [season] -> best matching show dir under tv/cartoons/anime
local matches d
matches=$(find "$MEDIA_ROOT"/tv "$MEDIA_ROOT"/cartoons "$MEDIA_ROOT"/anime \
-mindepth 1 -maxdepth 1 -type d -iname "*$1*" 2>/dev/null \
| awk '{ print length, $0 }' | sort -n | cut -d' ' -f2- | head -1
| awk '{ print length, $0 }' | sort -n | cut -d' ' -f2-)
[ -n "$matches" ] || return 0
# With per-season sibling dirs, a dir naming the requested season beats the
# generic shortest-name pick (which lands on whichever season sorts first).
if [ -n "${2:-}" ]; then
while IFS= read -r d; do
if names_season "$(basename "$d")" "$2"; then printf '%s\n' "$d"; return 0; fi
done <<<"$matches"
fi
head -1 <<<"$matches"
}
# Some show dirs hold several self-contained releases side by side (e.g. a full
# 1080p series, a 720p series, and standalone movies). Pick the versioned
@ -172,7 +189,16 @@ build_show_playlist() { # <showdir> <season?> <episode?> -> writes $PLAYLIST
local sxe start
sxe=$(printf 'S%02dE%02d' "$season" "${ep:-1}")
start=$(grep -in "$sxe" "$PLAYLIST.all" | head -1 | cut -d: -f1)
if [ -n "$start" ]; then tail -n +"$start" "$PLAYLIST.all" > "$PLAYLIST"; else cp "$PLAYLIST.all" "$PLAYLIST"; fi
if [ -n "$start" ]; then
tail -n +"$start" "$PLAYLIST.all" > "$PLAYLIST"
elif names_season "$(basename "$showdir")" "$season"; then
# Per-season dir with nonstandard episode names — the whole dir IS the
# requested season, so start from its beginning.
cp "$PLAYLIST.all" "$PLAYLIST"
else
rm -f "$PLAYLIST.all"
die "no $sxe under $(basename "$showdir") — refusing to start the wrong season"
fi
else
cp "$PLAYLIST.all" "$PLAYLIST"
fi
@ -340,7 +366,7 @@ case "$cmd" in
launch "$PLAYLIST"; echo "playing $n item(s)" ;;
play-show)
[ $# -ge 1 ] || die "usage: black-tv play-show <query> [season] [episode]"
showdir=$(resolve_show "$1") || true
showdir=$(resolve_show "$1" "${2:-}") || true
[ -n "$showdir" ] || die "show not found: $1"
build_show_playlist "$showdir" "${2:-}" "${3:-}"
n=$(wc -l < "$PLAYLIST")

View file

@ -28,7 +28,7 @@ export const BLACKTV_TOOLS = [
},
{
name: "black_play_show",
description: "Play a show on black's TV. Resolves a show directory under black's local library (tv/cartoons/anime) by case-insensitive substring, builds an ordered playlist (preferring a 1080p release when several exist), and plays it through to the end. Brings up the display driver automatically. NOTE: the TV must be powered on physically — there is no HDMI-CEC.",
description: "Play a show on black's TV. Resolves a show directory under black's local library (tv/cartoons/anime) by case-insensitive substring, builds an ordered playlist (preferring a 1080p release when several exist), and plays it through to the end. When a show is stored as one directory per season, a requested season selects the directory naming that season (playback then covers that season); if the requested season can't be located, the call errors instead of starting the wrong season. Brings up the display driver automatically. NOTE: the TV must be powered on physically — there is no HDMI-CEC.",
inputSchema: {
type: "object" as const,
properties: {