mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""Where a library file's user photos live on disk (#3077).
|
|
|
|
Photos are Bambuddy-side metadata, so they sit inside the library data dir
|
|
regardless of whether the file itself is managed or external:
|
|
``<archive_dir>/library/photos/<file_id>/``. The routes, the trash sweeper,
|
|
the external-folder scan and the dispatch cleanup all derive the directory
|
|
from here — see ``archive_paths`` for why one path derived in several places
|
|
is a bug waiting to happen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
import uuid
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from backend.app.core.config import settings
|
|
from backend.app.utils.safe_path import PathTraversalError, safe_join_under
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def library_photos_dir(file_id: int) -> Path:
|
|
"""The photo directory for library file *file_id* (not created)."""
|
|
library_dir = Path(settings.archive_dir) / "library"
|
|
return library_dir / "photos" / str(file_id) # SEC-PATH-OK: file_id is an int primary key
|
|
|
|
|
|
def remove_library_photos_dir(file_id: int) -> None:
|
|
"""Best-effort removal of a file's photo directory and everything in it."""
|
|
photos_dir = library_photos_dir(file_id)
|
|
if not photos_dir.is_dir():
|
|
return
|
|
try:
|
|
shutil.rmtree(photos_dir)
|
|
except OSError as e:
|
|
logger.warning("Failed to remove library photos dir %s: %s", photos_dir, e)
|
|
|
|
|
|
def move_library_photos(file_id: int, photos: Sequence[str], destination: Path) -> list[str]:
|
|
"""Move a library file's photos into *destination*, emptying its directory.
|
|
|
|
Used where a library row is consumed by the archive that replaces it
|
|
(``cleanup_library_after_dispatch``): the photos follow the file instead
|
|
of being orphaned under an id nothing points at any more. Returns the
|
|
names the photos ended up under, in order — a name already taken in
|
|
*destination* gets a fresh one, because both sides draw photo names from
|
|
the same 8-hex-digit alphabet.
|
|
|
|
Best-effort: a photo that cannot be moved is left out of the returned
|
|
list, so it is never named by an archive that does not have it. The
|
|
caller is mid-dispatch and has nowhere to report to. The source
|
|
directory is only removed once everything in the list did move, so a
|
|
failure orphans the pictures rather than destroying them.
|
|
"""
|
|
source_dir = library_photos_dir(file_id)
|
|
if not source_dir.is_dir():
|
|
return []
|
|
moved: list[str] = []
|
|
failed = False
|
|
for filename in photos:
|
|
try:
|
|
source = safe_join_under(source_dir, filename, http=False)
|
|
except PathTraversalError:
|
|
failed = True
|
|
continue
|
|
if not source.is_file():
|
|
continue
|
|
target_name = filename
|
|
try:
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
target = safe_join_under(destination, target_name, http=False)
|
|
if target.exists():
|
|
target_name = f"{uuid.uuid4().hex[:8]}{source.suffix.lower()}"
|
|
target = destination / target_name # SEC-PATH-OK: uuid.uuid4().hex[:8] + suffix
|
|
shutil.move(str(source), str(target))
|
|
except (OSError, PathTraversalError) as e:
|
|
logger.warning("Failed to move library photo %s to %s: %s", source, destination, e)
|
|
failed = True
|
|
continue
|
|
moved.append(target_name)
|
|
if failed:
|
|
logger.warning("Kept library photos dir %s: not every photo reached %s", source_dir, destination)
|
|
else:
|
|
remove_library_photos_dir(file_id)
|
|
return moved
|