fix: folder tree counts computed from photos (not stale folder.photo_count)

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:11:22 +02:00
parent b4a2241bd9
commit 8413b112ee

View File

@@ -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