aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 15:57:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 15:57:41 +0200
commitc5585beab3d6adefaa2ef9444946dd3816960a7c (patch)
treea321e540c2db0458716d526e4a045fca123937b2 /packages/meshbay-node/src/meshbay_node/indexer/indexer.py
parent317f09328ed8bf20148b707470c9b0fe82e59575 (diff)
downloadmeshbay-c5585beab3d6adefaa2ef9444946dd3816960a7c.tar.gz
fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggle
Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/indexer.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py108
1 files changed, 78 insertions, 30 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index b479f7e..b643046 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -226,6 +226,18 @@ class DirectoryIndexer:
self._reconciler: asyncio.Task | None = None
self._pending_timers: dict[str, asyncio.TimerHandle] = {}
self.progress = IndexProgress()
+ # Real-time watchdog adds (_debounce/_update_entry below) previously
+ # never touched `progress` at all — a whole season dropped into an
+ # already-watched folder gave the operator no scanning indicator and
+ # no progress bar, files just appeared one at a time with no feedback
+ # (found live). `_burst_inflight` counts files currently scheduled or
+ # being hashed in the current burst; `scanning` only drops back to
+ # False once it reaches zero *and* no more timers are pending — a
+ # debounce timer firing is not the same as the hash it schedules
+ # having finished, and the whole point of a progress indicator is to
+ # stay up for exactly as long as the slow part (hashing) is running.
+ self._burst_inflight = 0
+ self._burst_sizes: dict[str, int] = {}
@property
def index(self) -> GroupIndex:
@@ -637,6 +649,28 @@ class DirectoryIndexer:
if old:
old.cancel()
+ # Progress accounting for the real-time path — see `_burst_inflight`'s
+ # docstring in __init__. Only on this key's *first* appearance in the
+ # current burst: a rapid re-trigger of the same path (several writes
+ # debounced together) cancels the old timer above and must not also
+ # double-count its size, so `_burst_sizes` (keyed the same as
+ # `_pending_timers`) is the source of truth for "already counted",
+ # not "does a timer currently exist for it" — a cancelled timer's
+ # bookkeeping still has to reach the `fire()` that actually runs.
+ if not deleted and key not in self._burst_sizes:
+ try:
+ size = file_path.stat().st_size
+ except OSError:
+ size = 0
+ if not self.progress.scanning:
+ self.progress.scanning = True
+ self.progress.scanned_bytes = 0
+ self.progress.total_bytes = 0
+ self.progress.total_bytes += size
+ self.progress.current_dir = file_path.parent.name
+ self._burst_sizes[key] = size
+ self._burst_inflight += 1
+
def fire() -> None:
self._pending_timers.pop(key, None)
asyncio.ensure_future(self._update_entry(file_path, deleted))
@@ -654,41 +688,55 @@ class DirectoryIndexer:
self._index.remove_entry(entry.id)
async def _update_entry(self, file_path: Path, deleted: bool) -> None:
- root = self._root_for(file_path)
- if root is None:
- return
+ try:
+ root = self._root_for(file_path)
+ if root is None:
+ return
- if deleted and not root.is_live():
- # The volume went away rather than the file. Freeze: mark the root
- # and touch nothing. Every other event for this root will arrive
- # here too and be dropped the same way, which is the intent — one
- # unplugged drive must not empty a library.
- if root.available:
- root.available = False
- self._index.roots = self.roots.describe()
- log.warning("Root %r disappeared — ignoring deletion events and "
- "freezing %d entries", root.name,
- len(self._entries_under(root)))
- self._index.version = int(time.time())
- if self.on_change:
- await self.on_change(self)
- return
+ if deleted and not root.is_live():
+ # The volume went away rather than the file. Freeze: mark the
+ # root and touch nothing. Every other event for this root
+ # will arrive here too and be dropped the same way, which is
+ # the intent — one unplugged drive must not empty a library.
+ if root.available:
+ root.available = False
+ self._index.roots = self.roots.describe()
+ log.warning("Root %r disappeared — ignoring deletion events and "
+ "freezing %d entries", root.name,
+ len(self._entries_under(root)))
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+ return
- if not root.available:
- return
+ if not root.available:
+ return
- self._remove_by_path(root, file_path)
+ self._remove_by_path(root, file_path)
- if not deleted:
- entry = await self._hash_or_cached(root, file_path)
- if entry:
- self._index.add_entry(entry)
- log.debug("Indexed: %s (%s, %d bytes)",
- file_path.name, entry.id[:8], entry.size)
+ if not deleted:
+ entry = await self._hash_or_cached(root, file_path)
+ if entry:
+ self._index.add_entry(entry)
+ log.debug("Indexed: %s (%s, %d bytes)",
+ file_path.name, entry.id[:8], entry.size)
- self._index.version = int(time.time())
- if self.on_change:
- await self.on_change(self)
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+ finally:
+ # Mirror image of the accounting in _debounce, run whichever way
+ # this method exits (including the several early returns above) —
+ # otherwise a frozen/unavailable root's files would leave
+ # `scanning` stuck True forever, the exact bug this is fixing but
+ # in the other direction.
+ size = self._burst_sizes.pop(str(file_path), None)
+ if size is not None:
+ self.progress.scanned_bytes += size
+ self._burst_inflight -= 1
+ if self._burst_inflight <= 0 and not self._pending_timers:
+ self.progress.scanning = False
+ self.progress.current_dir = ""
class _WatchdogHandler(FileSystemEventHandler):