mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
* 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]>
220 lines
7.4 KiB
Python
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)
|