Fix rescan loop: short-circuited registration dropped files from state

The directory worker registered video files with
'node["changed"] = node["changed"] or await _register_candidate(...)',
so once one file in a directory was found changed, the rest were never
registered: they fell out of known_paths, were pruned from the persisted
seen-mtimes at finalize, and were rediscovered as new on every scan.
That rotating ~10-item subset was reprocessed (metadata, TMDb lookups,
logs) every 30 seconds forever.

Also fix the self-sustaining reprocess loop for items that produce no
index entries (no playable file, no parseable episodes, non-media):
they never appear in the index, so the DB-aware mtime gating alone could
never skip them.  They are now recorded in a persisted empty_mtimes map
(scan-state.json) and skipped while their mtime is unchanged.

Episode/playable/blu-ray finder caches are now cleared at the start of
each scan: an empty result cached before a download finished (or during
a transient network-mount error) previously stuck for the process
lifetime and reported "no episodes found" for series that have episodes.

Logging: a no-change scan now emits a single line with item count and
duration; per-item "up to date" chatter moved to debug; change scans
report the new/changed count plus per-item reason lines (stored vs
current mtime, index/empty-marker membership), finalize prune/commit
traces, and scan-state load/save confirmations.

Add AGENTS.md noting the project's Python 3.14 baseline (PEP 758
unparenthesized except) so tooling does not flag it as a syntax error.
This commit is contained in:
2026-09-09 14:40:32 +00:00
parent 36e2fdd5ff
commit ff6b195973
4 changed files with 193 additions and 23 deletions
+20
View File
@@ -0,0 +1,20 @@
# MediaHive agent notes
## Python 3.14 syntax: unparenthesized `except` is valid
This project targets Python **>= 3.14** (see `requires-python` in
`pyproject.toml`). Per [PEP 758](https://peps.python.org/pep-0758/) (Final,
Python 3.14), multiple exception types may be caught **without parentheses**:
```python
except OSError, ValueError: # valid Python 3.14+, equivalent to except (OSError, ValueError):
```
Parentheses are still required when an `as` clause is used:
`except (OSError, ValueError) as e:`.
Do not "fix" these into tuple form, and do not flag them as Python 2 remnant
syntax errors — that rule is obsolete training data. Any syntax validation,
compilation check, or linting of this codebase must run under Python 3.14+
(e.g. `python -m py_compile` with a 3.14 interpreter); older interpreters
will report false SyntaxErrors on this and other 3.14-only constructs.
+10 -2
View File
@@ -951,7 +951,11 @@ async def _process_series(
) )
if not seasons_data: if not seasons_data:
logger.info(" Skipping %s - no episodes found", display_title) logger.info(
" Skipping %s - no episodes in the %d scanned torrent(s)",
display_title,
len(items),
)
continue continue
different_titles = sorted( different_titles = sorted(
@@ -1003,7 +1007,11 @@ async def _process_series(
) )
if not seasons_data: if not seasons_data:
logger.info(" Skipping %s - no episodes found", title) logger.info(
" Skipping %s - no episodes in the %d scanned torrent(s)",
title,
len(items),
)
continue continue
item_timestamps = [await get_added_timestamp(item.path) for item in items] item_timestamps = [await get_added_timestamp(item.path) for item in items]
+151 -20
View File
@@ -29,7 +29,7 @@ from mediahive.hivescan.indexer import _process_movies, _process_series
from mediahive.hivescan.models import ContentType, ParsedContent from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.parsing import parse_download from mediahive.hivescan.parsing import parse_download
from mediahive.hivescan.scanignore import ScanIgnore from mediahive.hivescan.scanignore import ScanIgnore
from mediahive.hivescan.scanning import categorize_downloads from mediahive.hivescan.scanning import categorize_downloads, clear_scan_caches
from mediahive.hivescan.showreel import ( from mediahive.hivescan.showreel import (
episode_reel_exists, episode_reel_exists,
generate_episode_reel, generate_episode_reel,
@@ -102,6 +102,14 @@ class RootScanner:
self._showreel_worker_task: asyncio.Task | None = None self._showreel_worker_task: asyncio.Task | None = None
self._rescan_worker_task: asyncio.Task | None = None self._rescan_worker_task: asyncio.Task | None = None
self._seen_mtimes: dict[str, int] = {} self._seen_mtimes: dict[str, int] = {}
# Candidates that were fully processed but produced no index entries
# (no playable file, no parseable episodes, or non-media content).
# Tracked with their mtime so they are not reprocessed on every
# rescan — the DB-aware gating alone cannot skip them, because they
# never appear in the index. An entry is dropped as soon as the
# item's mtime changes or it starts producing index entries.
self._empty_mtimes: dict[str, int] = {}
self._empty_dirty = False
# Per-directory backoff state (relpath -> {until, streak, items}) # Per-directory backoff state (relpath -> {until, streak, items})
self._dir_state: dict[str, dict] = {} self._dir_state: dict[str, dict] = {}
self._dir_state_dirty = False self._dir_state_dirty = False
@@ -184,6 +192,15 @@ class RootScanner:
seen = data.get("seen_mtimes") seen = data.get("seen_mtimes")
if isinstance(seen, dict): if isinstance(seen, dict):
self._seen_mtimes = {str(k): int(v) for k, v in seen.items()} self._seen_mtimes = {str(k): int(v) for k, v in seen.items()}
empty = data.get("empty_mtimes")
if isinstance(empty, dict):
self._empty_mtimes = {str(k): int(v) for k, v in empty.items()}
logger.info(
"Loaded scan state from %s: seen=%d, empty=%d",
self._scan_state_path,
len(self._seen_mtimes),
len(self._empty_mtimes),
)
dir_state = data.get("dir_state") dir_state = data.get("dir_state")
if isinstance(dir_state, dict): if isinstance(dir_state, dict):
for key, value in dir_state.items(): for key, value in dir_state.items():
@@ -199,12 +216,20 @@ class RootScanner:
payload = json.dumps({ payload = json.dumps({
"version": 2, "version": 2,
"seen_mtimes": self._seen_mtimes, "seen_mtimes": self._seen_mtimes,
"empty_mtimes": self._empty_mtimes,
"dir_state": self._dir_state, "dir_state": self._dir_state,
}) })
tmp = self._scan_state_path.with_suffix(".tmp") tmp = self._scan_state_path.with_suffix(".tmp")
tmp.parent.mkdir(parents=True, exist_ok=True) tmp.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(payload, encoding="utf-8") tmp.write_text(payload, encoding="utf-8")
tmp.replace(self._scan_state_path) tmp.replace(self._scan_state_path)
logger.info(
"Saved scan state to %s: seen=%d, empty=%d, dir_state=%d",
self._scan_state_path,
len(self._seen_mtimes),
len(self._empty_mtimes),
len(self._dir_state),
)
except OSError, TypeError, ValueError: except OSError, TypeError, ValueError:
logger.exception("Failed to save scan state to %s", self._scan_state_path) logger.exception("Failed to save scan state to %s", self._scan_state_path)
@@ -477,18 +502,57 @@ class RootScanner:
try: try:
mtime = int((await AsyncPath(path).stat()).st_mtime) mtime = int((await AsyncPath(path).stat()).st_mtime)
except OSError, ValueError: except OSError, ValueError:
logger.info(
"Skipping candidate, stat failed: %s "
"(in_seen=%s, stored_mtime=%s)",
relpath,
relpath in self._seen_mtimes,
self._seen_mtimes.get(relpath),
)
return False return False
if self._seen_mtimes.get(relpath) == mtime: stored = self._seen_mtimes.get(relpath)
if indexed is None or relpath in indexed: if stored == mtime:
if indexed is not None and relpath in indexed:
# Produces index entries; drop any stale empty-marker.
if relpath in self._empty_mtimes:
del self._empty_mtimes[relpath]
self._empty_dirty = True
return False
if indexed is None or self._empty_mtimes.get(relpath) == mtime:
return False return False
# Unchanged on disk but missing from the index (snapshot # Unchanged on disk but missing from the index (snapshot
# wiped, upsert lost, ...): reprocess it unless it is not # wiped, upsert lost, ...): reprocess it unless it is not
# indexable content anyway. # indexable content anyway.
logger.info(
"Reprocessing unchanged item missing from index: %s "
"(stored_mtime=%d == current_mtime=%d, indexed=%s, "
"empty_marker=%s)",
relpath,
stored,
mtime,
"n/a" if indexed is None else "no",
self._empty_mtimes.get(relpath),
)
parsed = await parse_download(path) parsed = await parse_download(path)
if parsed.content_type is ContentType.OTHER: if parsed.content_type is ContentType.OTHER:
self._empty_mtimes[relpath] = mtime
self._empty_dirty = True
return False return False
downloads.append(parsed) downloads.append(parsed)
return True return True
logger.info(
"Treating as new/changed: %s (stored_mtime=%s, "
"current_mtime=%d, in_seen=%s, in_index=%s, empty_marker=%s, "
"seen_total=%d, known_total=%d)",
relpath,
"ABSENT" if stored is None else str(stored),
mtime,
relpath in self._seen_mtimes,
"n/a" if indexed is None else (relpath in indexed),
self._empty_mtimes.get(relpath),
len(self._seen_mtimes),
len(known_paths),
)
found_mtimes[relpath] = mtime found_mtimes[relpath] = mtime
downloads.append(await parse_download(path)) downloads.append(await parse_download(path))
return True return True
@@ -624,20 +688,24 @@ class RootScanner:
if video_files: if video_files:
newest = max(fm for _, fm in video_files) newest = max(fm for _, fm in video_files)
mtime = max(mtime or 0, newest) mtime = max(mtime or 0, newest)
node["changed"] = node["changed"] or await _register_candidate( if await _register_candidate(path, rel, mtime):
path, rel, mtime node["changed"] = True
)
else: else:
for file_path, file_mtime in video_files: for file_path, file_mtime in video_files:
file_rel = make_relative_path( file_rel = make_relative_path(
str(file_path), media_root_str str(file_path), media_root_str
) )
node["items"] = True node["items"] = True
node["changed"] = node[ # No "or" short-circuit here: every file must be
"changed" # registered even after an earlier file in this
] or await _register_candidate( # directory was found changed, otherwise the
# skipped files fall out of known_paths, get
# pruned at finalize, and are rediscovered as
# "new" on every subsequent scan.
if await _register_candidate(
file_path, file_rel, file_mtime file_path, file_rel, file_mtime
) ):
node["changed"] = True
await _enqueue_children(node, child_dirs) await _enqueue_children(node, child_dirs)
except Exception: except Exception:
# Never let one bad directory kill the worker or truncate # Never let one bad directory kill the worker or truncate
@@ -652,7 +720,7 @@ class RootScanner:
_complete_node(node) _complete_node(node)
queue.task_done() queue.task_done()
logger.info("Starting filesystem discovery at %s", self.media_root) logger.debug("Starting filesystem discovery at %s", self.media_root)
await _report(f"Scanning: {self.media_root}") await _report(f"Scanning: {self.media_root}")
stop_event = threading.Event() stop_event = threading.Event()
@@ -702,7 +770,7 @@ class RootScanner:
w.cancel() w.cancel()
await asyncio.gather(*workers, return_exceptions=True) await asyncio.gather(*workers, return_exceptions=True)
logger.info( logger.debug(
"Discovery complete: %d downloads found, %d directories visited", "Discovery complete: %d downloads found, %d directories visited",
len(downloads), len(downloads),
dirs_visited, dirs_visited,
@@ -718,15 +786,42 @@ class RootScanner:
prunes vanished paths, persists state when anything changed, and sends prunes vanished paths, persists state when anything changed, and sends
the Sync event that lets the store drop deleted torrents. the Sync event that lets the store drop deleted torrents.
""" """
pruned = sorted(k for k in self._seen_mtimes if k not in known_paths)
if pruned:
logger.info(
"Finalize: pruning %d previously seen path(s) not in this "
"scan's known set: %s%s",
len(pruned),
pruned[:10],
" ..." if len(pruned) > 10 else "",
)
committed = {k: v for k, v in self._seen_mtimes.items() if k in known_paths} committed = {k: v for k, v in self._seen_mtimes.items() if k in known_paths}
committed.update(found_mtimes) committed.update(found_mtimes)
state_changed = committed != self._seen_mtimes state_changed = committed != self._seen_mtimes
self._seen_mtimes = committed self._seen_mtimes = committed
if found_mtimes:
logger.info(
"Finalize: committing %d new/changed mtime(s): %s%s",
len(found_mtimes),
sorted(found_mtimes)[:10],
" ..." if len(found_mtimes) > 10 else "",
)
# Drop empty-markers for vanished or since-changed paths.
pruned_empty = {
k: v
for k, v in self._empty_mtimes.items()
if k in known_paths and committed.get(k) == v
}
if pruned_empty != self._empty_mtimes:
self._empty_mtimes = pruned_empty
self._empty_dirty = True
await self._send(Sync(paths=sorted(known_paths))) await self._send(Sync(paths=sorted(known_paths)))
if state_changed or self._dir_state_dirty: if state_changed or self._dir_state_dirty or self._empty_dirty:
self._dir_state_dirty = False self._dir_state_dirty = False
self._empty_dirty = False
await asyncio.to_thread(self._save_scan_state) await asyncio.to_thread(self._save_scan_state)
if probe_records_dirty(): if probe_records_dirty():
await asyncio.to_thread(save_probe_records) await asyncio.to_thread(save_probe_records)
@@ -744,9 +839,10 @@ class RootScanner:
""" """
task_id = f"scan-{uuid.uuid4().hex[:8]}" task_id = f"scan-{uuid.uuid4().hex[:8]}"
media_root_str = self.media_root.as_posix() media_root_str = self.media_root.as_posix()
started_at = time.monotonic()
try: try:
logger.info("Scan started (%s) for root %s", task_id, self.root_id) logger.debug("Scan started (%s) for root %s", task_id, self.root_id)
await self._send( await self._send(
Task( Task(
data=TaskInfo( data=TaskInfo(
@@ -758,12 +854,19 @@ class RootScanner:
) )
) )
clear_scan_caches()
downloads, found_mtimes, known_paths = await self._discover_downloads( downloads, found_mtimes, known_paths = await self._discover_downloads(
task_id task_id
) )
elapsed = time.monotonic() - started_at
if not downloads: if not downloads:
logger.info("No new downloads found (%s)", task_id) logger.info(
"Scan (%s): no changes — %d items inspected in %.1fs",
task_id,
len(known_paths),
elapsed,
)
await self._finalize_scan(found_mtimes, known_paths) await self._finalize_scan(found_mtimes, known_paths)
await self._send( await self._send(
Task( Task(
@@ -777,7 +880,13 @@ class RootScanner:
) )
return return
logger.info("Found %d items to process", len(downloads)) logger.info(
"Scan (%s): %d new/changed of %d items found in %.1fs",
task_id,
len(downloads),
len(known_paths),
elapsed,
)
await self._send( await self._send(
Task( Task(
data=TaskInfo( data=TaskInfo(
@@ -851,14 +960,14 @@ class RootScanner:
self._showreel_queue.qsize(), self._showreel_queue.qsize(),
) )
else: else:
logger.info( logger.debug(
"[%d/%d] Movie: %s (showreel up to date)", "[%d/%d] Movie: %s (showreel up to date)",
processed + 1, processed + 1,
total, total,
movie.title, movie.title,
) )
else: else:
logger.info( logger.debug(
"[%d/%d] Movie: %s (no showreel task)", "[%d/%d] Movie: %s (no showreel task)",
processed + 1, processed + 1,
total, total,
@@ -935,7 +1044,7 @@ class RootScanner:
self._showreel_queue.qsize(), self._showreel_queue.qsize(),
) )
else: else:
logger.info( logger.debug(
"[%d/%d] Series: %s (reels up to date)", "[%d/%d] Series: %s (reels up to date)",
processed + 1, processed + 1,
total, total,
@@ -954,6 +1063,26 @@ class RootScanner:
) )
) )
# Record processed candidates that yielded no index entries (no
# playable file, no parseable episodes, ...) so later rescans
# skip them while their mtime is unchanged instead of
# reprocessing — and re-logging — them on every pass. The
# snapshot here predates this scan's upserts, so an item that
# just produced content may be marked empty once; the marker is
# dropped again on the next pass when it shows up in the index.
indexed_snapshot = self._indexed_paths() if self._indexed_paths else set()
for item in downloads:
rel = make_relative_path(item.path.as_posix(), media_root_str)
if rel in indexed_snapshot:
if rel in self._empty_mtimes:
del self._empty_mtimes[rel]
self._empty_dirty = True
else:
m = found_mtimes.get(rel, self._seen_mtimes.get(rel))
if m is not None and self._empty_mtimes.get(rel) != m:
self._empty_mtimes[rel] = m
self._empty_dirty = True
await self._finalize_scan(found_mtimes, known_paths) await self._finalize_scan(found_mtimes, known_paths)
await self._send( await self._send(
Task( Task(
@@ -966,11 +1095,13 @@ class RootScanner:
) )
) )
logger.info( logger.info(
"Scan complete (%s): %d movies, %d series, showreel queue=%d", "Scan complete (%s): %d movies, %d series updated, "
"showreel queue=%d, %.1fs total",
task_id, task_id,
n_movies, n_movies,
n_series, n_series,
self._showreel_queue.qsize(), self._showreel_queue.qsize(),
time.monotonic() - started_at,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
+12 -1
View File
@@ -28,12 +28,23 @@ VIDEO_EXTENSIONS = {
".m2ts", ".m2ts",
} }
# Caches for expensive operations # Caches for expensive operations. These are per-scan only: the scanner
# clears them at the start of every scan. Caching across scans is wrong —
# an empty result recorded before a download finished (or during a transient
# network-mount error) would stick for the process lifetime and report
# "no episodes found" for series that do have episodes.
_episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {} _episode_files_cache: dict[str, dict[tuple[int, int], list[tuple[str, int]]]] = {}
_playable_file_cache: dict[str, str | None] = {} _playable_file_cache: dict[str, str | None] = {}
_bluray_probe_file_cache: dict[str, str | None] = {} _bluray_probe_file_cache: dict[str, str | None] = {}
def clear_scan_caches() -> None:
"""Drop all per-scan filesystem caches; called at the start of each scan."""
_episode_files_cache.clear()
_playable_file_cache.clear()
_bluray_probe_file_cache.clear()
def _scandir_split( def _scandir_split(
directory: Path, directory: Path,
stop_event: threading.Event, stop_event: threading.Event,