diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index a418340..50141f5 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -142,6 +142,12 @@ class RegenerateThumbnailsRequest(BaseModel): default=False, description="If true, only re-queue photos whose processing_status is 'failed'.", ) + only_pending: bool = Field( + default=False, + description="If true, only (re-)queue photos whose processing_status is 'pending'. " + "Useful for kicking rows that were created by a scan but never had " + "their thumbnail task picked up.", + ) @router.get("/maintenance/thumbnail-stats") @@ -207,6 +213,8 @@ async def regenerate_thumbnails( query = query.where(Photo.media_type.in_(media_types)) if body.only_failed: query = query.where(Photo.processing_status == 'failed') + if body.only_pending: + query = query.where(Photo.processing_status == 'pending') photos = (await db.execute(query)).scalars().all() @@ -390,6 +398,29 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): } +@router.get("/maintenance/missing-stats") +async def get_missing_stats(): + """Count photos whose files no longer exist on disk under a mounted + source root. Surfaced in Settings so the user can see a number before + pulling the trigger on prune-missing. Cheap enough to call freely.""" + from app.services.cleanup import prune_missing_photos + return await prune_missing_photos(dry_run=True) + + +@router.post("/maintenance/prune-missing") +async def run_prune_missing(): + """Actually delete the orphaned photo rows reported by /missing-stats. + Common cause: PHOTO_DIRS in .env was repointed at a different library + leaving every old row dangling. Skips any photo whose source root + isn't currently mounted (almost always means an unmounted drive).""" + from app.services.cleanup import prune_missing_photos + try: + return {"status": "success", **(await prune_missing_photos(dry_run=False))} + except Exception as e: + logger.error(f"Prune missing failed: {e}") + return {"status": "error", "message": str(e)} + + @router.post("/maintenance/cleanup") async def run_data_integrity_cleanup(): """Re-run the source-roots / folders / photos data-integrity cleanup diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py index dd64a98..01200fe 100644 --- a/backend/app/services/cleanup.py +++ b/backend/app/services/cleanup.py @@ -139,6 +139,73 @@ async def _warn_stale_source_roots(session: AsyncSession) -> int: return stale +async def find_missing_photos(session: AsyncSession) -> tuple[list[str], list[str]]: + """Walk every non-discarded photo and check whether its file is still + on disk. Returns (deletable_ids, skipped_under_unmounted_roots). + + Skipped rows are photos whose owning source_root path itself doesn't + resolve — that's almost always an unmounted drive, and silently + deleting those rows would be data loss. The caller can surface the + skip count separately so the user knows the cleanup wasn't a no-op + by accident. + """ + sr_rows = (await session.execute(select(SourceRoot))).scalars().all() + sr_mounted: dict[str, bool] = {sr.id: os.path.isdir(sr.path) for sr in sr_rows} + + photos = (await session.execute( + select(Photo.id, Photo.filepath, Photo.folder_id) + .where(Photo.is_discarded.is_(False)) + )).all() + + # folder -> source_root lookup + folders = (await session.execute(select(Folder.id, Folder.source_root_id))).all() + folder_to_sr = {fid: srid for fid, srid in folders} + + deletable: 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.append(pid) + return deletable, skipped + + +async def prune_missing_photos(dry_run: bool = True) -> dict: + """Delete photo rows whose files 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. + """ + from sqlalchemy import delete + async with AsyncSessionLocal() as session: + try: + deletable, skipped = await find_missing_photos(session) + if not dry_run and deletable: + # Chunked delete to keep the IN clause within SQLite limits. + CHUNK = 500 + for i in range(0, len(deletable), CHUNK): + await session.execute( + delete(Photo).where(Photo.id.in_(deletable[i:i + CHUNK])) + ) + await session.commit() + logger.info(f"Pruned {len(deletable)} orphaned photo rows") + return { + "would_delete" if dry_run else "deleted": len(deletable), + "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 cleanup_data_integrity() -> dict: """Top-level entry point. Runs the dedupe + count refresh in a single transaction. Returns a small summary dict for logging.""" diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 93f6247..eef1ff2 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { type LibraryStats, type MediaType, type WorkerStatus, + type MissingStats, } from '../../services/api' import { toast } from '../ToastContainer' @@ -42,6 +43,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const [thumbStats, setThumbStats] = useState(null) const [libStats, setLibStats] = useState(null) const [workerStatus, setWorkerStatus] = useState(null) + const [missingStats, setMissingStats] = useState(null) const [loadingStats, setLoadingStats] = useState(false) const [loadingWorkers, setLoadingWorkers] = useState(false) const [showAllErrors, setShowAllErrors] = useState(false) @@ -69,8 +71,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const refreshWorkers = useCallback(async () => { setLoadingWorkers(true) try { - const ws = await library.maintenance.workerStatus() + const [ws, ms] = await Promise.all([ + library.maintenance.workerStatus(), + library.maintenance.missingStats(), + ]) setWorkerStatus(ws) + setMissingStats(ms) } catch (e) { console.error('Failed to load worker status', e) toast.error('Could not load worker status') @@ -111,7 +117,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { try { const result = await fn() toast.success(successTitle, describe?.(result)) - await refreshStats() + await Promise.all([refreshStats(), refreshWorkers()]) } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) toast.error(`${successTitle} failed`, message) @@ -119,11 +125,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { setBusy((b) => ({ ...b, [key]: false })) } }, - [busy, refreshStats] + [busy, refreshStats, refreshWorkers] ) const regenerate = useCallback( - (key: string, body: { media_types?: MediaType[]; only_failed?: boolean }) => + ( + key: string, + body: { + media_types?: MediaType[] + only_failed?: boolean + only_pending?: boolean + } + ) => runAction( key, () => library.maintenance.regenerateThumbnails(body), @@ -263,6 +276,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { {thumbStats ? ` (${thumbStats.failed})` : ''} + + regenerate('regen-pending', { only_pending: true }) + } + disabled={!!thumbStats && thumbStats.pending === 0} + > + + Kick pending + {thumbStats ? ` (${thumbStats.pending})` : ''} + + )} + {/* Orphaned rows (files gone from disk) */} + {missingStats && (missingStats.would_delete ?? 0) > 0 && ( +
+
+
+
+ + {missingStats.would_delete} orphaned photo rows +
+
+ Files no longer exist on disk under a mounted source + root. Usually means PHOTO_DIRS was repointed at a + different library. + {missingStats.skipped_unmounted > 0 && ( + <> + {' '} + {missingStats.skipped_unmounted} more rows are + under unmounted roots and will not be touched. + + )} +
+
+ { + if ( + !confirm( + `Delete ${missingStats.would_delete} photo rows whose files are missing? ` + + 'This cannot be undone.' + ) + ) + return + runAction( + 'prune-missing', + () => library.maintenance.pruneMissing(), + 'Orphans pruned', + (r) => `${r.deleted ?? 0} rows deleted` + ) + }} + > + + Prune + +
+
+ )} + {/* Per-worker breakdown */} {workerStatus && workerStatus.workers.length > 0 && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index eddfb4f..c30914a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -230,6 +230,18 @@ export interface RegenerateResult { } } +export interface MissingStats { + would_delete?: number + deleted?: number + skipped_unmounted: number + dry_run: boolean +} + +export interface PruneResult extends MissingStats { + status?: string + message?: string +} + export interface WorkerInfo { name: string status: 'online' | 'unreachable' @@ -295,7 +307,11 @@ export const library = { /** Reset on-disk thumbs and re-queue Celery generation. With no * filters, every photo in the library is re-queued. */ regenerateThumbnails: async ( - body: { media_types?: MediaType[]; only_failed?: boolean } = {} + body: { + media_types?: MediaType[] + only_failed?: boolean + only_pending?: boolean + } = {} ): Promise => { const response = await api.post( '/library/maintenance/regenerate-thumbnails', @@ -312,6 +328,19 @@ export const library = { return response.data }, + /** Dry-run count of photo rows whose files are no longer on disk + * (under a mounted source root). */ + missingStats: async (): Promise => { + const response = await api.get('/library/maintenance/missing-stats') + return response.data + }, + + /** Actually delete the orphaned photo rows. */ + pruneMissing: async (): Promise => { + const response = await api.post('/library/maintenance/prune-missing') + return response.data + }, + /** Re-run the source-roots / folders / photos integrity cleanup that * normally runs on backend startup. */ cleanup: async (): Promise<{ status: string; message?: string }> => {