Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
147 lines
5.9 KiB
Python
147 lines
5.9 KiB
Python
"""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)
|
||
# Record the original immediately so a failed thumbnail does not
|
||
# orphan the on-disk file and trigger an eternal re-fetch.
|
||
db.execute(
|
||
text(
|
||
"UPDATE domrf_kn_photos"
|
||
" SET local_path = :lp, downloaded_at = NOW()"
|
||
" WHERE obj_id = :o AND obj_file_id = :f"
|
||
),
|
||
{"lp": str(src), "o": obj_id, "f": file_id},
|
||
)
|
||
db.commit()
|
||
generated = make_thumbnail(src)
|
||
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)
|
||
|
||
# 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")
|