fix: photos folder_id filter accepts source root ids too

GET /folders returns SourceRoot rows (the top-level scan paths
shown in the LeftSidebar tree), but photos.folder_id points to
a Folder row (a directory under a source root), and the photos
list endpoint was matching Photo.folder_id == folder_id literally.
Result: clicking "MulitaTest" in the sidebar sent the source root
id, which never matched any photo, so the timeline went empty
even though the photo_count badge showed 5.

Fix: when the folder_id param matches a SourceRoot, expand it to
every child Folder.id under that root and use IN. Falls back to
the literal match for actual folder ids. If a source root has no
child folder rows yet, returns no photos (rather than the whole
library) so a half-scanned root doesn't accidentally show
everything.

The longer-term cleanup is to deduplicate the source_root /
folder rows the scanner is creating on each rescan, but this
makes the navigation work today.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:39:33 +02:00
parent 66ffd94c48
commit 7dcfa8f30d

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
@@ -67,9 +68,27 @@ async def list_photos(
if date_to:
filters.append(Photo.taken_at <= date_to)
# Folder filter
# Folder filter — the sidebar exposes "source roots" (top-level scan
# paths) under the same UI affordance as folders, so the same param has
# to accept either a folder id or a source root id. If the value matches
# a source root, expand to every folder under that root and use IN.
if folder_id:
filters.append(Photo.folder_id == folder_id)
sr_check = await db.execute(
select(SourceRoot.id).where(SourceRoot.id == folder_id)
)
if sr_check.scalar_one_or_none() is not None:
child_folders = await db.execute(
select(Folder.id).where(Folder.source_root_id == folder_id)
)
child_ids = [row[0] for row in child_folders.all()]
if child_ids:
filters.append(Photo.folder_id.in_(child_ids))
else:
# Source root with no folder rows yet — match nothing rather
# than returning the entire library.
filters.append(Photo.id == '__no_match__')
else:
filters.append(Photo.folder_id == folder_id)
# Media type filter
if media_type: