- 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>
110 lines
3.5 KiB
Python
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 []
|
|
} |