feat(fs): hardlink import/export assets when possible, harden sync init

Importer (gamelist/launchbox file:// flows) and exporters (gamelist.xml,
metadata.pegasus.txt local exports) now hardlink media assets when source
and destination share a filesystem, falling back transparently to a copy
on EXDEV / EPERM / EOPNOTSUPP / EMLINK / EACCES (cross-device, FAT32,
exFAT, network mounts, etc.). Saves disk space and is effectively
instantaneous on large files (videos, manuals, miximages).

Covers keep a real copy (allow_link=False) because _store_cover resizes
the small cover in place via PIL.Image.save, which would truncate the
shared inode and corrupt the user's source image.

Also makes FSSyncHandler tolerate a missing/unwritable /romm/sync at
startup: an OSError from mkdir now logs a warning instead of crashing
the whole app at module-import time. Sync calls still fail at use time
if the mount remains broken — the right place to surface the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-05-18 07:38:11 -04:00
parent c7ecf5d197
commit 757fafae5f
9 changed files with 336 additions and 16 deletions

View File

@@ -16,8 +16,9 @@ from anyio import open_file
from starlette.datastructures import UploadFile
from config.config_manager import config_manager as cm
from logger.logger import log
from models.base import FILE_NAME_MAX_LENGTH
from utils.filesystem import iter_directories, iter_files
from utils.filesystem import iter_directories, iter_files, link_or_copy_file
TAG_REGEX = re.compile(r"\(([^)]+)\)|\[([^]]+)\]")
EXTENSION_REGEX = re.compile(r"\.(([a-z]+\.)*\w+)$")
@@ -148,13 +149,22 @@ class Asset(Enum):
class FSHandler:
def __init__(self, base_path: str):
def __init__(self, base_path: str, tolerate_missing_base: bool = False):
self.base_path = Path(base_path).resolve()
self._locks: dict[str, asyncio.Lock] = {}
self._lock_mutex = asyncio.Lock()
# Create base directory synchronously during initialization
self.base_path.mkdir(parents=True, exist_ok=True)
# Create base directory synchronously during initialization.
try:
self.base_path.mkdir(parents=True, exist_ok=True)
except OSError:
if not tolerate_missing_base:
raise
log.warning(
f"Could not create or access {self.base_path}; "
"feature will be unavailable until the directory is writable."
)
async def _get_file_lock(self, file_path: str) -> asyncio.Lock:
"""Get or create a lock for a specific file path."""
@@ -487,13 +497,23 @@ class FSHandler:
return await open_file(full_path, "rb")
async def copy_file(self, source_full_path: Path, dest_path: str) -> None:
async def copy_file(
self,
source_full_path: Path,
dest_path: str,
allow_link: bool = True,
) -> None:
"""
Copy a file from source to destination.
Args:
source_full_path: Absolute path to the source file
dest_path: Relative path to the destination file
allow_link: If True (default), try a hardlink first and fall back to
a copy when the link isn't possible (cross-device, unsupported
filesystem, etc.). Pass False when the caller will mutate the
destination in place, since mutating a hardlinked file also
mutates the source — see `_store_cover`'s resize step.
Raises:
FileNotFoundError: If source file does not exist
@@ -518,7 +538,10 @@ class FSHandler:
# Create destination directory if needed
dest_parent_anyio_path = AnyioPath(str(dest_full_path.parent))
await dest_parent_anyio_path.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source_full_path), str(dest_full_path))
if allow_link:
link_or_copy_file(source_full_path, dest_full_path)
else:
shutil.copy2(str(source_full_path), str(dest_full_path))
async def move_file_or_folder(self, source_path: str, dest_path: str) -> None:
"""