diff --git a/Sources/TVAnarchyCore/Library/LibraryController.swift b/Sources/TVAnarchyCore/Library/LibraryController.swift index 4d1d64e..ee2b67a 100644 --- a/Sources/TVAnarchyCore/Library/LibraryController.swift +++ b/Sources/TVAnarchyCore/Library/LibraryController.swift @@ -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")) diff --git a/Sources/TVAnarchyCore/Library/LibraryScanner.swift b/Sources/TVAnarchyCore/Library/LibraryScanner.swift index 2905d37..8fec1f3 100644 --- a/Sources/TVAnarchyCore/Library/LibraryScanner.swift +++ b/Sources/TVAnarchyCore/Library/LibraryScanner.swift @@ -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 } diff --git a/Sources/TVAnarchyCore/Metadata/MetaWriter.swift b/Sources/TVAnarchyCore/Metadata/MetaWriter.swift index b14a1eb..e18f410 100644 --- a/Sources/TVAnarchyCore/Metadata/MetaWriter.swift +++ b/Sources/TVAnarchyCore/Metadata/MetaWriter.swift @@ -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") } diff --git a/mcp/src/blacktv/black-tv.sh b/mcp/src/blacktv/black-tv.sh index 5c28829..89f3efa 100644 --- a/mcp/src/blacktv/black-tv.sh +++ b/mcp/src/blacktv/black-tv.sh @@ -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() { # -> writes $PLAYLIST, echoes count | sort > "$PLAYLIST" wc -l < "$PLAYLIST" } -resolve_show() { # -> 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() { # -> exit 0 when the name designates that season + printf '%s' "$1" | grep -qiE "(season[ ._-]*|s)0*$2([^0-9]|\$)" +} +resolve_show() { # [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() { # -> 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 [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") diff --git a/mcp/src/blacktv/tools.ts b/mcp/src/blacktv/tools.ts index f1882aa..c55691d 100644 --- a/mcp/src/blacktv/tools.ts +++ b/mcp/src/blacktv/tools.ts @@ -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: {