Files
Random-image-api/backend/app/api/public/images.py
T
2026-02-23 06:54:02 +08:00

371 lines
14 KiB
Python

from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Query, Request
from fastapi.responses import JSONResponse
from app.core.errors import ApiError, ErrorCode
from app.core.http_stream import stream_url
import sqlalchemy as sa
from app.core.pixiv_urls import ALLOWED_IMAGE_EXTS
from app.core.pximg_reverse_proxy import normalize_pximg_mirror_host, rewrite_pximg_to_mirror
from app.core.request_id import get_or_create_request_id, set_request_id_header, set_request_id_on_state
from app.core.runtime_settings import load_runtime_config
from app.core.time import iso_utc_ms
from app.core.proxy_routing import select_proxy_uri_for_url
from app.db.images_get import get_image_by_id
from app.db.images_list import list_images as db_list_images
from app.db.images_mark import mark_image_failure, mark_image_ok
from app.db.models.image_tags import ImageTag
from app.db.session import create_sessionmaker
from app.db.tags_get import get_tag_names_for_image
from app.jobs.enqueue import enqueue_opportunistic_hydrate_metadata
router = APIRouter()
_MAX_TAG_FILTERS = 50
@router.get("/images")
async def list_images(
request: Request,
limit: int = 50,
cursor: str | None = None,
r18: int = 0,
r18_strict: int = 1,
ai_type: str = "any",
orientation: str = "any",
min_width: int = 0,
min_height: int = 0,
min_pixels: int = 0,
included_tags: list[str] | None = Query(default=None),
excluded_tags: list[str] | None = Query(default=None),
user_id: int | None = None,
illust_id: int | None = None,
created_from: str | None = None,
created_to: str | None = None,
) -> Any:
if limit < 1 or limit > 200:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported limit", status_code=400)
cursor_i: int | None = None
cursor_raw = (cursor or "").strip()
if cursor_raw:
if not cursor_raw.isdigit():
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
cursor_i = int(cursor_raw)
if cursor_i <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
if r18 not in {0, 1, 2}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18", status_code=400)
if r18_strict not in {0, 1}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18_strict", status_code=400)
ai_type_raw = (ai_type or "any").strip().lower()
ai_type_i: int | None = None
if ai_type_raw in {"", "any"}:
ai_type_i = None
elif ai_type_raw in {"0", "1"}:
ai_type_i = int(ai_type_raw)
else:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ai_type", status_code=400)
orientation = (orientation or "").strip().lower()
orientation_map = {"any": None, "portrait": 1, "landscape": 2, "square": 3}
if orientation not in orientation_map:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported orientation", status_code=400)
if min_width < 0 or min_height < 0 or min_pixels < 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
def _parse_tags(values: list[str] | None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in values or []:
for part in str(raw).split("|"):
name = part.strip()
if not name or name in seen:
continue
seen.add(name)
out.append(name)
return out
included = _parse_tags(included_tags)
excluded = _parse_tags(excluded_tags)
if len(included) > _MAX_TAG_FILTERS or len(excluded) > _MAX_TAG_FILTERS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag filters", status_code=400)
if user_id is not None and int(user_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported user_id", status_code=400)
if illust_id is not None and int(illust_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400)
def _normalize_iso_utc(value: str) -> str:
raw = (value or "").strip()
if not raw:
raise ValueError("empty datetime")
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.astimezone(timezone.utc).replace(microsecond=0)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
created_from_norm: str | None = None
created_to_norm: str | None = None
try:
if created_from is not None:
created_from_norm = _normalize_iso_utc(created_from)
if created_to is not None:
created_to_norm = _normalize_iso_utc(created_to)
except Exception:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported created_*", status_code=400)
if created_from_norm is not None and created_to_norm is not None:
if created_from_norm > created_to_norm:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="created_from > created_to", status_code=400)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
images, next_cursor = await db_list_images(
session,
limit=limit,
cursor=cursor_i,
r18=r18,
r18_strict=bool(r18_strict),
orientation=orientation_map[orientation],
ai_type=ai_type_i,
min_width=min_width,
min_height=min_height,
min_pixels=min_pixels,
included_tags=included,
excluded_tags=excluded,
user_id=user_id,
illust_id=illust_id,
created_from=created_from_norm,
created_to=created_to_norm,
)
rid = get_or_create_request_id(request)
set_request_id_on_state(request, rid)
items = [
{
"id": str(img.id),
"illust_id": str(img.illust_id),
"page_index": img.page_index,
"ext": img.ext,
"width": img.width,
"height": img.height,
"x_restrict": img.x_restrict,
"ai_type": img.ai_type,
"bookmark_count": getattr(img, "bookmark_count", None),
"view_count": getattr(img, "view_count", None),
"comment_count": getattr(img, "comment_count", None),
"user": {
"id": str(img.user_id) if img.user_id is not None else None,
"name": img.user_name,
},
"title": img.title,
"created_at_pixiv": img.created_at_pixiv,
}
for img in images
]
resp = JSONResponse(
status_code=200,
content={
"ok": True,
"items": items,
"next_cursor": str(next_cursor) if next_cursor is not None else "",
"request_id": rid,
},
)
set_request_id_header(resp, rid)
return resp
@router.get("/images/{image_id}")
async def get_image(
request: Request,
image_id: int,
) -> Any:
if int(image_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported image_id", status_code=400)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
image = await get_image_by_id(session, image_id=image_id)
if image is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
tags = await get_tag_names_for_image(session, image_id=image.id)
rid = get_or_create_request_id(request)
set_request_id_on_state(request, rid)
resp = JSONResponse(
status_code=200,
content={
"ok": True,
"item": {
"image": {
"id": str(image.id),
"illust_id": str(image.illust_id),
"page_index": image.page_index,
"ext": image.ext,
"width": image.width,
"height": image.height,
"x_restrict": image.x_restrict,
"ai_type": image.ai_type,
"bookmark_count": getattr(image, "bookmark_count", None),
"view_count": getattr(image, "view_count", None),
"comment_count": getattr(image, "comment_count", None),
"user": {
"id": str(image.user_id) if image.user_id is not None else None,
"name": image.user_name,
},
"title": image.title,
"created_at_pixiv": image.created_at_pixiv,
},
"tags": tags,
},
"request_id": rid,
},
)
set_request_id_header(resp, rid)
return resp
@router.get("/i/{image_id}.{ext}")
async def proxy_image(
request: Request,
image_id: int,
ext: str,
background_tasks: BackgroundTasks,
pixiv_cat: int = 0,
pximg_mirror_host: str | None = None,
):
ext = (ext or "").lower()
if ext not in ALLOWED_IMAGE_EXTS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ext", status_code=400)
if pixiv_cat not in {0, 1}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
mirror_host_override: str | None = None
if pximg_mirror_host is not None:
raw = str(pximg_mirror_host or "").strip()
if raw:
mirror_host_override = normalize_pximg_mirror_host(raw)
if mirror_host_override is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
needs_hydrate = False
should_mark_ok = False
async with Session() as session:
image = await get_image_by_id(session, image_id=image_id)
if image is None or (image.ext or "").lower() != ext:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
should_mark_ok = image.last_ok_at is None or image.last_error_code is not None
missing_basic_fields = (
getattr(image, "width", None) is None
or getattr(image, "height", None) is None
or getattr(image, "x_restrict", None) is None
or getattr(image, "ai_type", None) is None
or getattr(image, "user_id", None) is None
or not str(getattr(image, "user_name", "") or "").strip()
or not str(getattr(image, "title", "") or "").strip()
or not str(getattr(image, "created_at_pixiv", "") or "").strip()
or getattr(image, "bookmark_count", None) is None
or getattr(image, "view_count", None) is None
or getattr(image, "comment_count", None) is None
)
if missing_basic_fields:
needs_hydrate = True
else:
tag_row = (
await session.execute(
sa.select(ImageTag.image_id).where(ImageTag.image_id == int(image.id)).limit(1)
)
).scalar_one_or_none()
needs_hydrate = tag_row is None
runtime = await load_runtime_config(engine)
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1
mirror_host = mirror_host_override or str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
async def _best_effort(fn, *args, timeout_s: float = 1.5, **kwargs) -> None: # type: ignore[no-untyped-def]
try:
await asyncio.wait_for(fn(*args, **kwargs), timeout=float(timeout_s))
except Exception:
pass
source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url)
proxy_uri = None
if not use_pixiv_cat:
picked = await select_proxy_uri_for_url(
engine,
request.app.state.settings,
runtime,
url=str(image.original_url),
)
if picked is not None:
proxy_uri = picked.uri
transport = getattr(request.app.state, "httpx_transport", None)
now = iso_utc_ms()
try:
resp = await stream_url(
source_url,
transport=transport,
proxy=proxy_uri,
cache_control="public, max-age=31536000, immutable",
range_header=request.headers.get("Range"),
)
if should_mark_ok:
background_tasks.add_task(_best_effort, mark_image_ok, engine, image_id=int(image.id), now=now, timeout_s=1.5)
if needs_hydrate:
background_tasks.add_task(
_best_effort,
enqueue_opportunistic_hydrate_metadata,
engine,
illust_id=int(image.illust_id),
reason="image_proxy",
timeout_s=2.5,
)
try:
if getattr(resp, "background", None) is None:
resp.background = background_tasks
except Exception:
pass
return resp
except ApiError as exc:
if exc.code in {
ErrorCode.UPSTREAM_STREAM_ERROR,
ErrorCode.UPSTREAM_403,
ErrorCode.UPSTREAM_404,
ErrorCode.UPSTREAM_RATE_LIMIT,
}:
await _best_effort(
mark_image_failure,
engine,
image_id=int(image.id),
now=now,
error_code=exc.code.value,
error_message=exc.message,
timeout_s=1.5,
)
raise