""" Photos API router """ from typing import List, Optional, Dict, Any from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from sqlalchemy import select, and_, or_, func, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload import asyncio import base64 import json import os import logging logger = logging.getLogger(__name__) from app.database import get_db from app.models import Photo, Folder, Tag from app.models.folders import SourceRoot from app.models.user import User from app.models.heaps import heap_photos from app.models.tags import photo_tags from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.services.exif_writer import ExifWriteError, write_taken_at from app.services.date_guess import has_date_warning as compute_date_warning from app.services import video as video_service from app.dependencies import ( get_current_user, get_current_user_media, get_user_photo, get_user_or_shared_heap, get_user_or_shared_folder, can_access_photo_via_share, ) from app.services.nextcloud_dav import ( NextcloudCredentialsMissing, get_preview_async, is_nextcloud_path, move_for_user, ) from app.config import settings def _nc_unsupported(detail: str) -> HTTPException: """Returns a 501 with a UI-friendly hint to use Nextcloud's web UI for an op we haven't routed through WebDAV yet. Centralised so the message stays consistent.""" return HTTPException( status_code=501, detail=( f"{detail} For Nextcloud-managed libraries, do this from " "Nextcloud's web UI; mule-image will pick up the change." ), ) router = APIRouter() @router.get("") async def list_photos( q: Optional[str] = None, # Accept either a bare date ("2026-04-10") or a full ISO datetime # ("2026-04-10T23:59:59"). pydantic v2's datetime parser rejects # the bare form with 422; we coerce manually below so older # clients that send a date-only string keep working. date_from: Optional[str] = None, date_to: Optional[str] = None, folder_id: Optional[str] = None, tag_ids: Optional[str] = None, media_type: Optional[str] = None, rating_min: Optional[int] = Query(None, ge=0, le=5), rating_max: Optional[int] = Query(None, ge=0, le=5), color_label: Optional[str] = None, is_discarded: Optional[bool] = False, is_duplicate: Optional[bool] = None, has_date_warning: Optional[bool] = None, heap_id: Optional[str] = None, sort: str = "taken_at", order: str = "desc", page: int = Query(1, ge=1), per_page: int = Query(100, ge=1, le=500), cursor: Optional[str] = Query(None, description="Opaque cursor for keyset pagination"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """List photos with filters and pagination""" # Determine if this request is for shared content. When viewing a # shared heap or folder the user_id filter is replaced by the # heap/folder join that already encodes authorization. is_shared_context = False if heap_id: try: _heap, _perm = await get_user_or_shared_heap(heap_id, current_user, db) if _perm != "owner": is_shared_context = True except Exception: raise HTTPException(status_code=404, detail="Heap not found") if folder_id and not is_shared_context: try: _folder, _perm = await get_user_or_shared_folder(folder_id, current_user, db) if _perm != "owner": is_shared_context = True except Exception: raise HTTPException(status_code=404, detail="Folder not found") # Build query — eager-load tags so the response can include them # without an N+1 round-trip per photo. Scoped to the current user # unless we're in a shared context (scoped by heap/folder instead). query = select(Photo).options(selectinload(Photo.tags)) if not is_shared_context: query = query.where(Photo.user_id == current_user.id) # Apply filters filters = [] # Text search. Walks every metadata field a user might reasonably # remember a photo by: basename, title, notes, raw EXIF blob, and # tag names (via a subquery so photos with any matching tag come # back even when the tag filter isn't set). Case-insensitive ILIKE # across all fields — the frontend re-runs the same match logic to # render a "matched on …" chip on each thumbnail. if q: search_pattern = f"%{q}%" tag_subq = ( select(photo_tags.c.photo_id) .select_from(photo_tags.join(Tag, photo_tags.c.tag_id == Tag.id)) .where(Tag.name.ilike(search_pattern)) ) filters.append( or_( Photo.filename.ilike(search_pattern), Photo.user_title.ilike(search_pattern), Photo.user_notes.ilike(search_pattern), Photo.exif_json.ilike(search_pattern), Photo.id.in_(tag_subq), ) ) # Date range. Both inputs are strings to keep pydantic from rejecting # bare-date forms ("2026-04-10") with 422; coerce here. fromisoformat # accepts both bare dates and full ISO datetimes — when given a date, # it returns midnight, which is what we want for the lower bound. def _parse_bound(s: Optional[str]) -> Optional[datetime]: if not s: return None try: dt = datetime.fromisoformat(s) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid date: {s!r} (expected YYYY-MM-DD or ISO 8601)", ) # If the caller sent a tz-aware string, normalize to naive UTC — # the photos.taken_at column is `timestamp without time zone`. if dt.tzinfo is not None: from datetime import timezone dt = dt.astimezone(timezone.utc).replace(tzinfo=None) return dt parsed_date_from = _parse_bound(date_from) parsed_date_to = _parse_bound(date_to) if parsed_date_from: filters.append(Photo.taken_at >= parsed_date_from) if parsed_date_to: filters.append(Photo.taken_at <= parsed_date_to) # Folder filter. The sidebar can pass either a SourceRoot id or a # Folder id; both should include descendants so clicking a parent # folder shows everything under it (Lightroom semantics). if folder_id: sr_check = await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id) ) sr_row = sr_check.scalar_one_or_none() if sr_row is not None: # Source root → all folders under it (any depth). 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: filters.append(Photo.id == '__no_match__') else: # Folder id → that folder + every descendant by path prefix. target_check = await db.execute( select(Folder).where(Folder.id == folder_id) ) target = target_check.scalar_one_or_none() if target is None: filters.append(Photo.id == '__no_match__') else: target_path = os.path.normpath(target.path).rstrip(os.sep) desc_result = await db.execute( select(Folder.id).where( (Folder.path == target_path) | (Folder.path.like(target_path + os.sep + '%')) ) ) desc_ids = [row[0] for row in desc_result.all()] filters.append(Photo.folder_id.in_(desc_ids)) # Media type filter if media_type: types = media_type.split(',') filters.append(Photo.media_type.in_(types)) # Rating filter if rating_min is not None: filters.append(Photo.rating >= rating_min) if rating_max is not None: filters.append(Photo.rating <= rating_max) # Color label filter if color_label: if color_label == 'none': filters.append(Photo.color_label.is_(None)) else: filters.append(Photo.color_label == color_label) # Discard filter — defaults to hiding discarded photos filters.append(Photo.is_discarded == is_discarded) # Hidden-folder filter. Photos in folders the user has marked # "hidden from views" (or any descendant of one) are excluded from # every cross-cutting listing — All Photos, Rated, Colors, Tags, # People, Map, search, etc. We only apply the filter when the # request isn't already scoped to a user-intentional collection: # - folder_id set: the user is explicitly browsing that folder, # which is precisely how hidden folders are "opened" again. # - heap_id set: heaps are hand-curated. If the user added a # photo to a heap and later hid its folder, the heap still # reflects their explicit pick. if not folder_id and not heap_id: filters.append(Photo.is_hidden.is_(False)) # Duplicate filter — only applied when explicitly set, so the default # view shows everything regardless of duplicate status. if is_duplicate is not None: filters.append(Photo.is_duplicate == is_duplicate) if has_date_warning is not None: filters.append(Photo.has_date_warning == has_date_warning) # Heap membership filter — restrict to photos that belong to the heap. if heap_id: filters.append( Photo.id.in_( select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) ) ) # Tag filter — comma-separated tag ids, AND semantics. A photo must # have a row in photo_tags for EVERY listed tag. Implemented as a # single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost # is independent of the number of tags being filtered. if tag_ids: tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()] if tag_id_list: matching_photos = ( select(photo_tags.c.photo_id) .where(photo_tags.c.tag_id.in_(tag_id_list)) .group_by(photo_tags.c.photo_id) .having( func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list) ) ) filters.append(Photo.id.in_(matching_photos)) # Apply all filters if filters: query = query.where(and_(*filters)) # Apply sorting. The sort field is whitelisted so a malicious client # can't pass an arbitrary column name (e.g. "filepath" leaks paths or # any other Photo attribute the model exposes). SORT_WHITELIST = { "taken_at": Photo.taken_at, "added_at": Photo.added_at, "filename": Photo.filename, "file_size": Photo.file_size, "rating": Photo.rating, } sort_column = SORT_WHITELIST.get(sort, Photo.taken_at) desc = order == "desc" # Keyset / cursor pagination — O(1) regardless of page depth. # The cursor encodes the last-seen (sort_value, id) pair so the DB # can seek directly to the next slice via an indexed range scan # instead of skipping N rows with OFFSET. if cursor: try: decoded = json.loads(base64.urlsafe_b64decode(cursor)) cursor_val = decoded["v"] cursor_id = decoded["id"] # For datetime columns, parse the ISO string back. if sort in ("taken_at", "added_at") and cursor_val is not None: cursor_val = datetime.fromisoformat(cursor_val) except Exception: raise HTTPException(status_code=400, detail="Invalid cursor") # Keyset condition: for DESC we want rows "less than" the cursor, # for ASC rows "greater than". We use (sort_col, id) tuple # comparison which handles NULLs and ties correctly. if desc: if cursor_val is None: # NULL sorts last in DESC with NULLS LAST — seek past it by id query = query.where( or_( sort_column.is_(None) & (Photo.id < cursor_id), ) ) else: query = query.where( or_( sort_column < cursor_val, and_(sort_column == cursor_val, Photo.id < cursor_id), sort_column.is_(None), ) ) else: if cursor_val is None: query = query.where( or_( sort_column.is_(None) & (Photo.id > cursor_id), ) ) else: query = query.where( or_( sort_column > cursor_val, and_(sort_column == cursor_val, Photo.id > cursor_id), ) ) if desc: query = query.order_by(sort_column.desc().nulls_last(), Photo.id.desc()) else: query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc()) # Count total results (only when no cursor — first page needs it; # subsequent pages reuse the total from the first response). total = None if not cursor: count_query = select(func.count()).select_from(query.subquery()) total_result = await db.execute(count_query) total = total_result.scalar() # Fallback to offset pagination when no cursor is provided and page > 1 # (backward compat for any callers not yet using cursors). if not cursor and page > 1: offset = (page - 1) * per_page query = query.offset(offset) query = query.limit(per_page) # Execute query result = await db.execute(query) photos = result.scalars().all() # Build next_cursor from the last row in this batch. next_cursor = None if photos and len(photos) == per_page: last = photos[-1] sort_val = getattr(last, sort if sort in SORT_WHITELIST else "taken_at") if isinstance(sort_val, datetime): sort_val = sort_val.isoformat() cursor_payload = json.dumps({"v": sort_val, "id": last.id}) next_cursor = base64.urlsafe_b64encode(cursor_payload.encode()).decode() # Convert to response, attaching tags inline so the frontend can group # client-side without a second round-trip. # In shared context, resolve owner usernames for photos from other users. owner_cache: dict[str, str] = {} # user_id → username if is_shared_context: other_user_ids = {p.user_id for p in photos if p.user_id != current_user.id} if other_user_ids: from app.models.user import User as UserModel user_result = await db.execute( select(UserModel.id, UserModel.username).where(UserModel.id.in_(other_user_ids)) ) owner_cache = {uid: uname for uid, uname in user_result.all()} photo_dicts = [] for photo in photos: d = PhotoResponse.from_orm(photo).dict() d["tags"] = [ {"id": t.id, "name": t.name, "color": t.color} for t in (photo.tags or []) ] if is_shared_context and photo.user_id != current_user.id: d["owner_username"] = owner_cache.get(photo.user_id) photo_dicts.append(d) response = { "photos": photo_dicts, "per_page": per_page, "next_cursor": next_cursor, } # Include total + legacy page fields on first page / non-cursor requests if total is not None: response["total"] = total response["page"] = page response["pages"] = (total + per_page - 1) // per_page if total else 0 return response @router.get("/map") async def list_photos_with_gps( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Lightweight listing of every non-discarded photo that has GPS coordinates, used by the Map view.""" result = await db.execute( select( Photo.id, Photo.latitude, Photo.longitude, Photo.taken_at, ).where( Photo.user_id == current_user.id, Photo.is_discarded.is_(False), Photo.is_hidden.is_(False), Photo.latitude.is_not(None), Photo.longitude.is_not(None), ) ) return [ { "id": row.id, "latitude": row.latitude, "longitude": row.longitude, "taken_at": row.taken_at.isoformat() if row.taken_at else None, } for row in result.all() ] @router.get("/memories") async def get_memories( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """'On this day' — photos taken on this date in previous years. Returns groups keyed by year, each with up to 12 photos. Only considers non-discarded, non-hidden photos with an EXIF-sourced taken_at (no filesystem-guessed dates to avoid false matches). """ from sqlalchemy import extract today = datetime.now().date() result = await db.execute( select( Photo.id, Photo.filename, Photo.taken_at, Photo.thumb_small, Photo.thumb_medium, Photo.media_type, Photo.width, Photo.height, Photo.rating, Photo.color_label, Photo.is_discarded, ) .where( Photo.user_id == current_user.id, Photo.is_discarded.is_(False), Photo.is_hidden.is_(False), Photo.taken_at.is_not(None), Photo.taken_at_source == "exif", extract("month", Photo.taken_at) == today.month, extract("day", Photo.taken_at) == today.day, extract("year", Photo.taken_at) < today.year, ) .order_by(Photo.taken_at.desc()) ) rows = result.all() # Group by year years: dict[int, list] = {} for row in rows: year = row.taken_at.year group = years.setdefault(year, []) if len(group) >= 12: continue group.append({ "id": row.id, "filename": row.filename, "taken_at": row.taken_at.isoformat(), "thumb_small": row.thumb_small, "thumb_medium": row.thumb_medium, "media_type": row.media_type, "width": row.width, "height": row.height, "rating": row.rating, "color_label": row.color_label, "is_discarded": row.is_discarded, }) memories = [ {"year": year, "years_ago": today.year - year, "photos": photos} for year, photos in sorted(years.items()) ] return {"date": today.isoformat(), "memories": memories} @router.get("/{photo_id}") async def get_photo( photo_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Get single photo with full EXIF and its tags.""" photo = await get_user_photo(photo_id, current_user, db) # Fetch tags via the join table so we don't need to declare a # relationship on the Photo model side. tag_result = await db.execute( select(Tag) .join(photo_tags, Tag.id == photo_tags.c.tag_id) .where(photo_tags.c.photo_id == photo_id) .order_by(Tag.name.asc()) ) tags = tag_result.scalars().all() base = PhotoResponse.from_orm(photo).dict() base["tags"] = [ {"id": t.id, "name": t.name, "color": t.color} for t in tags ] return base @router.post("/{photo_id}/tags", status_code=201) async def add_photo_tags( photo_id: str, body: dict, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Add one or more tags to a photo. Body: { tag_ids: [str, ...] }. Idempotent: re-adding existing members is a no-op.""" await get_user_photo(photo_id, current_user, db) tag_ids = body.get("tag_ids") or [] if not isinstance(tag_ids, list) or not tag_ids: return {"status": "success", "added": 0} existing = await db.execute( select(photo_tags.c.tag_id).where( photo_tags.c.photo_id == photo_id, photo_tags.c.tag_id.in_(tag_ids), ) ) existing_ids = {row[0] for row in existing.all()} new_ids = [tid for tid in tag_ids if tid not in existing_ids] if new_ids: from sqlalchemy import insert await db.execute( insert(photo_tags), [{"photo_id": photo_id, "tag_id": tid} for tid in new_ids], ) await db.commit() return {"status": "success", "added": len(new_ids)} @router.delete("/{photo_id}/tags/{tag_id}", status_code=204) async def remove_photo_tag( photo_id: str, tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Remove a tag from a photo. Removing a non-member is a no-op.""" await get_user_photo(photo_id, current_user, db) from sqlalchemy import delete as sql_delete await db.execute( sql_delete(photo_tags).where( photo_tags.c.photo_id == photo_id, photo_tags.c.tag_id == tag_id, ) ) await db.commit() return None async def _get_photo_with_share_fallback( photo_id: str, user: User, db: AsyncSession, *, options=None, ) -> Photo: """Fetch a photo the user owns, or one they can access via a share. The fast path (owned photo) does a single indexed query. The share fallback only runs when the first query returns nothing — this happens only for shared photos, not during normal browsing. `options` is a list of SQLAlchemy loader options applied to both queries. Pass `[joinedload(Photo.user)]` from the thumb handler so the photo lookup also yields the owner row (NC creds) in one round trip instead of issuing a second SELECT users. """ # Fast path — owned photo (single indexed query, no extra joins). stmt = select(Photo).where(Photo.id == photo_id, Photo.user_id == user.id) if options: stmt = stmt.options(*options) result = await db.execute(stmt) photo = result.scalar_one_or_none() if photo: return photo # Slow path — check share access (only for shared photos). if await can_access_photo_via_share(photo_id, user, db): stmt = select(Photo).where(Photo.id == photo_id) if options: stmt = stmt.options(*options) result = await db.execute(stmt) photo = result.scalar_one_or_none() if photo: return photo raise HTTPException(status_code=404, detail="Photo not found") # Pixel box mule's three logical sizes map to. Nextcloud's preview # endpoint takes (x, y) as a bounding box and `a=true` preserves the # source aspect ratio, so passing a square box is fine. Keep these in # sync with the worker's THUMB_SIZES if you ever change them. _NC_PREVIEW_PX = {"small": 240, "medium": 640, "large": 1280} @router.get("/{photo_id}/thumb/{size}") async def get_thumbnail( photo_id: str, size: str, response: Response, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user_media), ): """Serve a thumbnail. Primary path: proxy Nextcloud's `/index.php/core/preview` for the photo's `nextcloud_fileid`, authenticated with the owner's NC app password. Nextcloud already maintains previews for the source file; duplicating that work in `/data/thumbs/*` was burning disk and CPU. Fallback path: legacy / non-NC photos (where `nextcloud_fileid` is NULL) and any NC error keep working through the original on-disk thumbnail cache + inline-generate fallback. The fallback is intentionally identical to the old handler so a revert is one file diff. """ if size not in _NC_PREVIEW_PX: raise HTTPException(status_code=400, detail="Invalid thumbnail size") # joinedload(Photo.user) folds the owner row into the same query so # we don't issue a follow-up SELECT users on every thumbnail. photo = await _get_photo_with_share_fallback( photo_id, current_user, db, options=[joinedload(Photo.user)], ) # ── Primary: proxy Nextcloud's preview endpoint ──────────────────── if photo.nextcloud_fileid and photo.user_id: owner = photo.user if owner is not None: try: upstream = await get_preview_async( owner, photo.nextcloud_fileid, _NC_PREVIEW_PX[size], _NC_PREVIEW_PX[size], ) except NextcloudCredentialsMissing: # Owner hasn't set their NC app password — fall through # to disk; that path still works for them. upstream = None except Exception as e: logger.warning( "NC preview proxy failed for photo %s size=%s: %s", photo_id, size, e, ) upstream = None if upstream is not None and upstream.is_success: headers = { "Cache-Control": "private, max-age=86400", "X-Mule-Thumb-Source": "nextcloud", } etag = upstream.headers.get("etag") if etag: headers["ETag"] = etag media_type = upstream.headers.get( "content-type", "image/jpeg" ) return Response( content=upstream.content, media_type=media_type, headers=headers, ) # Non-success or exception: log + fall through. if upstream is not None: logger.info( "NC preview returned %s for photo=%s fileid=%s — falling back to disk", upstream.status_code, photo_id, photo.nextcloud_fileid, ) # ── Fallback: on-disk thumbnail (unchanged from pre-NC-proxy) ────── if photo.user_id: thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}" else: thumb_dir = f"/data/thumbs/{photo_id}" thumb_path = f"{thumb_dir}/{size}.webp" if not os.path.exists(thumb_path): # Queue background generation (handles RAW/HEIC/video properly) from app.tasks.thumbs import generate_thumbnails generate_thumbnails.delay(photo_id) # Best-effort inline fallback for standard images so the first # request doesn't have to wait for the worker. RAW/HEIC/video # and missing source files fall through to a clean 404 below. if photo.filepath and os.path.exists(photo.filepath): from PIL import Image try: os.makedirs(thumb_dir, exist_ok=True) img = Image.open(photo.filepath) # Auto-rotate based on EXIF from PIL import ExifTags try: for orientation in ExifTags.TAGS.keys(): if ExifTags.TAGS[orientation] == 'Orientation': break exif = img._getexif() if exif is not None: orient = exif.get(orientation) if orient == 3: img = img.rotate(180, expand=True) elif orient == 6: img = img.rotate(270, expand=True) elif orient == 8: img = img.rotate(90, expand=True) except: pass # Generate thumbnail size sizes = {'small': 150, 'medium': 400, 'large': 800} target_size = sizes.get(size, 400) img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) # Save as WebP img.save(thumb_path, 'WEBP', quality=85, optimize=True) except HTTPException: raise except Exception as e: logger.warning( f"Inline thumbnail fallback failed for {photo_id} ({size}); " f"waiting on worker: {e}" ) # If the inline fallback didn't (or couldn't) produce the file, # tell the client to retry instead of crashing in FileResponse/nginx. if not os.path.exists(thumb_path): raise HTTPException( status_code=404, detail="Thumbnail not ready", headers={"Retry-After": "2"}, ) response.headers["X-Mule-Thumb-Source"] = "disk" # Same one-day cache as the NC primary path so re-renders / back- # button navigation hit the browser cache instead of round-tripping # the backend just to re-serve an unchanged WebP. response.headers["Cache-Control"] = "private, max-age=86400" # Check if we're behind Nginx if os.environ.get('USE_X_ACCEL_REDIRECT'): # Use Nginx X-Accel-Redirect for better performance response.headers['X-Accel-Redirect'] = f'/internal_thumbs/{photo_id}/{size}.webp' response.headers['Content-Type'] = 'image/webp' return Response() else: # Direct file serving for development return FileResponse( thumb_path, media_type='image/webp', headers={"Cache-Control": "private, max-age=86400"}, ) _INLINE_MEDIA_TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mkv': 'video/x-matroska', '.m4v': 'video/mp4', } # How big each chunk we yield is when streaming a Range response. 1 MB # strikes a balance between syscall count and memory residency. _RANGE_CHUNK = 1024 * 1024 def _parse_range(header: str, size: int) -> Optional[tuple[int, int]]: """Parse a single-range `Range: bytes=START-END` header. Returns (start, end) inclusive on success, or None when the header is malformed / multi-range (we don't bother with multipart). The caller falls back to a 200 response in that case. """ if not header or not header.startswith("bytes="): return None spec = header[len("bytes="):] if "," in spec: # multi-range; punt return None if "-" not in spec: return None start_s, end_s = spec.split("-", 1) try: if start_s == "": # bytes=-N → last N bytes n = int(end_s) if n <= 0: return None start = max(0, size - n) end = size - 1 else: start = int(start_s) end = int(end_s) if end_s else size - 1 except ValueError: return None if start < 0 or start >= size or end < start: return None end = min(end, size - 1) return start, end @router.get("/{photo_id}/original") async def get_original( photo_id: str, request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user_media), ): """Serve the original file inline (web-safe formats) with HTTP Range support so `