diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 3a18d04..4df4d56 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -6,12 +6,12 @@ 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 +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 +from app.models import Folder, SourceRoot, Photo router = APIRouter() @@ -55,6 +55,15 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)): 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. @@ -79,14 +88,30 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)): 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. + # 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": f.photo_count or 0, + "photo_count": direct_counts.get(f.id, 0), "children": [], } for f in folders @@ -114,6 +139,17 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)): 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