Files
romm/backend/watcher.py
T
Georges-Antoine AssiandClaude Opus 5.5 e232a52656 fix(docker): boot when the container runs as a non-default UID (#5189)
* fix(docker): boot when the container runs as a non-default UID

Unraid runs the container as 99:100, which can't write to the image's
/romm (owned by 1000, mode 755), only to the mounted subfolders. The
PSP pending dir at /romm/cache/retroarch_sync was created at import
time, so every gunicorn worker failed to boot.

- Buffer pending PSP save files under the user's assets folder
  (assets/users/<user>/retroarch/psp_pending/), beside the other
  RetroArch Cloud Sync files and outside the client-reachable blob
  categories.
- Move the zip and rom-converto download caches to /tmp/romm/cache,
  and point nginx's internal /cache/ alias there.
- Put gunicorn's control socket in /tmp/gunicorn instead of
  $HOME/.gunicorn, since HOME is / for that UID.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): honor ROMM_TMP_PATH for caches, explain an unwritable sync folder

- The download caches live under $ROMM_TMP_PATH/cache when it's set,
  so installs with a small Docker disk (Unraid's docker.img) can point
  them at a share. The entrypoint exports the same path for nginx's
  /cache/ alias.
- With ENABLE_SYNC_FOLDER_WATCHER on and /romm/sync not writable, /init
  warns and skips the watcher instead of dying on mkdir under errexit.
- The compose example lists the /romm/sync mount the sync features need.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): cover reading back and clearing buffered PSP files

Addresses Greptile's review on #5189.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply code-review findings to PSP pending buffer

Remove the emptied psp_pending folder after a merge, define the sync
folder path once in init, update the architecture doc's PSP and blob
locations, and drop a stale .gitignore entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): apply simplify findings to PSP pending cleanup

Clear the buffered folder with remove_directory, let the startup check
own creating the sync folder, and reword the test fixture docstring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): cap buffered PSP files and drop them on delete

Buffered files for an unresolved save folder now count against the
bundle limits, so a folder can't fill the assets share and then fail
every merge once it resolves. A DELETE also removes the buffered copy,
so a deleted file isn't merged back when the folder resolves.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): check the sync folder for push-pull sync too

ENABLE_SYNC_PUSH_PULL keeps SSH keys and device saves under /romm/sync
as well, so an unwritable folder now disables it with the watcher and
warns at boot instead of failing when the task runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): strip whitespace from env vars like the backend does

The backend strips every variable it reads, while the init script and
nginx templates used raw values, so a padded ROMM_TMP_PATH sent nginx's
/cache/ alias somewhere the backend never writes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(config): drop the CACHE_BASE_PATH comment

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): say which backend config strips env whitespace

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* revert(docker): drop the entrypoint env whitespace trimming

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply pr-ready code-review findings

Gate push-pull on its SSH keys path rather than a writable sync folder,
accept the same truthy values as the backend, and clear only the merged
PSP pending files so another worker's buffered file survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor: apply pr-ready simplify findings

Share one truthy pattern across init's flag checks (the watcher gates
now accept the same values as the backend), disable gunicorn's unused
control socket instead of placing it, derive the converts redirect from
its own cache path, and factor the unknown PSP folder in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): apply pr-ready humanizer pass to the sync mount note

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(fs): create handler base folders on first write, skip leftover temp files

FSHandler no longer mkdirs its base path when constructed, so a module-level
handler rooted at an unmounted or unwritable folder can't take down boot;
every write path already creates its parent folders. Blob listings skip
.romm_tmp_ files a crashed write left behind, so they are never merged into
a PSP bundle or advertised in the sync manifest. The prefix now lives in one
TEMP_FILE_PREFIX constant.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:48:50 -04:00

220 lines
7.4 KiB
Python

