Files
mule-image/backend/app/routers/library.py
dtoro bd904aca36 fix: assorted UI polish from review pass
- FilterPill: drop the inline value text from the active state. Pills
  now stay the same width whether or not a filter is set; the popover
  is the canonical place to read the value, and the title attribute
  surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
  centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
  (h-5 min-w-[24px], tabular-nums) so counts line up in the same
  visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
  Discarded) from a new useLibraryStatsQuery hook backed by the
  expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
  match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
  header has scrolled OUT of the viewport. Avoids the duplicate-label
  flash when both labels would be visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:45:30 +02:00

110 lines
3.5 KiB
Python

"""
Library API router for stats and scanning
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Photo
router = APIRouter()
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics + per-section counts. Each section count
matches the filter the sidebar applies when you click it, so the
sidebar badges and the timeline below them stay in sync.
- all_photos: non-discarded photos + videos (matches the All
Photos section's default filter)
- rated: non-discarded with rating >= 1
- duplicates: non-discarded with is_duplicate = true
- discarded: is_discarded = true
- total_size: raw bytes across every row, including discarded
"""
not_discarded = Photo.is_discarded.is_(False)
all_photos_count = (
await db.execute(select(func.count(Photo.id)).where(not_discarded))
).scalar() or 0
rated_count = (
await db.execute(
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
)
).scalar() or 0
duplicates_count = (
await db.execute(
select(func.count(Photo.id)).where(
not_discarded, Photo.is_duplicate.is_(True)
)
)
).scalar() or 0
discarded_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
)
).scalar() or 0
# Legacy split (kept for the existing /stats consumers).
photo_count = (
await db.execute(
select(func.count(Photo.id)).where(
Photo.media_type.in_(['photo', 'heic', 'raw'])
)
)
).scalar() or 0
video_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
).scalar() or 0
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
return {
"all_photos": all_photos_count,
"rated": rated_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
"total_size_gb": round(size / (1024**3), 2) if size else 0,
}
@router.post("/scan")
async def trigger_scan():
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
scan_all_source_roots.delay()
return {"status": "success", "message": "Library scan started"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""
import redis
from app.config import settings
# Connect to Redis to get scan status
r = redis.Redis.from_url(settings.redis_url)
# Get scan status from Redis (set by worker tasks)
is_scanning = r.get('scan:active') == b'true'
current_folder = r.get('scan:current_folder')
processed_files = int(r.get('scan:processed_files') or 0)
total_files = int(r.get('scan:total_files') or 0)
errors = r.lrange('scan:errors', 0, -1)
return {
"is_scanning": is_scanning,
"current_folder": current_folder.decode() if current_folder else None,
"processed_files": processed_files,
"total_files": total_files,
"errors": [e.decode() for e in errors] if errors else []
}