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() { #