334 lines
12 KiB
Python
334 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, 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.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,
|
|
):
|
|
ext = (ext or "").lower()
|
|
if ext not in ALLOWED_IMAGE_EXTS:
|
|
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ext", 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)
|
|
proxy_uri = None
|
|
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(
|
|
image.original_url,
|
|
transport=transport,
|
|
proxy=proxy_uri,
|
|
cache_control="public, max-age=31536000, immutable",
|
|
range_header=request.headers.get("Range"),
|
|
)
|
|
if should_mark_ok:
|
|
await mark_image_ok(engine, image_id=int(image.id), now=now)
|
|
if needs_hydrate:
|
|
try:
|
|
await enqueue_opportunistic_hydrate_metadata(engine, illust_id=int(image.illust_id), reason="image_proxy")
|
|
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 mark_image_failure(
|
|
engine,
|
|
image_id=int(image.id),
|
|
now=now,
|
|
error_code=exc.code.value,
|
|
error_message=exc.message,
|
|
)
|
|
raise
|