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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user