feat(library): ✨ optimize scan merging with cached metadata
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
0a4cde36d1
commit
83a21ca105
5 changed files with 73 additions and 25 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue