Files
mule-image/backend/app/routers/library.py
dtoro 7d33e1688a feat: By Date sidebar — year/month drilldown navigation
The "By Date" library node was decorative. Now it's a real
hierarchical navigator: expand to see year buckets (with photo
counts), expand a year to see its months, click any year or
month to filter the timeline to that date range.

Backend
- New GET /library/date_buckets aggregates non-discarded photos
  by year+month from Photo.taken_at via SQLite strftime, returning
  [{ year, count, months: [{ month, count }] }] sorted newest-
  first. NULL taken_at rows are excluded.

Frontend
- New library.dateBuckets() helper + DateBucketYear / Month types.
- New hooks/useDateBucketsQuery.ts with a 60s staleTime.
- LeftSidebar builds the By Date subtree dynamically from the
  query: each year is a tree node with month children. Year nodes
  use the Calendar icon, months render as their full English name.
- applyLibraryNode handles two new id prefixes:
  'date-year-{year}'  → setDateFrom YYYY-01-01, setDateTo YYYY-12-31
  'date-month-{Y}-{M}'→ setDateFrom YYYY-MM-01, setDateTo YYYY-MM-LL
  where LL is the last day of the month (computed via Date trick
  new Date(year, month, 0).getDate() — uses month-day=0 to roll
  back into the previous month's last day).
- isItemActive recognises when the current dateFrom/dateTo matches
  a year or month node so the sidebar selection highlight stays
  in sync with the filter store (also when filters are set
  externally via the filter bar or URL hydration).

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

116 lines
3.7 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"""
# Count total photos
total_photos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos
total_videos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
video_count = total_videos.scalar()
# Calculate total size
total_size = await db.execute(
select(func.sum(Photo.file_size))
)
size = total_size.scalar() or 0
return {
"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("/date_buckets")
async def get_date_buckets(db: AsyncSession = Depends(get_db)):
"""Aggregate non-discarded photos by year and month based on taken_at.
Used by the LeftSidebar 'By Date' subtree to render a year-then-month
drilldown without fetching every photo. Returns:
[{ year: 2026, count: 5, months: [{ month: 4, count: 5 }] }]
"""
year_expr = func.strftime('%Y', Photo.taken_at)
month_expr = func.strftime('%m', Photo.taken_at)
result = await db.execute(
select(
year_expr.label('year'),
month_expr.label('month'),
func.count(Photo.id).label('count'),
)
.where(
Photo.is_discarded == False, # noqa: E712
Photo.taken_at.is_not(None),
)
.group_by(year_expr, month_expr)
.order_by(year_expr.desc(), month_expr.desc())
)
rows = result.all()
years: dict[int, dict] = {}
for year_str, month_str, count in rows:
if not year_str or not month_str:
continue
try:
year = int(year_str)
month = int(month_str)
except ValueError:
continue
bucket = years.setdefault(
year, {'year': year, 'count': 0, 'months': []}
)
bucket['count'] += int(count)
bucket['months'].append({'month': month, 'count': int(count)})
# Already ordered by the SQL ORDER BY (newest first), preserve insertion.
return list(years.values())
@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 []
}