""" Folders API router. Source roots themselves are config-driven (PHOTO_DIRS in .env → backend bootstrap on startup) — adding or removing one is a docker-compose change. Sub-folders inside a source root can be created, renamed, and deleted from the UI; those changes are mirrored to disk. """ import logging import os import shutil from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, func, update as sql_update, delete as sql_delete from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Folder, SourceRoot, Photo from app.models.user import User from app.dependencies import get_current_user, get_user_folder from app.services.nextcloud_dav import ( delete_for_user as nc_delete, is_nextcloud_path, mkcol_for_user, move_for_user as nc_move, ) logger = logging.getLogger(__name__) router = APIRouter() class FolderRename(BaseModel): name: str class FolderCreate(BaseModel): name: str parent_id: str # Folder.id (NOT a SourceRoot id) class FolderHide(BaseModel): hidden: bool def _validate_folder_name(name: str) -> str: """Trim + sanity-check a folder name. Rejects names that contain a path separator or that resolve to a parent traversal — those would let the user escape the parent directory through this endpoint. """ name = (name or '').strip() if not name: raise HTTPException(status_code=400, detail="Name cannot be empty") if '/' in name or '\\' in name or name in ('.', '..'): raise HTTPException(status_code=400, detail="Invalid folder name") return name @router.get("") async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Get all source folders""" # Get source roots instead of regular folders result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id)) source_roots = result.scalars().all() folders_list = [] for root in source_roots: # Get photo count for this source root folder_result = await db.execute( select(Folder).where(Folder.source_root_id == root.id) ) folders = folder_result.scalars().all() photo_count = sum(f.photo_count for f in folders) folders_list.append({ "id": root.id, "name": root.name or os.path.basename(root.path), "path": root.path, "photo_count": photo_count }) return {"folders": folders_list} @router.get("/tree") async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Recursive folder tree, one root per active SourceRoot. The tree starts at the Folder row matching the SourceRoot.path (the scanner creates one for every walked directory), with the SourceRoot's display name overlaid so the top-level entry reads as "Library" instead of "/photos". Returns a list of root nodes; each node has: { id, name, path, photo_count, children: [...] } photo_count is **recursive** — every node reports the total non- discarded photos in its own subtree, so the badge matches what the user sees when they click the row (which also filters recursively). The stored Folder.photo_count column is intentionally NOT trusted; the scanner's bookkeeping for that field has historically been wrong (it leaks the global total into whichever folder os.walk visited last). We compute counts here from the photos table. Sub-folders that physically belong to the same source root but weren't created on disk (e.g. the / row the scanner sometimes creates as a parent walk) are skipped via path-prefix filtering. """ sr_result = await db.execute( select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712 ) source_roots = sr_result.scalars().all() out = [] for sr in source_roots: # Folders physically inside this source root, by path prefix. prefix = os.path.normpath(sr.path).rstrip(os.sep) f_result = await db.execute( select(Folder).where( Folder.source_root_id == sr.id, # Either the folder IS the source root, or it sits beneath it. (Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%')) ) ) folders = f_result.scalars().all() if not folders: continue # Direct (non-recursive) photo counts per folder, computed from # the photos table. Excludes discarded AND hidden photos so the # sidebar badge matches the "All Photos"-style cross-cutting # views. Users can still click into a hidden folder and see its # contents; the badge count simply won't reflect those photos. folder_ids = [f.id for f in folders] direct_counts: dict[str, int] = {} if folder_ids: count_result = await db.execute( select(Photo.folder_id, func.count(Photo.id)) .where( Photo.is_discarded == False, # noqa: E712 Photo.is_hidden == False, # noqa: E712 Photo.folder_id.in_(folder_ids), ) .group_by(Photo.folder_id) ) direct_counts = {row[0]: int(row[1]) for row in count_result.all()} # Build a path → node map so we can attach children regardless of # parent_id consistency. We populate photo_count with the direct # count first, then accumulate descendants in a post-order pass. # `is_hidden` on each node carries the user-set folder flag (NOT # the effective ancestry flag) so the frontend can render the # hidden icon on the exact folder the user toggled. nodes = { f.path: { "id": f.id, "name": f.name or os.path.basename(f.path), "path": f.path, "photo_count": direct_counts.get(f.id, 0), "is_hidden": bool(f.is_hidden), "children": [], } for f in folders } root_node = None for f in folders: node = nodes[f.path] if f.path == prefix: root_node = node # Override the display name with the source root's label. node["name"] = sr.name or node["name"] continue parent_path = os.path.normpath(os.path.dirname(f.path)) parent = nodes.get(parent_path) if parent is not None: parent["children"].append(node) # If parent isn't in the set (orphan from a partial scan), drop # the node — it can't be rendered consistently. if root_node is not None: # Sort children alphabetically at every level. def sort_recursive(n): n["children"].sort(key=lambda c: c["name"].lower()) for c in n["children"]: sort_recursive(c) sort_recursive(root_node) # Post-order: each node's recursive count is its own direct # count plus the sum of every descendant's recursive count. def accumulate(n) -> int: total = n["photo_count"] for c in n["children"]: total += accumulate(c) n["photo_count"] = total return total accumulate(root_node) out.append(root_node) return out @router.patch("/{folder_id}") async def rename_folder( folder_id: str, body: FolderRename, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Rename a folder. Two cases: - SourceRoot id → just change the display label. The on-disk path is owned by the docker mount and never moves. - Folder id → rename the directory on disk AND update every descendant Folder.path + Photo.filepath that lived under the old prefix. Refuses to rename the source-root folder itself (= the row that matches the SourceRoot.path) because that would require renaming the docker mount. """ name = _validate_folder_name(body.name) # Try SourceRoot first (display-only rename). sr_result = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) source_root = sr_result.scalar_one_or_none() if source_root: source_root.name = name await db.commit() return { "id": source_root.id, "name": source_root.name, "path": source_root.path, } # Otherwise it's a Folder row. folder = await get_user_folder(folder_id, current_user, db) # Refuse to rename the bare source root mount through here. sr_check = await db.execute( select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id) ) sr = sr_check.scalar_one_or_none() if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): raise HTTPException( status_code=400, detail="Cannot rename the source root mount; rename the docker mount instead.", ) old_path = os.path.normpath(folder.path).rstrip(os.sep) parent_dir = os.path.dirname(old_path) new_path = os.path.join(parent_dir, name) if os.path.exists(new_path): raise HTTPException( status_code=400, detail=f"A folder named '{name}' already exists here", ) if is_nextcloud_path(old_path): # WebDAV MOVE keeps Nextcloud's oc_filecache + sharing metadata # consistent. NC's MOVE is recursive — descendants come along, # exactly like shutil.move. nc_move(current_user, old_path, new_path) else: try: shutil.move(old_path, new_path) except OSError as e: raise HTTPException(status_code=500, detail=f"Rename failed: {e}") # Update folder paths: this row + every descendant. SQLite REPLACE # rewrites the prefix; we use the trailing separator on the LIKE # pattern so a folder named "foo" doesn't accidentally match "foobar". await db.execute( sql_update(Folder) .where(Folder.id == folder.id) .values(path=new_path, name=name) ) descendant_prefix = old_path + os.sep descendants = await db.execute( select(Folder).where(Folder.path.like(descendant_prefix + '%')) ) for d in descendants.scalars().all(): d.path = new_path + d.path[len(old_path):] # Update every photo whose filepath lives under the old prefix. photos_result = await db.execute( select(Photo).where(Photo.filepath.like(descendant_prefix + '%')) ) for p in photos_result.scalars().all(): p.filepath = new_path + p.filepath[len(old_path):] # Photos directly inside this folder (not in a subdir) won't match # the descendant_prefix LIKE if their old path was old_path + '/file' # — actually they DO match, since 'oldpath/file' starts with # 'oldpath/'. So the loop above already covers them. await db.commit() return { "id": folder.id, "name": folder.name, "path": folder.path, } @router.post("", status_code=201) async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Create a new sub-folder under an existing Folder. Mirrors the create to disk so the next scan sees it. Body: { name, parent_id }. parent_id MUST be an existing Folder row id (any descendant of a source root); creating a brand-new top-level mount is a docker operation, not a UI one. """ name = _validate_folder_name(body.name) parent = await get_user_folder(body.parent_id, current_user, db) new_path = os.path.join(parent.path, name) if os.path.exists(new_path): raise HTTPException( status_code=400, detail=f"A folder named '{name}' already exists here", ) if is_nextcloud_path(new_path): # MKCOL via WebDAV so Nextcloud knows about the new collection. mkcol_for_user(current_user, new_path) else: try: os.makedirs(new_path, exist_ok=False) except OSError as e: raise HTTPException(status_code=500, detail=f"Create failed: {e}") new_folder = Folder( name=name, path=new_path, source_root_id=parent.source_root_id, user_id=current_user.id, photo_count=0, ) db.add(new_folder) await db.commit() await db.refresh(new_folder) return { "id": new_folder.id, "name": new_folder.name, "path": new_folder.path, "parent_id": parent.id, } @router.delete("/{folder_id}") async def delete_folder( folder_id: str, mode: Literal['discard', 'permanent'] = Query('discard'), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a folder. Behavior depends on mode: - mode=discard (default): mark every photo whose filepath lives under this folder as is_discarded=true. The folder row, its descendant rows, and the on-disk directory are LEFT INTACT — the user can still recover photos from the discard pile, and a re-scan won't double-import them. - mode=permanent: unlink every photo file under this folder, remove the photo + folder rows from the DB, and rmtree the on-disk directory. Irreversible. Refuses to delete the bare source-root mount in either mode (deleting the docker mount through the UI would be a footgun). """ folder = await get_user_folder(folder_id, current_user, db) sr_check = await db.execute( select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id) ) sr = sr_check.scalar_one_or_none() if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): raise HTTPException( status_code=400, detail="Cannot delete the source root mount through the UI", ) folder_path = os.path.normpath(folder.path).rstrip(os.sep) descendant_prefix = folder_path + os.sep # Collect every photo under this folder OR any descendant. We match # by filepath prefix instead of folder_id because that catches photos # in nested subfolders without a recursive folder walk. photos_result = await db.execute( select(Photo).where( (Photo.filepath == folder_path) | (Photo.filepath.like(descendant_prefix + '%')) ) ) photos = photos_result.scalars().all() if mode == 'discard': from datetime import datetime now = datetime.utcnow() for p in photos: p.is_discarded = True p.discarded_at = now await db.commit() return { "status": "success", "mode": "discard", "discarded": len(photos), } # mode == 'permanent' file_errors = 0 folder_is_nc = is_nextcloud_path(folder_path) if folder_is_nc: # One WebDAV DELETE on the folder itself does the recursive # delete (NC moves the whole tree to trashbin and updates # oc_filecache for everything inside). Skip per-photo unlinks. try: nc_delete(current_user, folder_path) except HTTPException as e: logger.error(f"Nextcloud DELETE failed for {folder_path}: {e.detail}") raise for p in photos: await db.delete(p) else: for p in photos: try: if p.filepath and os.path.exists(p.filepath): os.unlink(p.filepath) except OSError as e: file_errors += 1 logger.error(f"Failed to unlink {p.filepath}: {e}") await db.delete(p) # Delete this folder + every descendant Folder row. await db.execute( sql_delete(Folder).where( (Folder.id == folder.id) | (Folder.path.like(descendant_prefix + '%')) ) ) if not folder_is_nc: try: if os.path.isdir(folder_path): shutil.rmtree(folder_path) except OSError as e: logger.error(f"Failed to rmtree {folder_path}: {e}") # Don't raise — DB rows are already gone, leaving an orphan # directory is the lesser evil. await db.commit() return { "status": "success", "mode": "permanent", "deleted_photos": len(photos), "file_errors": file_errors, } async def _recompute_photo_hidden_flags(db: AsyncSession) -> None: """Rematerialize photos.is_hidden from the full folder ancestry. `photos.is_hidden` is true iff any ancestor folder in the photo's folder chain (including the folder the photo is directly in) has `folders.is_hidden = true`. Rather than do a recursive walk in Python, we lean on Postgres's WITH RECURSIVE to compute each folder's effective hidden state in a single query, then join on photos to bulk-update the flag. Called after any folders.is_hidden toggle AND after moving photos between folders, since the photo's effective-hidden state can change even when no folder flag changes. Cheap — one O(folders) CTE + one O(photos) UPDATE. On a 13k-photo library this runs in under 50ms. """ from sqlalchemy import text as _text await db.execute( _text(""" WITH RECURSIVE folder_chain AS ( -- Base: source-root folders (no parent_id). Their own -- is_hidden is the starting effective value. SELECT id, is_hidden AS effective_hidden FROM folders WHERE parent_id IS NULL UNION ALL -- Step: a child folder inherits from its parent. The -- effective flag is true if the parent's effective flag -- is true OR the child's own flag is true. Short-circuit -- would be nice but a plain OR does the job. SELECT f.id, (f.is_hidden OR fc.effective_hidden) AS effective_hidden FROM folders f JOIN folder_chain fc ON f.parent_id = fc.id ) UPDATE photos p SET is_hidden = fc.effective_hidden FROM folder_chain fc WHERE p.folder_id = fc.id AND p.is_hidden IS DISTINCT FROM fc.effective_hidden """) ) @router.post("/{folder_id}/hide") async def set_folder_hidden( folder_id: str, body: FolderHide, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Toggle the "hide from views" flag on a folder or source root. A hidden folder's photos are excluded from every cross-cutting view (All Photos, Map, Tags, People, Search, sidebar counts, duplicates) but remain fully indexed and visible when the user navigates directly into the folder. The flag cascades to every descendant folder via the photos.is_hidden recompute — the child folder's own `is_hidden` column stays where the user set it, but a photo under a hidden ancestor will still be marked hidden. Accepts both Folder ids and SourceRoot ids. For a SourceRoot, we look up the root Folder row (the one matching source_root.path) and flip that — source roots themselves don't carry the column because the whole subtree lives on a single Folder row anyway. """ # SourceRoot path — resolve to the Folder row at the mount point. sr_result = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) source_root = sr_result.scalar_one_or_none() folder: Optional[Folder] if source_root: root_folder_result = await db.execute( select(Folder).where( Folder.source_root_id == source_root.id, Folder.user_id == current_user.id, Folder.path == os.path.normpath(source_root.path), ) ) folder = root_folder_result.scalar_one_or_none() if folder is None: raise HTTPException( status_code=404, detail="Source root has no indexed Folder row yet; scan first.", ) else: folder = await get_user_folder(folder_id, current_user, db) folder.is_hidden = bool(body.hidden) await db.flush() # Rematerialize photos.is_hidden across the whole tree. Cheap # enough (tens of ms on a typical library) that we don't need to # scope the update to just this folder's subtree — doing it # globally also fixes any drift introduced by earlier moves. await _recompute_photo_hidden_flags(db) await db.commit() return { "id": folder.id, "name": folder.name, "path": folder.path, "is_hidden": folder.is_hidden, } @router.post("/{folder_id}/scan") async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Trigger manual re-scan of source root folder""" from app.tasks.celery import celery_app result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)) source_root = result.scalar_one_or_none() if not source_root: raise HTTPException(status_code=404, detail="Source folder not found") # Queue scan task using the task name defined in the decorator task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id]) return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}