import enum
import fnmatch
import json
import os
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
from opentelemetry import trace
from config import (
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
LIBRARY_BASE_PATH,
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
SCAN_TIMEOUT,
TASK_RESULT_TTL,
)
from config.config_manager import config_manager as cm
from endpoints.sockets.scan import report_scan_failure, scan_job_meta, scan_platforms
from handler.database import db_platform_handler
from handler.redis_handler import get_job_kwargs, scan_queue
from handler.scan_handler import ScanType, get_enabled_metadata_sources
from handler.scan_jobs import get_pending_scan_jobs
from logger.formatter import CYAN
from logger.formatter import highlight as hl
from logger.logger import log
from utils.filesystem import TEMP_FILE_PREFIX
from utils.sentry import init_sentry
init_sentry()
tracer = trace.get_tracer(__name__)
@enum.unique
class EventType(enum.StrEnum):
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
VALID_EVENTS = frozenset(
(
EventType.ADDED,
EventType.DELETED,
)
)
# A change is a tuple representing a file change, first element is the event type, second is the
# path of the file or directory that changed.
Change = tuple[EventType, str]
@dataclass(frozen=True)
class PendingScanCoverage:
"""What the scans already in flight cover, so a rescan is not duplicated."""
full_library: int
platform_ids: frozenset[int]
platform_fs_slugs: frozenset[str]
def get_pending_scan_coverage() -> PendingScanCoverage:
"""Summarise what the scans waiting to run already cover.
Returns:
PendingScanCoverage: How many pending scans cover the whole library, and
the platforms and folders the rest are scoped to.
"""
full_library = 0
platform_ids: set[int] = set()
platform_fs_slugs: set[str] = set()
for job in get_pending_scan_jobs():
kwargs = get_job_kwargs(job)
if kwargs is None:
# Nothing is known about what an unreadable scan covers, and a
# duplicate scan costs less than a rescan that never happens.
continue
# A scan of named roms resolves them from the database and never walks
# the filesystem, so it covers no change on disk, not even one under the
# platform it names.
if kwargs.get("roms_ids"):
continue
# Scans are enqueued with keywords only. A task-driven scan names no
# scope at all, and covers everything.
job_platform_ids = kwargs.get("platform_ids") or []
job_platform_fs_slugs = kwargs.get("platform_fs_slugs") or []
if not (job_platform_ids or job_platform_fs_slugs):
full_library += 1
continue
platform_ids.update(job_platform_ids)
platform_fs_slugs.update(job_platform_fs_slugs)
return PendingScanCoverage(
full_library=full_library,
platform_ids=frozenset(platform_ids),
platform_fs_slugs=frozenset(platform_fs_slugs),
)
def process_changes(changes: Sequence[Change]) -> None:
if not ENABLE_RESCAN_ON_FILESYSTEM_CHANGE:
return
# Filter for valid events, applying the same exclusion rules as the scanner:
# exact-match and fnmatch patterns for files, plus excluded directory names
# checked against every path component so events inside excluded dirs are ignored.
cnfg = cm.get_config()
# A library-relative event path leads with an empty segment for the separator.
platform_segment = len(cnfg.default_structure.platform_dir) + 1
excluded_patterns = (
cnfg.EXCLUDED_SINGLE_FILES
+ cnfg.EXCLUDED_MULTI_FILES
+ cnfg.EXCLUDED_MULTI_PARTS_FILES
)
def _is_excluded(path: str) -> bool:
parts = path.strip("/").split("/")
for part in parts:
if part.startswith(TEMP_FILE_PREFIX):
return True
if any(
part == pat or fnmatch.fnmatch(part, pat) for pat in excluded_patterns
):
return True
return False
changes = [
change
for change in changes
if change[0] in VALID_EVENTS
and not _is_excluded(os.fsdecode(change[1]).split(LIBRARY_BASE_PATH)[-1])
]
if not changes:
return
with tracer.start_as_current_span("process_changes"):
# Find affected platform slugs
fs_slugs: set[str] = set()
changes_platform_directory = False
for change in changes:
event_type, change_path = change
src_path = os.fsdecode(change_path)
event_src = src_path.split(LIBRARY_BASE_PATH)[-1]
event_src_parts = event_src.split("/")
if len(event_src_parts) <= platform_segment:
log.warning(
f"Filesystem event path '{event_src}' has no platform segment. Skipping event."
)
continue
if len(event_src_parts) == platform_segment + 1:
changes_platform_directory = True
log.info(f"Filesystem event: {event_type} {event_src}")
fs_slugs.add(event_src_parts[platform_segment])
if not fs_slugs:
log.info("No valid filesystem slugs found in changes, exiting...")
return
metadata_sources = get_enabled_metadata_sources()
if not metadata_sources:
log.warning("No metadata sources enabled, skipping rescan")
return
pending = get_pending_scan_coverage()
if pending.full_library:
log.info(f"Full rescan already pending ({pending.full_library} job(s))")
return
time_delta = timedelta(minutes=RESCAN_ON_FILESYSTEM_CHANGE_DELAY)
rescan_in_msg = f"rescanning in {hl(str(RESCAN_ON_FILESYSTEM_CHANGE_DELAY), color=CYAN)} minutes."
def schedule_rescan(platform_ids: list[int], scan_type: ScanType) -> None:
scan_queue.enqueue_in(
time_delta,
scan_platforms,
platform_ids=platform_ids,
metadata_sources=metadata_sources,
scan_type=scan_type,
on_failure=report_scan_failure,
job_timeout=SCAN_TIMEOUT,
result_ttl=TASK_RESULT_TTL,
meta=scan_job_meta(scan_type),
)
# Any change to a platform directory should trigger a full rescan
if changes_platform_directory:
log.info(f"Platform directory changed, {rescan_in_msg}")
schedule_rescan([], ScanType.UPDATE)
return
# Otherwise, process each platform slug
for fs_slug in fs_slugs:
# TODO: Query platforms from the database in bulk
db_platform = db_platform_handler.get_platform_by_fs_slug(fs_slug)
if not db_platform:
continue
if (
db_platform.id in pending.platform_ids
or fs_slug in pending.platform_fs_slugs
):
log.info(f"Scan already pending for {hl(fs_slug)}")
continue
log.info(f"Change detected in {hl(fs_slug)} folder, {rescan_in_msg}")
schedule_rescan([db_platform.id], ScanType.QUICK)
if __name__ == "__main__":
changes = cast(list[Change], json.loads(os.getenv("WATCHFILES_CHANGES", "[]")))
if changes:
process_changes(changes)