The frontend AddSourceFolderDialog let users register source roots from inside the app, but with the bootstrap auto-creating one for the /photos mount on first boot, the dialog was redundant in the common case and confusing in every other (users had to know which container path corresponded to their host directory). Going config-driven matches Plex/Photoprism/Immich and matches the mental model "the docker mount IS the library". Frontend - Deleted components/dialogs/AddSourceFolderDialog.tsx entirely. - LeftSidebar drops the "+ Add Source Folder" button + bottom-bar layout, the addFolderMutation, the dead Plus action button on the (no-longer-existing) folders/heaps tree headers, and the Plus icon import. - api.ts: removed sourceFolders.add(), library.browse(), and the BrowseChild / BrowseResponse types. The remaining sourceFolders surface is read-only (list + manual scan). - LeftSidebar bottom strip is now just the "Scan all folders" button when there's at least one source root. Backend - Dropped POST /folders (no consumers) along with FolderCreate / FolderResponse pydantic models. The folders router header now documents the config-driven approach. - Dropped GET /library/browse (no consumers). Removed the unused os/HTTPException/SourceRoot imports it brought in. - cleanup_data_integrity now also walks the source roots and logs a warning for any whose path is missing on disk. Doesn't auto- delete (a missing path could be a temporarily unmounted drive) but surfaces enough hint to fix it. Returns the count in the summary dict alongside merged-duplicates. Docs - README "How libraries are managed" section rewritten to spell out that mounts ARE source roots, edit .env + restart, no UI for managing source roots. New "Changing or adding libraries" section walks through the typical edit-restart loop including the optional volume-nuke for a clean slate. - "Adding more libraries" subsection covers multi-mount via edited compose with a note that auto-registration is roadmap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
164 lines
5.6 KiB
Python
164 lines
5.6 KiB
Python
"""
|
|
One-shot data integrity cleanup for source_roots / folders / photos.
|
|
|
|
Earlier versions of the scanner stored paths verbatim, so trailing slashes
|
|
and redundant separators produced duplicate SourceRoot and Folder rows for
|
|
the same physical directory. The watcher also auto-created source roots
|
|
when fired with a parent dir. This module merges the duplicates and
|
|
re-points photos to the canonical folder so the data lines up with the
|
|
post-fix scanner.
|
|
|
|
Idempotent: safe to run on every backend startup.
|
|
"""
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from sqlalchemy import select, update, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import AsyncSessionLocal
|
|
from app.models import Photo, Folder, SourceRoot
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _normalize_path(path: str) -> str:
|
|
return os.path.normpath(path)
|
|
|
|
|
|
async def _dedupe_source_roots(session: AsyncSession) -> int:
|
|
"""Group source roots by normalized path and merge duplicates. Returns
|
|
the number of rows deleted."""
|
|
result = await session.execute(select(SourceRoot))
|
|
rows = result.scalars().all()
|
|
|
|
groups: dict[str, list[SourceRoot]] = {}
|
|
for sr in rows:
|
|
norm = _normalize_path(sr.path)
|
|
groups.setdefault(norm, []).append(sr)
|
|
|
|
deleted = 0
|
|
for norm, srs in groups.items():
|
|
if len(srs) == 1:
|
|
# Make sure the canonical row's path is normalized too.
|
|
if srs[0].path != norm:
|
|
srs[0].path = norm
|
|
continue
|
|
# Pick the canonical row: prefer one with a non-empty name and the
|
|
# earliest added_at (most likely the original).
|
|
canonical = sorted(
|
|
srs,
|
|
key=lambda s: (not bool(s.name), s.added_at or datetime.max),
|
|
)[0]
|
|
canonical.path = norm
|
|
for sr in srs:
|
|
if sr.id == canonical.id:
|
|
continue
|
|
# Re-point folders that referenced the duplicate root.
|
|
await session.execute(
|
|
update(Folder)
|
|
.where(Folder.source_root_id == sr.id)
|
|
.values(source_root_id=canonical.id)
|
|
)
|
|
await session.delete(sr)
|
|
deleted += 1
|
|
|
|
return deleted
|
|
|
|
|
|
async def _dedupe_folders(session: AsyncSession) -> int:
|
|
"""Group folders by normalized path and merge duplicates. Returns the
|
|
number of rows deleted."""
|
|
result = await session.execute(select(Folder))
|
|
rows = result.scalars().all()
|
|
|
|
groups: dict[str, list[Folder]] = {}
|
|
for f in rows:
|
|
norm = _normalize_path(f.path)
|
|
groups.setdefault(norm, []).append(f)
|
|
|
|
deleted = 0
|
|
for norm, folders in groups.items():
|
|
if len(folders) == 1:
|
|
if folders[0].path != norm:
|
|
folders[0].path = norm
|
|
continue
|
|
# Canonical = the one with the most photos already attached, then
|
|
# the lowest-id (deterministic tiebreaker).
|
|
canonical = sorted(
|
|
folders,
|
|
key=lambda f: (-(f.photo_count or 0), f.id),
|
|
)[0]
|
|
canonical.path = norm
|
|
for f in folders:
|
|
if f.id == canonical.id:
|
|
continue
|
|
# Re-point photos to the canonical folder.
|
|
await session.execute(
|
|
update(Photo)
|
|
.where(Photo.folder_id == f.id)
|
|
.values(folder_id=canonical.id)
|
|
)
|
|
await session.delete(f)
|
|
deleted += 1
|
|
|
|
return deleted
|
|
|
|
|
|
async def _recompute_folder_counts(session: AsyncSession) -> None:
|
|
"""Set folder.photo_count to the actual non-discarded photo count."""
|
|
result = await session.execute(select(Folder))
|
|
folders = result.scalars().all()
|
|
for f in folders:
|
|
count_result = await session.execute(
|
|
select(func.count(Photo.id)).where(
|
|
Photo.folder_id == f.id,
|
|
Photo.is_discarded == False, # noqa: E712
|
|
)
|
|
)
|
|
f.photo_count = int(count_result.scalar() or 0)
|
|
|
|
|
|
async def _warn_stale_source_roots(session: AsyncSession) -> int:
|
|
"""Log a warning for any active source root whose path no longer exists
|
|
on disk. Doesn't delete — a missing path could be a temporarily
|
|
unmounted drive, and silently dropping user data is worse than
|
|
surfacing a noisy log line.
|
|
"""
|
|
result = await session.execute(select(SourceRoot))
|
|
rows = result.scalars().all()
|
|
stale = 0
|
|
for sr in rows:
|
|
if not os.path.isdir(sr.path):
|
|
stale += 1
|
|
logger.warning(
|
|
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
|
|
f"— is the docker mount still in place? "
|
|
f"(Edit docker-compose.yml or PHOTO_DIRS in .env to fix.)"
|
|
)
|
|
return stale
|
|
|
|
|
|
async def cleanup_data_integrity() -> dict:
|
|
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
|
transaction. Returns a small summary dict for logging."""
|
|
async with AsyncSessionLocal() as session:
|
|
try:
|
|
sr_deleted = await _dedupe_source_roots(session)
|
|
f_deleted = await _dedupe_folders(session)
|
|
await _recompute_folder_counts(session)
|
|
stale = await _warn_stale_source_roots(session)
|
|
await session.commit()
|
|
summary = {
|
|
"source_roots_merged": sr_deleted,
|
|
"folders_merged": f_deleted,
|
|
"source_roots_stale": stale,
|
|
}
|
|
if sr_deleted or f_deleted:
|
|
logger.info(f"Cleanup merged duplicates: {summary}")
|
|
return summary
|
|
except Exception as e:
|
|
logger.error(f"Cleanup failed: {e}")
|
|
await session.rollback()
|
|
raise
|