uvicorn's reloader is unusable on Windows for this app: it restarts the server child via CTRL_C_EVENT, which is never delivered to a plain spawn child (no console process group of its own), so the child kept running old code and the reloader blocked in process.join() after the first reload — the scanner included. And when a child does restart, uvicorn hands it parent-bound sockets that ProactorEventLoop cannot register with IOCP (WinError 87 on accept), while the selector loop would break asyncio subprocesses (ffmpeg/ffprobe showreel generation). In dev mode on Windows, mediahive now runs a small supervisor instead: it watches the package with watchfiles and respawns a fresh child process (which binds its own sockets) on every change, and terminates the child on Ctrl-C. POSIX keeps uvicorn's native reload. Shutdown: RootScanner.stop() no longer does a final best-effort state save — those to_thread writes ran on the asyncio default executor, whose non-daemon threads blocked interpreter exit, so log lines appeared after the shell prompt returned. All scanner state is already persisted after each completed scan and when the showreel queue drains. Shutdown-time log lines (scan cancelled, showreel worker stopping) move to debug.
1357 lines
54 KiB
Python
1357 lines
54 KiB
Python
"""Scan orchestration — background tasks for continuous media scanning.
|
|
|
|
All scanning logic lives here in hivescan. Communication with the mediahive
|
|
server happens exclusively through an async ``send`` callable that pushes
|
|
:class:`~mediahive.models.events.ScanEvent` messages (``Upsert`` /
|
|
``Task``) onto an :class:`asyncio.Queue` owned by the caller.
|
|
|
|
The scanner recursively walks the media root, respecting ignore patterns
|
|
defined in ``.mediahive/scanignore`` (gitignore-style syntax).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
|
|
from aiopathlib import AsyncPath
|
|
|
|
from mediahive.hivescan.indexer import _process_movies, _process_series
|
|
from mediahive.hivescan.models import ContentType, ParsedContent
|
|
from mediahive.hivescan.parsing import parse_download
|
|
from mediahive.hivescan.scanignore import ScanIgnore
|
|
from mediahive.hivescan.scanning import categorize_downloads, clear_scan_caches
|
|
from mediahive.hivescan.showreel import (
|
|
episode_reel_exists,
|
|
generate_episode_reel,
|
|
generate_showreel_images,
|
|
get_existing_episode_reel_sources,
|
|
get_existing_showreel_source_sets,
|
|
load_probe_records,
|
|
movie_showreels_exist,
|
|
probe_records_dirty,
|
|
save_probe_records,
|
|
)
|
|
from mediahive.hivescan.tmdb_client import set_cache_dir
|
|
from mediahive.hivescan.utils import (
|
|
DEFAULT_OUTPUT_FOLDER,
|
|
make_relative_path,
|
|
)
|
|
from mediahive.models.data import TaskInfo
|
|
from mediahive.models.events import (
|
|
EpisodeReel,
|
|
MovieShowreel,
|
|
ScanEvent,
|
|
Sync,
|
|
Task,
|
|
Upsert,
|
|
)
|
|
|
|
logger = logging.getLogger("hivescan.scanner")
|
|
|
|
# Discovery walk tunables: number of concurrent directory readers, and the
|
|
# exponential backoff schedule for subtrees that repeatedly contain no items
|
|
# of interest (60s, 120s, 240s, ... capped at 1h). Only subtrees with at
|
|
# least _MIN_TRACKED_DIRS directories earn backoff entries — smaller trees
|
|
# are cheap enough to rescan every pass and are left untracked.
|
|
_SCAN_WORKERS = 16
|
|
_EMPTY_BACKOFF_BASE_S = 60.0
|
|
_EMPTY_BACKOFF_MAX_S = 3600.0
|
|
_MIN_TRACKED_DIRS = 8
|
|
|
|
# Type alias for the send callable
|
|
Send = Callable[[ScanEvent], Awaitable[None]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RootScanner — per-root scanning runtime
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RootScanner:
|
|
"""Scanner instance for a single media root."""
|
|
|
|
def __init__(
|
|
self,
|
|
root_id: str,
|
|
media_root: Path,
|
|
send: Send,
|
|
indexed_paths: Callable[[], set[str]] | None = None,
|
|
) -> None:
|
|
self.root_id = root_id
|
|
self.media_root = media_root
|
|
self._send = send
|
|
# Returns the torrent paths currently present in the index; used to
|
|
# reprocess items missing from the database despite unchanged mtimes.
|
|
self._indexed_paths = indexed_paths
|
|
self._output_dir = media_root / DEFAULT_OUTPUT_FOLDER
|
|
self._scanignore = ScanIgnore(media_root)
|
|
|
|
# Runtime state
|
|
self._scan_task: asyncio.Task | None = None
|
|
self._showreel_queue: asyncio.Queue = asyncio.Queue()
|
|
self._showreel_worker_task: asyncio.Task | None = None
|
|
self._rescan_worker_task: asyncio.Task | None = None
|
|
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})
|
|
self._dir_state: dict[str, dict] = {}
|
|
self._dir_state_dirty = False
|
|
|
|
# Persisted state (under .mediahive/)
|
|
self._scan_state_path = self._output_dir / "scan-state.json"
|
|
self._reel_state_path = self._output_dir / "reel-state.json"
|
|
self._probe_cache_path = self._output_dir / "probe-cache.json"
|
|
self._reel_state: dict[str, dict[str, dict]] = {"movies": {}, "episodes": {}}
|
|
self._reel_state_dirty = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
async def start(self) -> None:
|
|
"""Initialise and start background workers."""
|
|
await AsyncPath(self._output_dir).mkdir(parents=True, exist_ok=True)
|
|
set_cache_dir(self._output_dir / ".tmdb-cache")
|
|
await asyncio.to_thread(self._load_scan_state)
|
|
await asyncio.to_thread(load_probe_records, self._probe_cache_path)
|
|
await asyncio.to_thread(self._load_reel_state)
|
|
|
|
logger.info(
|
|
"Scanner started for root %s — path=%s, scanignore=%s, known-paths=%d",
|
|
self.root_id,
|
|
self.media_root,
|
|
"loaded" if self._scanignore.file_path.exists() else "defaults only",
|
|
len(self._seen_mtimes),
|
|
)
|
|
|
|
self._showreel_worker_task = asyncio.create_task(self._showreel_worker())
|
|
self._rescan_worker_task = asyncio.create_task(self._rescan_loop())
|
|
|
|
async def stop(self) -> None:
|
|
"""Cancel all background tasks."""
|
|
tasks = [
|
|
self._scan_task,
|
|
self._showreel_worker_task,
|
|
self._rescan_worker_task,
|
|
]
|
|
for task in tasks:
|
|
if task and not task.done():
|
|
task.cancel()
|
|
# Wait briefly for graceful shutdown to avoid lingering scanner tasks.
|
|
# All tasks are awaited concurrently (a slow one must not delay the
|
|
# others), under a single overall timeout.
|
|
pending = [task for task in tasks if task and not task.done()]
|
|
if pending:
|
|
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
|
|
await asyncio.wait_for(
|
|
asyncio.gather(*pending, return_exceptions=True), timeout=2.0
|
|
)
|
|
# No final state save on shutdown: state is persisted after every
|
|
# completed scan, and a last-minute save over a network drive only
|
|
# delays process exit (executor threads block interpreter shutdown,
|
|
# so log lines appear after the shell prompt has returned).
|
|
|
|
def is_scanning(self) -> bool:
|
|
return self._scan_task is not None and not self._scan_task.done()
|
|
|
|
def showreel_queue_size(self) -> int:
|
|
return self._showreel_queue.qsize()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Scan-state persistence
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_scan_state(self) -> None:
|
|
try:
|
|
data = json.loads(self._scan_state_path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError, ValueError:
|
|
logger.exception("Failed to load scan state from %s", self._scan_state_path)
|
|
return
|
|
seen = data.get("seen_mtimes")
|
|
if isinstance(seen, dict):
|
|
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")
|
|
if isinstance(dir_state, dict):
|
|
for key, value in dir_state.items():
|
|
if isinstance(value, dict):
|
|
self._dir_state[str(key)] = {
|
|
"until": float(value.get("until") or 0.0),
|
|
"streak": int(value.get("streak") or 0),
|
|
"items": bool(value.get("items")),
|
|
}
|
|
|
|
def _save_scan_state(self) -> None:
|
|
try:
|
|
payload = json.dumps({
|
|
"version": 2,
|
|
"seen_mtimes": self._seen_mtimes,
|
|
"empty_mtimes": self._empty_mtimes,
|
|
"dir_state": self._dir_state,
|
|
})
|
|
tmp = self._scan_state_path.with_suffix(".tmp")
|
|
tmp.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.write_text(payload, encoding="utf-8")
|
|
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:
|
|
logger.exception("Failed to save scan state to %s", self._scan_state_path)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Reel-state persistence and gating
|
|
#
|
|
# ``reel-state.json`` records, per media folder (movies) or per episode,
|
|
# whether reel generation succeeded or failed for the current video file
|
|
# (keyed by mtime+size). This stops the showreel worker from retrying
|
|
# permanently unreadable files on every scan, and stops short videos from
|
|
# being re-queued forever because they legitimately have fewer reels than
|
|
# the maximum.
|
|
# ------------------------------------------------------------------
|
|
|
|
# Retry delay for failed generations: 6h, 12h, 24h, ... capped at a week.
|
|
_REEL_RETRY_BASE_HOURS = 6
|
|
_REEL_RETRY_MAX_HOURS = 168
|
|
|
|
def _load_reel_state(self) -> None:
|
|
try:
|
|
data = json.loads(self._reel_state_path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError, ValueError:
|
|
logger.exception("Failed to load reel state from %s", self._reel_state_path)
|
|
return
|
|
for bucket in ("movies", "episodes"):
|
|
records = data.get(bucket)
|
|
if isinstance(records, dict):
|
|
self._reel_state[bucket] = records
|
|
|
|
def _save_reel_state(self) -> None:
|
|
try:
|
|
payload = json.dumps({"version": 1, **self._reel_state})
|
|
tmp = self._reel_state_path.with_suffix(".tmp")
|
|
tmp.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.write_text(payload, encoding="utf-8")
|
|
tmp.replace(self._reel_state_path)
|
|
except OSError, TypeError, ValueError:
|
|
logger.exception("Failed to save reel state to %s", self._reel_state_path)
|
|
|
|
def _reel_state_bucket(
|
|
self,
|
|
kind: str,
|
|
media_folder: Path,
|
|
season_num: int | None = None,
|
|
episode_num: int | None = None,
|
|
) -> tuple[dict[str, dict], str]:
|
|
folder_key = make_relative_path(
|
|
media_folder.as_posix(), self.media_root.as_posix()
|
|
)
|
|
if kind == "movie":
|
|
return self._reel_state["movies"], folder_key
|
|
key = f"{folder_key}#S{season_num:02d}E{episode_num:02d}"
|
|
return self._reel_state["episodes"], key
|
|
|
|
def _record_reel_state(
|
|
self,
|
|
kind: str,
|
|
media_folder: Path,
|
|
season_num: int | None,
|
|
episode_num: int | None,
|
|
video_path: str,
|
|
sig: tuple[int, int] | None,
|
|
status: str,
|
|
error: str | None = None,
|
|
) -> None:
|
|
bucket, key = self._reel_state_bucket(
|
|
kind, media_folder, season_num, episode_num
|
|
)
|
|
previous = bucket.get(key)
|
|
attempts = (
|
|
0 if status == "done" else int((previous or {}).get("attempts", 0)) + 1
|
|
)
|
|
bucket[key] = {
|
|
"video": make_relative_path(video_path, self.media_root.as_posix()),
|
|
"mtime": sig[0] if sig else None,
|
|
"size": sig[1] if sig else None,
|
|
"status": status,
|
|
"attempts": attempts,
|
|
"last": time.time(),
|
|
"error": error,
|
|
}
|
|
self._reel_state_dirty = True
|
|
|
|
async def _reel_needed(
|
|
self,
|
|
kind: str,
|
|
video_path: str,
|
|
media_folder: Path,
|
|
season_num: int | None = None,
|
|
episode_num: int | None = None,
|
|
) -> tuple[bool, tuple[int, int] | None]:
|
|
"""Decide whether a reel-generation task should be queued.
|
|
|
|
Returns ``(needed, signature)`` where signature is the video file's
|
|
(mtime, size) or None when it cannot be stat'ed.
|
|
"""
|
|
sig: tuple[int, int] | None = None
|
|
try:
|
|
st = await AsyncPath(video_path).stat()
|
|
sig = (int(st.st_mtime), st.st_size)
|
|
except OSError, ValueError:
|
|
pass
|
|
|
|
bucket, key = self._reel_state_bucket(
|
|
kind, media_folder, season_num, episode_num
|
|
)
|
|
rec = bucket.get(key)
|
|
if (
|
|
rec is not None
|
|
and sig is not None
|
|
and rec.get("mtime") == sig[0]
|
|
and rec.get("size") == sig[1]
|
|
):
|
|
if rec.get("status") == "done":
|
|
return False, sig
|
|
attempts = int(rec.get("attempts", 1))
|
|
delay = (
|
|
min(
|
|
self._REEL_RETRY_BASE_HOURS * 2**attempts,
|
|
self._REEL_RETRY_MAX_HOURS,
|
|
)
|
|
* 3600
|
|
)
|
|
if time.time() - float(rec.get("last", 0)) < delay:
|
|
return False, sig
|
|
|
|
if kind == "movie":
|
|
exists = await movie_showreels_exist(media_folder)
|
|
else:
|
|
exists = await episode_reel_exists(media_folder, season_num, episode_num)
|
|
if exists:
|
|
if rec is None and sig is not None:
|
|
# Reels already on disk (e.g. generated before this feature):
|
|
# record success so future scans take the cheap path.
|
|
self._record_reel_state(
|
|
kind, media_folder, season_num, episode_num, video_path, sig, "done"
|
|
)
|
|
return False, sig
|
|
return True, sig
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal scan orchestration
|
|
# ------------------------------------------------------------------
|
|
|
|
def _start_scan(self) -> None:
|
|
self._scan_task = asyncio.create_task(self._run_scan())
|
|
|
|
async def _rescan_loop(self) -> None:
|
|
try:
|
|
while True:
|
|
self._start_scan()
|
|
if self._scan_task:
|
|
await self._scan_task
|
|
await asyncio.sleep(30)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
logger.exception("Rescan loop error")
|
|
|
|
async def _discover_downloads(
|
|
self, task_id: str
|
|
) -> tuple[list[ParsedContent], dict[str, int], set[str]]:
|
|
"""Walk the media root with a pool of parallel workers.
|
|
|
|
Each directory costs exactly one ``scandir`` round trip (entry
|
|
mtimes come from the directory listing itself), and up to
|
|
``_SCAN_WORKERS`` directories are read concurrently — over a
|
|
network mount the walk is latency-bound, so this is close to an
|
|
N-fold speedup over the old serialized recursion.
|
|
|
|
Subtree scheduling is adaptive (``_dir_state``, persisted in
|
|
scan-state.json): directories that repeatedly yield no candidates
|
|
are penalised with exponential backoff (1, 2, 4, ... minutes up to
|
|
``_EMPTY_BACKOFF_MAX_S``), while trees with items of interest or
|
|
recent changes are scanned every pass and queued first, so new
|
|
downloads appear almost immediately even while a large cold tree
|
|
is still being walked.
|
|
|
|
Returns ``(downloads, found_mtimes, known_paths)``: the new/changed
|
|
items to process, the mtimes observed for them (committed to
|
|
``_seen_mtimes`` only after the scan completes successfully), and
|
|
the full set of media-root-relative candidate paths (backed-off
|
|
subtrees contribute their previously known paths, so deletion
|
|
sync never drops them unseen).
|
|
"""
|
|
downloads: list[ParsedContent] = []
|
|
found_mtimes: dict[str, int] = {}
|
|
known_paths: set[str] = set()
|
|
media_root_str = self.media_root.as_posix()
|
|
now = time.time()
|
|
dirs_visited = 0
|
|
# Torrent paths currently in the index; a matching mtime alone is not
|
|
# enough to skip an item that the database does not actually have.
|
|
indexed = self._indexed_paths() if self._indexed_paths else None
|
|
|
|
media_container_dirs = {"BDMV", "VIDEO_TS", "HVDVD_TS"}
|
|
video_extensions = {
|
|
".mkv",
|
|
".mp4",
|
|
".avi",
|
|
".m4v",
|
|
".mov",
|
|
".wmv",
|
|
".flv",
|
|
".webm",
|
|
".ts",
|
|
".m2ts",
|
|
}
|
|
|
|
queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
|
|
seq = itertools.count()
|
|
|
|
def _scan_dir(
|
|
directory: Path,
|
|
stop_event: threading.Event,
|
|
) -> tuple[list[tuple[Path, int | None]], list[tuple[Path, int]], bool]:
|
|
"""One scandir; DirEntry stats are free from the listing."""
|
|
child_dirs: list[tuple[Path, int | None]] = []
|
|
video_files: list[tuple[Path, int]] = []
|
|
is_media_container = False
|
|
with os.scandir(directory) as entries:
|
|
for entry in entries:
|
|
if stop_event.is_set():
|
|
break
|
|
name = entry.name
|
|
if name.startswith("."):
|
|
continue
|
|
try:
|
|
is_dir = entry.is_dir(follow_symlinks=False)
|
|
except OSError:
|
|
continue
|
|
if is_dir:
|
|
if name.upper() in media_container_dirs:
|
|
is_media_container = True
|
|
try:
|
|
mtime: int | None = int(entry.stat().st_mtime)
|
|
except OSError:
|
|
mtime = None
|
|
child_dirs.append((Path(entry.path), mtime))
|
|
else:
|
|
item = Path(entry.path)
|
|
if item.suffix.lower() not in video_extensions:
|
|
continue
|
|
try:
|
|
video_files.append((item, int(entry.stat().st_mtime)))
|
|
except OSError:
|
|
continue
|
|
return child_dirs, video_files, is_media_container
|
|
|
|
async def _report(detail: str) -> None:
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail=detail,
|
|
)
|
|
)
|
|
)
|
|
|
|
async def _register_candidate(
|
|
path: Path, relpath: str, mtime: int | None
|
|
) -> bool:
|
|
"""Record a candidate; return True when it is new/changed."""
|
|
known_paths.add(relpath)
|
|
if mtime is None:
|
|
try:
|
|
mtime = int((await AsyncPath(path).stat()).st_mtime)
|
|
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
|
|
stored = self._seen_mtimes.get(relpath)
|
|
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
|
|
# Unchanged on disk but missing from the index (snapshot
|
|
# wiped, upsert lost, ...): reprocess it unless it is not
|
|
# 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)
|
|
if parsed.content_type is ContentType.OTHER:
|
|
self._empty_mtimes[relpath] = mtime
|
|
self._empty_dirty = True
|
|
return False
|
|
downloads.append(parsed)
|
|
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
|
|
downloads.append(await parse_download(path))
|
|
return True
|
|
|
|
def _carry_known(relpath: str) -> None:
|
|
"""Keep previously known paths of a skipped (backed-off) subtree."""
|
|
prefix = relpath + "/"
|
|
for p in self._seen_mtimes:
|
|
if p == relpath or p.startswith(prefix):
|
|
known_paths.add(p)
|
|
|
|
def _complete_node(node: dict) -> None:
|
|
"""Fold a finished subtree into the backoff state and its parent."""
|
|
rel = node["rel"]
|
|
if rel is not None and not stop_event.is_set():
|
|
prev = self._dir_state.get(rel)
|
|
if node["items"] or node["changed"] or node["dirs"] < _MIN_TRACKED_DIRS:
|
|
# Hot, changed, or tiny subtree: no tracking needed —
|
|
# default scheduling (scan every pass) is already right.
|
|
if prev is not None:
|
|
del self._dir_state[rel]
|
|
self._dir_state_dirty = True
|
|
else:
|
|
streak = int((prev or {}).get("streak", 0)) + 1
|
|
delay = min(
|
|
_EMPTY_BACKOFF_BASE_S * 2 ** (streak - 1),
|
|
_EMPTY_BACKOFF_MAX_S,
|
|
)
|
|
new = {"until": now + delay, "streak": streak, "items": False}
|
|
if prev != new:
|
|
self._dir_state[rel] = new
|
|
self._dir_state_dirty = True
|
|
parent = node["parent"]
|
|
if parent is not None:
|
|
parent["items"] = parent["items"] or node["items"]
|
|
parent["changed"] = parent["changed"] or node["changed"]
|
|
parent["dirs"] += node["dirs"]
|
|
parent["pending"] -= 1
|
|
if parent["pending"] == 0:
|
|
_complete_node(parent)
|
|
|
|
def _child_priority(state: dict | None) -> int:
|
|
# Items of interest (or recently changed) first, never-seen
|
|
# directories next (they may hold brand-new content), known
|
|
# empty last.
|
|
if state is None:
|
|
return 1
|
|
if state.get("items"):
|
|
return 0
|
|
return 2
|
|
|
|
async def _enqueue_children(
|
|
node: dict, child_dirs: list[tuple[Path, int | None]]
|
|
) -> None:
|
|
for child, child_mtime in child_dirs:
|
|
crel = make_relative_path(str(child), media_root_str)
|
|
state = self._dir_state.get(crel)
|
|
if (
|
|
state is not None
|
|
and not state.get("items")
|
|
and float(state.get("until", 0.0)) > now
|
|
):
|
|
_carry_known(crel)
|
|
continue
|
|
cnode = {
|
|
"parent": node,
|
|
"pending": 1, # self-reference, released after processing
|
|
"items": False,
|
|
"changed": False,
|
|
"dirs": 0,
|
|
"rel": crel,
|
|
}
|
|
node["pending"] += 1
|
|
await queue.put((
|
|
_child_priority(state),
|
|
next(seq),
|
|
child,
|
|
child_mtime,
|
|
cnode,
|
|
))
|
|
|
|
async def _worker(stop_event: threading.Event) -> None:
|
|
nonlocal dirs_visited
|
|
while True:
|
|
_, _, path, mtime, node = await queue.get()
|
|
rel = make_relative_path(str(path), media_root_str)
|
|
try:
|
|
if stop_event.is_set():
|
|
continue
|
|
dirs_visited += 1
|
|
node["dirs"] += 1
|
|
try:
|
|
(
|
|
child_dirs,
|
|
video_files,
|
|
is_media_container,
|
|
) = await asyncio.to_thread(_scan_dir, path, stop_event)
|
|
except OSError, PermissionError:
|
|
logger.debug("Cannot list directory: %s", path)
|
|
# Unreadable (e.g. a transient network-mount failure):
|
|
# keep its previously known paths so the Sync event
|
|
# cannot delete the subtree's items, and don't let it
|
|
# earn backoff.
|
|
_carry_known(rel)
|
|
node["changed"] = True
|
|
child_dirs, video_files, is_media_container = [], [], False
|
|
if self._scanignore:
|
|
child_dirs = [
|
|
c
|
|
for c in child_dirs
|
|
if not self._scanignore.is_excluded(c[0])
|
|
]
|
|
video_files = [
|
|
f
|
|
for f in video_files
|
|
if not self._scanignore.is_excluded(f[0])
|
|
]
|
|
if dirs_visited % 8 == 1:
|
|
await _report(f"Scanning: {rel} ({len(downloads)} found)")
|
|
|
|
if is_media_container or not child_dirs:
|
|
# Disc structure or leaf directory: the directory
|
|
# itself is the candidate. For backoff purposes it
|
|
# only counts as an item of interest when it actually
|
|
# holds video — a leaf of nothing but flac/mp3 files
|
|
# must not keep its whole subtree hot.
|
|
# Directory mtimes are lazy (NTFS) and unreliable
|
|
# (SMB), so fold in the newest video file mtime —
|
|
# file mtimes are reliable, and this is what makes a
|
|
# still-growing download show up immediately.
|
|
if is_media_container or video_files:
|
|
node["items"] = True
|
|
if video_files:
|
|
newest = max(fm for _, fm in video_files)
|
|
mtime = max(mtime or 0, newest)
|
|
if await _register_candidate(path, rel, mtime):
|
|
node["changed"] = True
|
|
else:
|
|
for file_path, file_mtime in video_files:
|
|
file_rel = make_relative_path(
|
|
str(file_path), media_root_str
|
|
)
|
|
node["items"] = True
|
|
# No "or" short-circuit here: every file must be
|
|
# registered even after an earlier file in this
|
|
# 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
|
|
):
|
|
node["changed"] = True
|
|
await _enqueue_children(node, child_dirs)
|
|
except Exception:
|
|
# Never let one bad directory kill the worker or truncate
|
|
# the walk — an incomplete known_paths set would make the
|
|
# Sync event delete items that still exist on disk.
|
|
logger.exception("Discovery failed for directory: %s", path)
|
|
_carry_known(rel)
|
|
node["changed"] = True
|
|
finally:
|
|
node["pending"] -= 1
|
|
if node["pending"] == 0:
|
|
_complete_node(node)
|
|
queue.task_done()
|
|
|
|
logger.debug("Starting filesystem discovery at %s", self.media_root)
|
|
await _report(f"Scanning: {self.media_root}")
|
|
|
|
stop_event = threading.Event()
|
|
try:
|
|
child_dirs, video_files, _ = await asyncio.to_thread(
|
|
_scan_dir, self.media_root, stop_event
|
|
)
|
|
except asyncio.CancelledError:
|
|
stop_event.set()
|
|
raise
|
|
except OSError, PermissionError:
|
|
# Failing to list the root must fail the whole scan: returning an
|
|
# empty known_paths here would make the Sync event delete every
|
|
# item in the index.
|
|
raise RuntimeError(f"Cannot list media root: {self.media_root}")
|
|
|
|
if self._scanignore:
|
|
child_dirs = [
|
|
c for c in child_dirs if not self._scanignore.is_excluded(c[0])
|
|
]
|
|
video_files = [
|
|
f for f in video_files if not self._scanignore.is_excluded(f[0])
|
|
]
|
|
|
|
root_node = {
|
|
"parent": None,
|
|
"pending": 1,
|
|
"items": False,
|
|
"changed": False,
|
|
"dirs": 0,
|
|
"rel": None,
|
|
}
|
|
for file_path, file_mtime in video_files:
|
|
rel = make_relative_path(str(file_path), media_root_str)
|
|
root_node["items"] = True
|
|
await _register_candidate(file_path, rel, file_mtime)
|
|
await _enqueue_children(root_node, child_dirs)
|
|
|
|
workers = [
|
|
asyncio.create_task(_worker(stop_event)) for _ in range(_SCAN_WORKERS)
|
|
]
|
|
try:
|
|
await queue.join()
|
|
finally:
|
|
stop_event.set()
|
|
for w in workers:
|
|
w.cancel()
|
|
await asyncio.gather(*workers, return_exceptions=True)
|
|
|
|
logger.debug(
|
|
"Discovery complete: %d downloads found, %d directories visited",
|
|
len(downloads),
|
|
dirs_visited,
|
|
)
|
|
return downloads, found_mtimes, known_paths
|
|
|
|
async def _finalize_scan(
|
|
self, found_mtimes: dict[str, int], known_paths: set[str]
|
|
) -> None:
|
|
"""Commit discovery state after a fully completed scan.
|
|
|
|
Commits observed mtimes (so cancelled/failed scans retry their items),
|
|
prunes vanished paths, persists state when anything changed, and sends
|
|
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.update(found_mtimes)
|
|
state_changed = committed != self._seen_mtimes
|
|
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)))
|
|
|
|
if state_changed or self._dir_state_dirty or self._empty_dirty:
|
|
self._dir_state_dirty = False
|
|
self._empty_dirty = False
|
|
await asyncio.to_thread(self._save_scan_state)
|
|
if probe_records_dirty():
|
|
await asyncio.to_thread(save_probe_records)
|
|
if self._reel_state_dirty:
|
|
self._reel_state_dirty = False
|
|
await asyncio.to_thread(self._save_reel_state)
|
|
|
|
async def _run_scan(self) -> None:
|
|
"""Full scan pipeline:
|
|
|
|
1. Discover downloads
|
|
2. Categorise → movies / series
|
|
3. Iterate async generators, send each item as Upsert
|
|
4. Queue showreel tasks.
|
|
"""
|
|
task_id = f"scan-{uuid.uuid4().hex[:8]}"
|
|
media_root_str = self.media_root.as_posix()
|
|
started_at = time.monotonic()
|
|
|
|
try:
|
|
logger.debug("Scan started (%s) for root %s", task_id, self.root_id)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail="Starting scan...",
|
|
)
|
|
)
|
|
)
|
|
|
|
clear_scan_caches()
|
|
downloads, found_mtimes, known_paths = await self._discover_downloads(
|
|
task_id
|
|
)
|
|
elapsed = time.monotonic() - started_at
|
|
|
|
if not downloads:
|
|
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._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="completed",
|
|
progress=1,
|
|
detail="No new items",
|
|
)
|
|
)
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
"Scan (%s): %d new/changed of %d items found in %.1fs",
|
|
task_id,
|
|
len(downloads),
|
|
len(known_paths),
|
|
elapsed,
|
|
)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail=f"Processing {len(downloads)} items...",
|
|
)
|
|
)
|
|
)
|
|
|
|
categories = categorize_downloads(downloads)
|
|
n_movies = len(categories[ContentType.MOVIE])
|
|
n_series = len(categories[ContentType.SERIES])
|
|
total = n_movies + n_series
|
|
processed = 0
|
|
|
|
logger.info("Categorised: %d movies, %d series", n_movies, n_series)
|
|
|
|
# Process movies
|
|
if n_movies:
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail=f"Processing {n_movies} movies...",
|
|
)
|
|
)
|
|
)
|
|
async for (
|
|
movie_id,
|
|
movie,
|
|
showreel_task,
|
|
people,
|
|
scanned,
|
|
) in _process_movies(
|
|
categories,
|
|
self._output_dir,
|
|
fetch_covers=True,
|
|
generate_showreels=True,
|
|
media_root=media_root_str,
|
|
root_id=self.root_id,
|
|
):
|
|
await self._send(
|
|
Upsert(
|
|
kind="movie",
|
|
id=movie_id,
|
|
item=movie,
|
|
people=people or None,
|
|
scanned=scanned,
|
|
)
|
|
)
|
|
if showreel_task:
|
|
needed, sig = await self._reel_needed(
|
|
"movie", showreel_task[0], showreel_task[1]
|
|
)
|
|
if needed:
|
|
await self._showreel_queue.put((
|
|
"movie",
|
|
movie_id,
|
|
showreel_task,
|
|
sig,
|
|
))
|
|
logger.info(
|
|
"[%d/%d] Movie: %s (showreel queued, queue=%d)",
|
|
processed + 1,
|
|
total,
|
|
movie.title,
|
|
self._showreel_queue.qsize(),
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"[%d/%d] Movie: %s (showreel up to date)",
|
|
processed + 1,
|
|
total,
|
|
movie.title,
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"[%d/%d] Movie: %s (no showreel task)",
|
|
processed + 1,
|
|
total,
|
|
movie.title,
|
|
)
|
|
processed += 1
|
|
progress = round(processed / total, 3) if total else 1
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=progress,
|
|
detail=movie.title,
|
|
)
|
|
)
|
|
)
|
|
|
|
# Process series
|
|
if n_series:
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=processed / total if total else 0.5,
|
|
detail=f"Processing {n_series} series...",
|
|
)
|
|
)
|
|
)
|
|
async for (
|
|
series_id,
|
|
series,
|
|
ep_reel_tasks,
|
|
people,
|
|
scanned,
|
|
) in _process_series(
|
|
categories,
|
|
self._output_dir,
|
|
fetch_covers=True,
|
|
generate_showreels=True,
|
|
media_root=media_root_str,
|
|
root_id=self.root_id,
|
|
):
|
|
await self._send(
|
|
Upsert(
|
|
kind="series",
|
|
id=series_id,
|
|
item=series,
|
|
people=people or None,
|
|
scanned=scanned,
|
|
)
|
|
)
|
|
queued = 0
|
|
for task in ep_reel_tasks:
|
|
needed, sig = await self._reel_needed(
|
|
"episode", task[0], task[1], task[2], task[3]
|
|
)
|
|
if needed:
|
|
await self._showreel_queue.put((
|
|
"episode",
|
|
series_id,
|
|
task,
|
|
sig,
|
|
))
|
|
queued += 1
|
|
if queued:
|
|
logger.info(
|
|
"[%d/%d] Series: %s (%d episode reels queued, queue=%d)",
|
|
processed + 1,
|
|
total,
|
|
series.title,
|
|
queued,
|
|
self._showreel_queue.qsize(),
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"[%d/%d] Series: %s (reels up to date)",
|
|
processed + 1,
|
|
total,
|
|
series.title,
|
|
)
|
|
processed += 1
|
|
progress = round(processed / total, 3) if total else 1
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=progress,
|
|
detail=series.title,
|
|
)
|
|
)
|
|
)
|
|
|
|
# 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._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="completed",
|
|
progress=1,
|
|
detail=f"Done — {n_movies} movies, {n_series} series",
|
|
)
|
|
)
|
|
)
|
|
logger.info(
|
|
"Scan complete (%s): %d movies, %d series updated, "
|
|
"showreel queue=%d, %.1fs total",
|
|
task_id,
|
|
n_movies,
|
|
n_series,
|
|
self._showreel_queue.qsize(),
|
|
time.monotonic() - started_at,
|
|
)
|
|
|
|
except asyncio.CancelledError:
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="cancelled",
|
|
progress=0,
|
|
detail="Scan cancelled",
|
|
)
|
|
)
|
|
)
|
|
logger.debug("Scan cancelled (%s)", task_id)
|
|
except Exception:
|
|
logger.exception("Scan failed (%s)", task_id)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="error",
|
|
progress=0,
|
|
detail="Scan error",
|
|
)
|
|
)
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Showreel worker
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _showreel_worker(self) -> None:
|
|
"""Background worker that generates showreels one at a time."""
|
|
logger.info("Showreel worker started for root %s", self.root_id)
|
|
media_root_path = self.media_root
|
|
|
|
while True:
|
|
try:
|
|
kind, item_id, task_data, sig = await self._showreel_queue.get()
|
|
task_id = f"showreel-{uuid.uuid4().hex[:8]}"
|
|
remaining = self._showreel_queue.qsize()
|
|
|
|
if kind == "movie":
|
|
video_path, media_folder, title = task_data
|
|
logger.info(
|
|
"Showreel dequeued: %s (video=%s, folder=%s, %d remaining)",
|
|
title,
|
|
video_path,
|
|
media_folder,
|
|
remaining,
|
|
)
|
|
needed, _ = await self._reel_needed(
|
|
"movie", video_path, media_folder
|
|
)
|
|
if not needed:
|
|
logger.info("Showreel skipped (already exists): %s", title)
|
|
self._showreel_queue.task_done()
|
|
continue
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail=f"Showreel: {title}",
|
|
)
|
|
)
|
|
)
|
|
generated = await generate_showreel_images(
|
|
video_path,
|
|
media_folder,
|
|
title=title,
|
|
)
|
|
if generated:
|
|
source_sets = get_existing_showreel_source_sets(
|
|
media_folder, media_root=media_root_path
|
|
)
|
|
paths = [sources[0] for sources in source_sets if sources]
|
|
self._record_reel_state(
|
|
"movie", media_folder, None, None, video_path, sig, "done"
|
|
)
|
|
await self._send(
|
|
MovieShowreel(
|
|
id=item_id,
|
|
showreel_images=paths or None,
|
|
showreel_source_sets=source_sets or None,
|
|
)
|
|
)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="completed",
|
|
progress=1,
|
|
detail=(
|
|
f"Showreel: {title} ({len(generated)} reels)"
|
|
),
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
self._record_reel_state(
|
|
"movie",
|
|
media_folder,
|
|
None,
|
|
None,
|
|
video_path,
|
|
sig,
|
|
"failed",
|
|
error="no reels produced",
|
|
)
|
|
logger.warning(
|
|
"Showreel generation returned nothing: %s", title
|
|
)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="error",
|
|
progress=0,
|
|
detail=f"Showreel failed: {title}",
|
|
)
|
|
)
|
|
)
|
|
|
|
elif kind == "episode":
|
|
video_path, media_folder, season_num, episode_num, series_title = (
|
|
task_data
|
|
)
|
|
ep_code = f"S{season_num:02d}E{episode_num:02d}"
|
|
logger.info(
|
|
"Episode reel dequeued: %s %s (video=%s, %d remaining)",
|
|
series_title,
|
|
ep_code,
|
|
video_path,
|
|
remaining,
|
|
)
|
|
needed, _ = await self._reel_needed(
|
|
"episode", video_path, media_folder, season_num, episode_num
|
|
)
|
|
if not needed:
|
|
logger.info(
|
|
"Episode reel skipped (already exists): %s %s",
|
|
series_title,
|
|
ep_code,
|
|
)
|
|
self._showreel_queue.task_done()
|
|
continue
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="running",
|
|
progress=0,
|
|
detail=f"Reel: {series_title} {ep_code}",
|
|
)
|
|
)
|
|
)
|
|
reel_path = await generate_episode_reel(
|
|
video_path,
|
|
media_folder,
|
|
season_num,
|
|
episode_num,
|
|
)
|
|
if reel_path:
|
|
reel_sources = get_existing_episode_reel_sources(
|
|
media_folder,
|
|
season_num,
|
|
episode_num,
|
|
media_root=media_root_path,
|
|
)
|
|
reel_image = (
|
|
reel_sources[0]
|
|
if reel_sources
|
|
else make_relative_path(
|
|
reel_path, media_root_path.as_posix()
|
|
)
|
|
)
|
|
self._record_reel_state(
|
|
"episode",
|
|
media_folder,
|
|
season_num,
|
|
episode_num,
|
|
video_path,
|
|
sig,
|
|
"done",
|
|
)
|
|
await self._send(
|
|
EpisodeReel(
|
|
id=item_id,
|
|
season=season_num,
|
|
episode=episode_num,
|
|
reel_image=reel_image,
|
|
reel_sources=reel_sources or None,
|
|
)
|
|
)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="completed",
|
|
progress=1,
|
|
detail=f"Reel: {series_title} {ep_code}",
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
self._record_reel_state(
|
|
"episode",
|
|
media_folder,
|
|
season_num,
|
|
episode_num,
|
|
video_path,
|
|
sig,
|
|
"failed",
|
|
error="no reel produced",
|
|
)
|
|
logger.warning(
|
|
"Episode reel generation failed: %s %s",
|
|
series_title,
|
|
ep_code,
|
|
)
|
|
await self._send(
|
|
Task(
|
|
data=TaskInfo(
|
|
id=task_id,
|
|
status="error",
|
|
progress=0,
|
|
detail=f"Reel failed: {series_title} {ep_code}",
|
|
)
|
|
)
|
|
)
|
|
|
|
self._showreel_queue.task_done()
|
|
if self._showreel_queue.empty() and self._reel_state_dirty:
|
|
# Persist as soon as the queue drains; scans may be far
|
|
# apart and a crash would otherwise lose the records.
|
|
self._reel_state_dirty = False
|
|
await asyncio.to_thread(self._save_reel_state)
|
|
|
|
except asyncio.CancelledError:
|
|
logger.debug("Showreel worker shutting down for root %s", self.root_id)
|
|
return
|
|
except Exception:
|
|
logger.exception(
|
|
"Showreel worker error (queue size=%d)",
|
|
self._showreel_queue.qsize(),
|
|
)
|
|
with contextlib.suppress(ValueError):
|
|
self._showreel_queue.task_done()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy module-level API removed — use RootScanner per root instead.
|
|
# ---------------------------------------------------------------------------
|