From 8413b112eed54b499f66b70e99f13260a07c1624 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 12:11:22 +0200 Subject: [PATCH] fix: folder tree counts computed from photos (not stale folder.photo_count) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar showed Juno=7 and sub=blank because the scanner's folder.photo_count bookkeeping is broken end-to-end: for root, dirs, files in os.walk(folder_path): folder = await get_or_create_folder(...) ... processed_files += 1 # global counter # AFTER the loop: folder.last_scanned = datetime.utcnow() folder.photo_count = processed_files # only the LAST folder processed_files is the running total across the whole walk, not per-folder; and the assignment runs once after the loop, only on whichever folder os.walk happened to visit last. Result: that folder gets the grand total, every other folder gets nothing (or stale). Rather than fix the scanner's bookkeeping (which has leaked into two production scans already), the tree endpoint now computes counts on demand from the photos table: - One GROUP BY per source root: photo.folder_id → COUNT, excluding discarded - Each node starts with its DIRECT count - A post-order walk accumulates descendants so every node reports recursive count — i.e. clicking the row gives you that number of photos because the photos query also expands descendants The stored Folder.photo_count column is now unused by the API. A future cleanup could drop it from the model entirely. Verified on the dev DB: Library=7 (4 direct + Juno=2 + sub=1), Juno=2, sub=1. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/folders.py | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) 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