Files
romm/backend/handler/filesystem/sync_handler.py
Claude 26bdc11e13 refactor(filesystem): lazy-init launchbox + sync handlers, drop tolerate_missing_base
Apply the same lazy-factory pattern to FSLaunchboxHandler and FSSyncHandler
that ssh_sync_handler now uses. With both opt-in features deferred to
first-use, the tolerate_missing_base escape hatch on FSHandler is no longer
needed — every handler now fails loudly on mkdir failure, which is the
right behavior for the always-on core paths (assets, library, resources).

Touched call sites:
  - resources_handler._resolve_local_file_uri (launchbox)
  - sync_watcher.py, endpoints/device.py, tasks/manual/sync_folder_scan.py
    (fs_sync)

Net effect:
  - Default installs never poke /romm/launchbox or /romm/sync at startup.
  - Misconfigured opt-in users get a clear, actionable PermissionError at
    the call site instead of a silent warning followed by mystery failures.
  - tolerate_missing_base, its tests, and one stale log import are gone.
2026-05-24 14:59:03 +00:00

124 lines
4.2 KiB
Python

import functools
import hashlib
import os
from pathlib import Path
from config import SYNC_BASE_PATH
from logger.logger import log
from .base_handler import FSHandler
class FSSyncHandler(FSHandler):
"""Filesystem handler for sync folder operations (File Transfer mode)."""
def __init__(self) -> None:
super().__init__(base_path=SYNC_BASE_PATH)
def build_incoming_path(
self, device_id: str, platform_slug: str | None = None
) -> str:
parts = [device_id, "incoming"]
if platform_slug:
parts.append(platform_slug)
return os.path.join(*parts)
def build_outgoing_path(
self, device_id: str, platform_slug: str | None = None
) -> str:
parts = [device_id, "outgoing"]
if platform_slug:
parts.append(platform_slug)
return os.path.join(*parts)
def build_conflicts_path(
self, device_id: str, platform_slug: str | None = None
) -> str:
parts = [device_id, "conflicts"]
if platform_slug:
parts.append(platform_slug)
return os.path.join(*parts)
def ensure_device_directories(self, device_id: str) -> None:
incoming = self.base_path / self.build_incoming_path(device_id)
outgoing = self.base_path / self.build_outgoing_path(device_id)
incoming.mkdir(parents=True, exist_ok=True)
outgoing.mkdir(parents=True, exist_ok=True)
log.info(f"Ensured sync directories for device {device_id}")
def list_incoming_files(self, device_id: str) -> list[dict]:
"""List all files in a device's incoming directory.
Returns list of dicts with keys: platform_slug, file_name, full_path, file_size, mtime
"""
incoming_dir = self.base_path / self.build_incoming_path(device_id)
if not incoming_dir.exists():
return []
results = []
for platform_dir in incoming_dir.iterdir():
if not platform_dir.is_dir():
continue
platform_slug = platform_dir.name
for file_path in platform_dir.rglob("*"):
if not file_path.is_file():
continue
stat = file_path.stat()
results.append(
{
"platform_slug": platform_slug,
"file_name": file_path.name,
"full_path": str(file_path),
"relative_path": str(file_path.relative_to(incoming_dir)),
"file_size": stat.st_size,
"mtime": stat.st_mtime,
}
)
return results
def compute_file_hash(self, file_path: str) -> str:
"""Compute MD5 hash of a file synchronously (for watcher context)."""
hash_obj = hashlib.md5(usedforsecurity=False)
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hash_obj.update(chunk)
return hash_obj.hexdigest()
def write_outgoing_file(
self, device_id: str, platform_slug: str, file_name: str, data: bytes
) -> str:
"""Write a file to a device's outgoing directory."""
outgoing_dir = self.base_path / self.build_outgoing_path(
device_id, platform_slug
)
outgoing_dir.mkdir(parents=True, exist_ok=True)
file_path = outgoing_dir / file_name
file_path.write_bytes(data)
return str(file_path)
def remove_incoming_file(self, full_path: str) -> None:
"""Remove a processed file from the incoming directory."""
path = Path(full_path)
if path.exists() and path.is_file():
# Validate the file is within our base path
try:
path.resolve().relative_to(self.base_path.resolve())
except ValueError as e:
raise ValueError(
f"Path {full_path} is outside the sync base directory"
) from e
path.unlink()
@functools.cache
def get_fs_sync_handler() -> FSSyncHandler:
"""Lazily instantiate the sync folder handler on first use.
Deferred so that startup doesn't fail when sync is unconfigured or its
base path is not writable.
"""
return FSSyncHandler()