Files
mule-image/backend/app/services/cleanup.py
claudio a27267f7ad refactor: drop AI/vision pipeline + plain Postgres + full-refresh script
Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
  - backend: app/services/vision/, app/tasks/vision.py,
    app/services/feature_flags.py, app/routers/features.py — all
    deleted; admin AI/feature-flag endpoints and the worker-vision
    bootstrap call gone. Photo.needs_review and its index dropped.
  - frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
    types, "Needs Review" sidebar entry + filter, needs_review filter
    URL param all gone.
  - infra: worker-vision compose service + models_data volume deleted;
    worker-light command no longer runs bootstrap_models; the db
    image switches from pgvector/pgvector:pg16 to postgres:16; backend
    Dockerfile drops the dedicated torch RUN layer; requirements.txt
    drops torch/torchvision/open-clip-torch/onnxruntime.

Alembic 0019_drop_ai_remnants:
  - drops photos.needs_review + ix_photos_needs_review
  - DROP EXTENSION IF EXISTS vector (must run before the image swap;
    the new postgres:16 doesn't ship pgvector)

New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:20:38 +02:00

448 lines
16 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)
def _parent_is_accessible(path: str) -> bool:
"""True if the parent directory of `path` is readable. Used to
distinguish 'user renamed/deleted the source root folder' (parent
mount fine, leaf gone) from 'drive unmounted' (whole subtree
inaccessible). The former is safe to prune from; the latter is
not."""
parent = os.path.dirname(path.rstrip(os.sep))
if not parent:
return False
try:
os.listdir(parent)
return True
except OSError:
return False
def _sr_state(sr_path: str) -> str:
"""Classify a source root path as one of:
'present' — directory exists, business as usual
'renamed' — leaf missing but parent mount is accessible (user
renamed/deleted the folder in their file manager)
'unmounted'— parent itself inaccessible (drive not mounted)
"""
if os.path.isdir(sr_path):
return 'present'
if _parent_is_accessible(sr_path):
return 'renamed'
return 'unmounted'
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. Logs different hints for renamed-vs-
unmounted so the user knows which knob to turn.
"""
result = await session.execute(select(SourceRoot))
rows = result.scalars().all()
stale = 0
for sr in rows:
state = _sr_state(sr.path)
if state == 'present':
continue
stale += 1
if state == 'renamed':
logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"— parent mount is fine, looks like the folder was renamed "
f"or deleted. Photos under it can be cleared via "
f"POST /api/v1/library/maintenance/prune-missing."
)
else:
logger.warning(
f"Source root '{sr.name}' path is missing on disk: {sr.path} "
f"— parent directory is also inaccessible; is the docker "
f"mount still in place? (Edit docker-compose.yml or "
f"PHOTO_DIRS in .env to fix.)"
)
return stale
async def find_missing(
session: AsyncSession,
) -> tuple[list[str], list[str], list[str]]:
"""Walk every non-discarded photo + every folder and check whether
they still resolve on disk. Returns
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
Skipped rows are photos/folders whose owning source_root is truly
inaccessible (parent mount missing) — that's almost always an
unmounted drive, and silently deleting those rows would be data
loss. Photos under a source root whose leaf is missing but whose
parent mount IS accessible (user renamed/deleted the folder) are
treated as deletable, since their files are genuinely gone from
the user's library.
"""
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
# "Available" = source root path exists OR parent mount is accessible.
# Only truly-unmounted source roots skip pruning.
sr_mounted: dict[str, bool] = {
sr.id: _sr_state(sr.path) != 'unmounted' for sr in sr_rows
}
photos = (await session.execute(
select(Photo.id, Photo.filepath, Photo.folder_id)
.where(Photo.is_discarded.is_(False))
)).all()
folders = (await session.execute(
select(Folder.id, Folder.path, Folder.source_root_id)
)).all()
folder_to_sr = {fid: srid for fid, _path, srid in folders}
deletable_photos: list[str] = []
skipped: list[str] = []
for pid, fp, folder_id in photos:
sr_id = folder_to_sr.get(folder_id)
if sr_id is None or not sr_mounted.get(sr_id, False):
skipped.append(pid)
continue
if not os.path.exists(fp):
deletable_photos.append(pid)
deletable_folders: list[str] = []
for fid, fpath, sr_id in folders:
if sr_id is None or not sr_mounted.get(sr_id, False):
continue
if not os.path.isdir(fpath):
deletable_folders.append(fid)
return deletable_photos, deletable_folders, skipped
async def prune_missing_photos(dry_run: bool = True) -> dict:
"""Delete photo + folder rows whose paths are no longer on disk *and*
whose source root is currently mounted. Common cause: PHOTO_DIRS in
.env was repointed at a different library, leaving every old row
orphaned.
Set dry_run=False to actually delete. The default is intentionally
safe so the matching count can be surfaced in the UI before the
user commits to it.
Function name kept for backwards compatibility — it now also prunes
folders, not just photos.
"""
from sqlalchemy import delete
async with AsyncSessionLocal() as session:
try:
deletable_photos, deletable_folders, skipped = await find_missing(session)
if not dry_run:
CHUNK = 500
# Photos first (folders may FK from them via folder_id).
for i in range(0, len(deletable_photos), CHUNK):
await session.execute(
delete(Photo).where(
Photo.id.in_(deletable_photos[i:i + CHUNK])
)
)
# Then drop folders that ALSO no longer have any photos
# pointing at them. We re-check after the photo delete so
# we don't strand a folder that legitimately exists on
# disk but happened to match the orphan list.
if deletable_folders:
for i in range(0, len(deletable_folders), CHUNK):
chunk = deletable_folders[i:i + CHUNK]
# Only delete folders that now have zero photos
# left attached (defensive — should always be 0
# if the path is gone, but a concurrent scan
# could re-create rows).
still_used = (await session.execute(
select(Photo.folder_id)
.where(Photo.folder_id.in_(chunk))
.distinct()
)).scalars().all()
safe = [f for f in chunk if f not in set(still_used)]
if safe:
await session.execute(
delete(Folder).where(Folder.id.in_(safe))
)
await session.commit()
logger.info(
f"Pruned {len(deletable_photos)} photo rows + "
f"{len(deletable_folders)} folder rows"
)
key_p = "would_delete" if dry_run else "deleted"
key_f = "would_delete_folders" if dry_run else "deleted_folders"
return {
key_p: len(deletable_photos),
key_f: len(deletable_folders),
"skipped_unmounted": len(skipped),
"dry_run": dry_run,
}
except Exception as e:
logger.error(f"prune_missing_photos failed: {e}")
await session.rollback()
raise
async def prune_orphan_thumbnails(
thumbs_root: str = "/data/thumbs",
dry_run: bool = True,
) -> dict:
"""Remove `/data/thumbs/{user_id}/{photo_id}/` directories whose
photo_id no longer exists in the photos table.
Layout was per-Phase-4 set up by app.tasks.thumbs and is keyed by
`{user_id}/{photo_id}/`. The thumbs worker never deletes its own
output on photo removal, so over the lifetime of a library these
directories accumulate.
Set dry_run=False to actually `rm -rf` each matched directory.
Returns counts of matched / removed dirs and any per-dir errors.
"""
import shutil
if not os.path.isdir(thumbs_root):
return {
"would_remove": 0,
"removed": 0,
"skipped_no_root": True,
"dry_run": dry_run,
}
async with AsyncSessionLocal() as session:
live_ids = {
row[0]
for row in (await session.execute(select(Photo.id))).all()
}
matched: list[str] = []
errors: list[str] = []
for user_dir in os.listdir(thumbs_root):
user_path = os.path.join(thumbs_root, user_dir)
if not os.path.isdir(user_path):
continue
for photo_dir in os.listdir(user_path):
if photo_dir in live_ids:
continue
matched.append(os.path.join(user_path, photo_dir))
removed = 0
if not dry_run:
for path in matched:
try:
shutil.rmtree(path)
removed += 1
except OSError as e:
errors.append(f"{path}: {e}")
key = "would_remove" if dry_run else "removed"
return {
key: len(matched) if dry_run else removed,
"errors": errors,
"dry_run": dry_run,
}
async def discard_missing_photos() -> dict:
"""Soft variant of prune_missing_photos for the periodic beat
catch-up. Walks every active source root that is currently
`present` (not 'renamed' — the user-driven manual flow handles
those — and not 'unmounted'), and for each Photo whose file is
gone from disk, sets is_discarded=True so it shows up in the
in-app trash. Idempotent: skips photos that are already
discarded.
Hard-deletion stays manual via prune_missing_photos so users
can review the list before committing.
"""
async with AsyncSessionLocal() as session:
try:
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
present_sr_ids = {
sr.id for sr in sr_rows if _sr_state(sr.path) == 'present'
}
if not present_sr_ids:
return {"discarded": 0, "checked": 0}
folder_to_sr = {
fid: srid
for fid, _path, srid in (await session.execute(
select(Folder.id, Folder.path, Folder.source_root_id)
)).all()
}
photo_rows = (await session.execute(
select(Photo.id, Photo.filepath, Photo.folder_id)
.where(Photo.is_discarded.is_(False))
)).all()
missing_ids: list[str] = []
checked = 0
for pid, fp, folder_id in photo_rows:
sr_id = folder_to_sr.get(folder_id)
if sr_id not in present_sr_ids:
continue
checked += 1
if not os.path.exists(fp):
missing_ids.append(pid)
if missing_ids:
CHUNK = 500
now = datetime.utcnow()
for i in range(0, len(missing_ids), CHUNK):
await session.execute(
update(Photo)
.where(Photo.id.in_(missing_ids[i:i + CHUNK]))
.values(is_discarded=True, discarded_at=now)
)
await session.commit()
logger.info(
f"discard_missing_photos: discarded {len(missing_ids)} "
f"of {checked} photos under {len(present_sr_ids)} present source roots"
)
return {"discarded": len(missing_ids), "checked": checked}
except Exception as e:
logger.error(f"discard_missing_photos failed: {e}")
await session.rollback()
raise
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