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>
116 lines
3.7 KiB
Python
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 []
|
|
} |