Files
mule-image/backend/app/services/scanner.py
dtoro db37be902e fix: disable folder watcher on startup to unblock scan_folder
Diagnosis: every backend restart was dispatching watch_folders.delay()
unconditionally. watch_folders is an infinite-loop celery task
(for changes in watch(*paths)). With CELERYD_CONCURRENCY=4 and several
restarts during dev, all four worker slots ended up pinned by stale
watch_folders instances, leaving zero workers free for scan_folder.
The result: clicking "Scan all folders" successfully queued a task
that then sat in the queue forever, the new /photos/sub folder was
never walked, and the user's newly added photo never appeared.

The watcher was only opportunistically useful and the user already
triggers scans manually. Disabling it removes the foot-gun. Re-
enabling needs:
  - a Redis lock so only one watcher runs at a time
  - or a dedicated long-running container with concurrency=1
  - or a celery beat schedule with a singleton flag

Until then, manual scans work. Cleared the backlog by wiping the
redis broker volume so the stale watch_folders tasks are gone.

Verified: post-fix, scan_folder runs in 0.12s and reports
"Processed 7/7 files. Errors: 0", picking up the previously missing
/photos/sub/Samuel_Colman... file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:01:19 +02:00

66 lines
2.3 KiB
Python

"""
Scanner service for initial library scan and one-time bootstrap of the
default source root on first boot.
"""
import os
import logging
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import SourceRoot
from app.tasks.scan import scan_all_source_roots
from app.config import settings
logger = logging.getLogger(__name__)
# The single host → container mount path. The compose file mounts whatever
# the user set as PHOTO_DIRS at this path.
DEFAULT_LIBRARY_PATH = "/photos"
DEFAULT_LIBRARY_NAME = "Library"
async def bootstrap_default_source_root() -> None:
"""If no source roots exist in the DB, create one pointing at the default
library mount. Lets a fresh install pick up photos with zero
configuration: the user only needs to set PHOTO_DIRS in .env.
"""
if not os.path.isdir(DEFAULT_LIBRARY_PATH):
logger.warning(
f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; "
"set PHOTO_DIRS in .env and recreate the container."
)
return
async with AsyncSessionLocal() as session:
result = await session.execute(select(SourceRoot))
if result.scalars().first() is not None:
return # Already have at least one source root, leave it alone.
source_root = SourceRoot(
name=DEFAULT_LIBRARY_NAME,
path=DEFAULT_LIBRARY_PATH,
)
session.add(source_root)
await session.commit()
logger.info(
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME}"
f"{DEFAULT_LIBRARY_PATH}"
)
async def start_initial_scan():
"""Start the initial library scan.
NOTE: the folder watcher (watch_folders task) is intentionally NOT
dispatched here. It's an infinite loop celery task and every backend
restart was queuing a new instance, eventually pinning every worker
and starving scan_folder dispatches. Re-enabling it needs a Redis
lock or a dedicated long-running container — until then the user
triggers scans manually via "Scan all folders".
"""
try:
scan_all_source_roots.delay()
logger.info("Initial scan queued successfully")
except Exception as e:
logger.error(f"Failed to start initial scan: {e}")