"""Serve cached DOM.РФ photos. GET /api/v1/photos/{obj_id}/{file_id}?size=thumb|full - size=thumb (default): WebP 320×240 from local cache. If neither the thumb nor the original exists locally, lazily fetch the original from DOM.РФ via httpx + Basic auth, save it, generate the thumb, and serve. Subsequent requests hit the disk cache and return in single-digit ms. - size=full: original PNG/JPG if cached locally, else 302 redirect upstream. """ from __future__ import annotations import logging from pathlib import Path from typing import Annotated, Literal import httpx from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse, RedirectResponse, Response from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db from app.services.photos.thumbs import make_thumbnail logger = logging.getLogger(__name__) router = APIRouter() # Same Basic auth used by the kn-API scraper (1:qwe). Photo CDN accepts it # without the TLS-fingerprint challenge that the JSON API enforces. _UPSTREAM_AUTH = "Basic MTpxd2U=" _UPSTREAM_TIMEOUT = httpx.Timeout(8.0, connect=4.0) _PHOTOS_DIR = Path("data/raw/domrf_photos") _PHOTO_LOOKUP_SQL = text( """ SELECT local_path, thumb_path, photo_url, photo_name, size_bytes FROM domrf_kn_photos WHERE obj_id = :obj AND obj_file_id = :fid LIMIT 1 """ ) def _ext_from_name(name: str | None) -> str: n = (name or "").lower() return ".jpg" if n.endswith((".jpg", ".jpeg")) else ".png" def _fetch_upstream(url: str) -> bytes | None: """Fetch upstream image bytes. Returns None on any error (logged).""" try: with httpx.Client(timeout=_UPSTREAM_TIMEOUT, follow_redirects=True) as c: r = c.get(url, headers={"Authorization": _UPSTREAM_AUTH}) if r.status_code == 200 and r.content: return r.content logger.warning("upstream photo %s status=%s len=%s", url, r.status_code, len(r.content)) except Exception as e: logger.warning("upstream photo %s failed: %s", url, e) return None def _persist_original(obj_id: int, file_id: str, name: str | None, data: bytes) -> Path: obj_dir = _PHOTOS_DIR / str(obj_id) obj_dir.mkdir(parents=True, exist_ok=True) dst = obj_dir / f"{file_id}{_ext_from_name(name)}" dst.write_bytes(data) return dst @router.get("/{obj_id}/{file_id}") def get_photo( db: Annotated[Session, Depends(get_db)], obj_id: int, file_id: str, size: Annotated[Literal["thumb", "full"], Query()] = "thumb", ) -> Response: row = db.execute(_PHOTO_LOOKUP_SQL, {"obj": obj_id, "fid": file_id}).mappings().first() if not row: raise HTTPException(status_code=404, detail="photo not registered") local_path = row["local_path"] thumb_path = row["thumb_path"] upstream = row["photo_url"] photo_name = row["photo_name"] headers = {"Cache-Control": "public, max-age=604800, immutable"} # ── size=thumb ────────────────────────────────────────────────────────── if size == "thumb": if thumb_path and Path(thumb_path).exists(): return FileResponse(thumb_path, media_type="image/webp", headers=headers) # Generate from existing local original. if local_path and Path(local_path).exists(): generated = make_thumbnail(Path(local_path)) if generated and generated.exists(): db.execute( text( "UPDATE domrf_kn_photos SET thumb_path = :tp" " WHERE obj_id = :o AND obj_file_id = :f" ), {"tp": str(generated), "o": obj_id, "f": file_id}, ) db.commit() return FileResponse(str(generated), media_type="image/webp", headers=headers) # Lazy: fetch upstream, persist original + thumb, then serve. if upstream: data = _fetch_upstream(upstream) if data: src = _persist_original(obj_id, file_id, photo_name, data) generated = make_thumbnail(src) if generated and generated.exists(): db.execute( text( "UPDATE domrf_kn_photos" " SET local_path = :lp, thumb_path = :tp," " downloaded_at = NOW()" " WHERE obj_id = :o AND obj_file_id = :f" ), {"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id}, ) db.commit() return FileResponse(str(generated), media_type="image/webp", headers=headers) # Last resort — redirect to upstream so the page still renders. if upstream: return RedirectResponse(upstream, status_code=302) raise HTTPException(status_code=404, detail="photo unavailable") # ── size=full ─────────────────────────────────────────────────────────── if local_path and Path(local_path).exists(): return FileResponse(local_path, headers=headers) if upstream: return RedirectResponse(upstream, status_code=302) raise HTTPException(status_code=404, detail="full photo unavailable")