""" 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. The UI can read the list, trigger a manual rescan, and rename the display label, but it can't change the on-disk path. """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession import os from app.database import get_db from app.models import Folder, SourceRoot, Photo router = APIRouter() class FolderRename(BaseModel): name: str @router.get("") async def get_folders(db: AsyncSession = Depends(get_db)): """Get all source folders""" # Get source roots instead of regular folders result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True)) 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)): """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) # 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. 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.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. 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), "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), ): """Rename a source root's display label. Does NOT touch the on-disk path — that's controlled by the docker mount.""" name = (body.name or '').strip() if not name: raise HTTPException(status_code=400, detail="Name cannot be empty") result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id)) source_root = result.scalar_one_or_none() if not source_root: raise HTTPException(status_code=404, detail="Source folder not found") source_root.name = name await db.commit() return {"id": source_root.id, "name": source_root.name, "path": source_root.path} @router.post("/{folder_id}/scan") async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)): """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)) 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}