1202 lines
48 KiB
Python
1202 lines
48 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections import deque
|
||
import random
|
||
import os
|
||
import math
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
from threading import Lock
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, Query, Request
|
||
from fastapi.responses import RedirectResponse
|
||
|
||
from app.core.errors import ApiError, ErrorCode
|
||
from app.core.http_stream import stream_url
|
||
from app.core.imgproxy import build_signed_processing_url, load_imgproxy_config_from_settings
|
||
from app.core.pximg_reverse_proxy import (
|
||
normalize_pximg_mirror_host,
|
||
normalize_pximg_proxy,
|
||
pick_pximg_mirror_host_for_request,
|
||
rewrite_pximg_to_mirror,
|
||
)
|
||
from app.core.proxy_routing import select_proxy_uri_for_url
|
||
from app.core.runtime_settings import load_runtime_config
|
||
from app.core.time import iso_utc_ms
|
||
from app.db.images_mark import mark_image_failure, mark_image_ok
|
||
from app.db.tags_get import get_tag_names_for_image
|
||
from app.db.random_pick import pick_random_image, pick_random_images
|
||
from app.db.session import create_sessionmaker
|
||
from app.jobs.enqueue import enqueue_opportunistic_hydrate_metadata
|
||
|
||
router = APIRouter()
|
||
|
||
_MAX_TAG_FILTERS = 50
|
||
_MAX_TAG_OR_TERMS = 20
|
||
_MAX_TAG_TOTAL_TERMS = 200
|
||
|
||
# Best-effort anti-repeat (process-local): reduce short-term duplicates without extra DB writes.
|
||
_RECENT_LOCK = Lock()
|
||
_RECENT_WINDOW_S = 20.0 * 60.0
|
||
_RECENT_MAX_IMAGES = 600
|
||
_RECENT_MAX_AUTHORS = 200
|
||
_RECENT_IMAGES: deque[tuple[float, int]] = deque()
|
||
_RECENT_AUTHORS: deque[tuple[float, int]] = deque()
|
||
|
||
|
||
def _prune_recent(now: float) -> None:
|
||
cutoff = float(now) - float(_RECENT_WINDOW_S)
|
||
while _RECENT_IMAGES and float(_RECENT_IMAGES[0][0]) < cutoff:
|
||
_RECENT_IMAGES.popleft()
|
||
while _RECENT_AUTHORS and float(_RECENT_AUTHORS[0][0]) < cutoff:
|
||
_RECENT_AUTHORS.popleft()
|
||
|
||
while len(_RECENT_IMAGES) > int(_RECENT_MAX_IMAGES):
|
||
_RECENT_IMAGES.popleft()
|
||
while len(_RECENT_AUTHORS) > int(_RECENT_MAX_AUTHORS):
|
||
_RECENT_AUTHORS.popleft()
|
||
|
||
|
||
def _get_recent_sets(now: float) -> tuple[set[int], set[int]]:
|
||
with _RECENT_LOCK:
|
||
_prune_recent(now)
|
||
recent_images = {int(image_id) for _t, image_id in _RECENT_IMAGES}
|
||
recent_authors = {int(user_id) for _t, user_id in _RECENT_AUTHORS}
|
||
return recent_images, recent_authors
|
||
|
||
|
||
def _record_recent(*, now: float, image_id: int, user_id: int | None) -> None:
|
||
try:
|
||
image_id_i = int(image_id)
|
||
except Exception:
|
||
return
|
||
if image_id_i <= 0:
|
||
return
|
||
user_id_i: int | None
|
||
try:
|
||
user_id_i = int(user_id) if user_id is not None else None
|
||
except Exception:
|
||
user_id_i = None
|
||
|
||
with _RECENT_LOCK:
|
||
_prune_recent(now)
|
||
_RECENT_IMAGES.append((float(now), int(image_id_i)))
|
||
if user_id_i is not None and user_id_i > 0:
|
||
_RECENT_AUTHORS.append((float(now), int(user_id_i)))
|
||
|
||
|
||
def _as_nonneg_int(value: Any) -> int:
|
||
if value is None:
|
||
return 0
|
||
if isinstance(value, bool):
|
||
return 0
|
||
try:
|
||
i = int(value)
|
||
except Exception:
|
||
return 0
|
||
return i if i > 0 else 0
|
||
|
||
|
||
_DEFAULT_SCORE_WEIGHTS: dict[str, float] = {
|
||
"bookmark": 4.0,
|
||
"view": 0.5,
|
||
"comment": 2.0,
|
||
"pixels": 1.0,
|
||
"bookmark_rate": 3.0,
|
||
# 适度提升“新鲜感”和“成长速度”,避免老热门长期统治。
|
||
# - freshness: 越新越加分(指数衰减,默认半衰期在代码里固定为 21 天)
|
||
# - bookmark_velocity: 收藏增长率(收藏数 / 存在天数)的对数项
|
||
"freshness": 1.0,
|
||
"bookmark_velocity": 1.2,
|
||
}
|
||
|
||
_DEFAULT_RECOMMENDATION: dict[str, Any] = {
|
||
"pick_mode": "weighted",
|
||
"temperature": 1.0,
|
||
"score_weights": dict(_DEFAULT_SCORE_WEIGHTS),
|
||
"multipliers": {
|
||
"ai": 1.0,
|
||
"non_ai": 1.0,
|
||
"unknown_ai": 1.0,
|
||
"illust": 1.0,
|
||
"manga": 1.0,
|
||
"ugoira": 1.0,
|
||
"unknown_illust_type": 1.0,
|
||
},
|
||
}
|
||
|
||
|
||
def _as_float(value: Any, *, default: float) -> float:
|
||
if value is None:
|
||
return float(default)
|
||
if isinstance(value, bool):
|
||
return float(default)
|
||
try:
|
||
return float(value)
|
||
except Exception:
|
||
return float(default)
|
||
|
||
|
||
def _quality_score(image: Any, *, weights: dict[str, float] | None = None) -> float:
|
||
w = weights or _DEFAULT_SCORE_WEIGHTS
|
||
w_bookmark = float(w.get("bookmark", _DEFAULT_SCORE_WEIGHTS["bookmark"]))
|
||
w_view = float(w.get("view", _DEFAULT_SCORE_WEIGHTS["view"]))
|
||
w_comment = float(w.get("comment", _DEFAULT_SCORE_WEIGHTS["comment"]))
|
||
w_pixels = float(w.get("pixels", _DEFAULT_SCORE_WEIGHTS["pixels"]))
|
||
w_bookmark_rate = float(w.get("bookmark_rate", _DEFAULT_SCORE_WEIGHTS["bookmark_rate"]))
|
||
|
||
bookmark_count = _as_nonneg_int(getattr(image, "bookmark_count", None))
|
||
view_count = _as_nonneg_int(getattr(image, "view_count", None))
|
||
comment_count = _as_nonneg_int(getattr(image, "comment_count", None))
|
||
|
||
width = _as_nonneg_int(getattr(image, "width", None))
|
||
height = _as_nonneg_int(getattr(image, "height", None))
|
||
pixels = width * height if width > 0 and height > 0 else 0
|
||
|
||
rate_term = 0.0
|
||
if view_count > 0:
|
||
bookmark_rate_per_mille = (float(bookmark_count) / float(view_count)) * 1000.0
|
||
rate_term = math.log1p(max(0.0, bookmark_rate_per_mille))
|
||
|
||
score = (
|
||
float(w_bookmark) * math.log1p(bookmark_count)
|
||
+ float(w_view) * math.log1p(view_count)
|
||
+ float(w_comment) * math.log1p(comment_count)
|
||
+ float(w_pixels) * math.log1p(float(pixels) / 1_000_000.0)
|
||
+ float(w_bookmark_rate) * rate_term
|
||
)
|
||
return float(score)
|
||
|
||
|
||
@router.get("/random")
|
||
async def random_image(
|
||
request: Request,
|
||
background_tasks: BackgroundTasks,
|
||
format: str = "image",
|
||
redirect: int = 0,
|
||
attempts: int | None = None,
|
||
seed: str | None = None,
|
||
strategy: str | None = None,
|
||
quality_samples: int | None = None,
|
||
r18: int = 0,
|
||
r18_strict: int | None = None,
|
||
ai_type: str = "any",
|
||
illust_type: str = "any",
|
||
orientation: str = "any",
|
||
layout: str | None = None,
|
||
adaptive: int = 0,
|
||
pixiv_cat: int = 0,
|
||
pximg_mirror_host: str | None = None,
|
||
proxy: str | None = None,
|
||
min_width: int = 0,
|
||
min_height: int = 0,
|
||
min_pixels: int = 0,
|
||
min_bookmarks: int = 0,
|
||
min_views: int = 0,
|
||
min_comments: 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 format not in {"image", "json", "simple_json"}:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported format", status_code=400)
|
||
if redirect not in {0, 1}:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported redirect", status_code=400)
|
||
seed_norm = (seed or "").strip()
|
||
if seed is not None and not seed_norm:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported seed", status_code=400)
|
||
if seed_norm and len(seed_norm) > 128:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported seed", 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)
|
||
|
||
illust_type_raw = (illust_type or "any").strip().lower()
|
||
illust_type_i: int | None = None
|
||
if illust_type_raw in {"", "any"}:
|
||
illust_type_i = None
|
||
elif illust_type_raw in {"0", "illust", "illustration"}:
|
||
illust_type_i = 0
|
||
elif illust_type_raw in {"1", "manga"}:
|
||
illust_type_i = 1
|
||
elif illust_type_raw in {"2", "ugoira"}:
|
||
illust_type_i = 2
|
||
else:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_type", status_code=400)
|
||
|
||
if r18 not in {0, 1, 2}:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18", status_code=400)
|
||
|
||
if adaptive not in {0, 1}:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported adaptive", status_code=400)
|
||
|
||
if pixiv_cat not in {0, 1}:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
|
||
|
||
pximg_mirror_host_override: str | None = None
|
||
if pximg_mirror_host is not None:
|
||
raw = str(pximg_mirror_host or "").strip()
|
||
if raw:
|
||
pximg_mirror_host_override = normalize_pximg_mirror_host(raw)
|
||
if pximg_mirror_host_override is None:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
|
||
|
||
layout_source = "orientation"
|
||
raw_layout = orientation
|
||
if layout is not None:
|
||
layout_source = "layout"
|
||
raw_layout = layout
|
||
|
||
layout_norm = (raw_layout or "").strip().lower()
|
||
alias_map = {"vertical": "portrait", "horizontal": "landscape"}
|
||
layout_norm = alias_map.get(layout_norm, layout_norm)
|
||
orientation_map = {"any": None, "portrait": 1, "landscape": 2, "square": 3}
|
||
if layout_norm not in orientation_map:
|
||
raise ApiError(
|
||
code=ErrorCode.BAD_REQUEST,
|
||
message="Unsupported layout" if layout_source == "layout" else "Unsupported orientation",
|
||
status_code=400,
|
||
)
|
||
|
||
min_width_i = int(min_width)
|
||
min_height_i = int(min_height)
|
||
min_pixels_i = int(min_pixels)
|
||
min_bookmarks_i = int(min_bookmarks)
|
||
min_views_i = int(min_views)
|
||
min_comments_i = int(min_comments)
|
||
if min_width_i < 0 or min_height_i < 0 or min_pixels_i < 0:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
|
||
if min_bookmarks_i < 0 or min_views_i < 0 or min_comments_i < 0:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
|
||
|
||
# 自适应:在用户没有显式指定的情况下,根据设备类型设置默认的方向/分辨率门槛。
|
||
# 注意:不会覆盖用户显式传入的 orientation/layout/min_*。
|
||
if int(adaptive) == 1:
|
||
qp = request.query_params
|
||
orientation_explicit = ("layout" in qp) or ("orientation" in qp)
|
||
min_explicit = ("min_width" in qp) or ("min_height" in qp) or ("min_pixels" in qp)
|
||
|
||
ch_mobile = (request.headers.get("sec-ch-ua-mobile") or request.headers.get("Sec-CH-UA-Mobile") or "").strip()
|
||
if ch_mobile == "?1":
|
||
is_mobile = True
|
||
elif ch_mobile == "?0":
|
||
is_mobile = False
|
||
else:
|
||
ua = (request.headers.get("user-agent") or request.headers.get("User-Agent") or "").lower()
|
||
is_mobile = any(x in ua for x in ("mobi", "android", "iphone", "ipad", "ipod"))
|
||
|
||
if not orientation_explicit and layout_norm == "any":
|
||
layout_norm = "portrait" if is_mobile else "landscape"
|
||
|
||
if not min_explicit and min_width_i == 0 and min_height_i == 0 and min_pixels_i == 0:
|
||
min_pixels_i = 1_000_000 if is_mobile else 2_000_000
|
||
|
||
def _parse_tag_filters(values: list[str] | None) -> list[str]:
|
||
"""
|
||
Tag filters support "AND of groups" where each query param is one group.
|
||
|
||
Examples:
|
||
- included_tags=girl&included_tags=boy -> girl AND boy
|
||
- included_tags=girl|boy -> girl OR boy
|
||
- included_tags=girl|boy&included_tags=white|black -> (girl OR boy) AND (white OR black)
|
||
"""
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in values or []:
|
||
expr = str(raw or "").strip()
|
||
if not expr or expr in seen:
|
||
continue
|
||
seen.add(expr)
|
||
out.append(expr)
|
||
return out
|
||
|
||
included = _parse_tag_filters(included_tags)
|
||
excluded = _parse_tag_filters(excluded_tags)
|
||
|
||
def _validate_tag_filters(values: list[str]) -> None:
|
||
total_terms = 0
|
||
for expr in values:
|
||
parts: list[str] = []
|
||
seen_terms: set[str] = set()
|
||
for part in str(expr).split("|"):
|
||
term = part.strip()
|
||
if not term or term in seen_terms:
|
||
continue
|
||
seen_terms.add(term)
|
||
parts.append(term)
|
||
if len(parts) > _MAX_TAG_OR_TERMS:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms in a group", status_code=400)
|
||
total_terms += len(parts)
|
||
if total_terms > _MAX_TAG_TOTAL_TERMS:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms", status_code=400)
|
||
|
||
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)
|
||
_validate_tag_filters(included)
|
||
_validate_tag_filters(excluded)
|
||
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)
|
||
|
||
def _no_match_error() -> ApiError:
|
||
applied_filters: dict[str, Any] = {
|
||
"r18": r18,
|
||
"r18_strict": int(r18_strict),
|
||
"ai_type": ai_type_raw,
|
||
"illust_type": illust_type_raw,
|
||
"adaptive": int(adaptive),
|
||
"orientation": layout_norm,
|
||
"min_width": int(min_width_i),
|
||
"min_height": int(min_height_i),
|
||
"min_pixels": int(min_pixels_i),
|
||
"min_bookmarks": int(min_bookmarks_i),
|
||
"min_views": int(min_views_i),
|
||
"min_comments": int(min_comments_i),
|
||
}
|
||
if included:
|
||
applied_filters["included_tags"] = included
|
||
if excluded:
|
||
applied_filters["excluded_tags"] = excluded
|
||
if user_id is not None:
|
||
applied_filters["user_id"] = int(user_id)
|
||
if illust_id is not None:
|
||
applied_filters["illust_id"] = int(illust_id)
|
||
if created_from_norm is not None:
|
||
applied_filters["created_from"] = created_from_norm
|
||
if created_to_norm is not None:
|
||
applied_filters["created_to"] = created_to_norm
|
||
|
||
suggestions: list[str] = ["运行元数据补全任务以提升元数据覆盖率"]
|
||
if r18 == 0 and int(r18_strict) == 1:
|
||
suggestions.append("将 r18_strict=0 以允许未知 x_restrict(冷启动阶段更容易命中)")
|
||
if layout_norm != "any":
|
||
suggestions.append("将 orientation=any(取消方向限制)")
|
||
if int(min_width_i) > 0 or int(min_height_i) > 0 or int(min_pixels_i) > 0:
|
||
suggestions.append("降低 min_width/min_height/min_pixels(放宽分辨率门槛)")
|
||
if int(min_bookmarks_i) > 0 or int(min_views_i) > 0 or int(min_comments_i) > 0:
|
||
suggestions.append("降低 min_bookmarks/min_views/min_comments(放宽热度门槛)")
|
||
if included:
|
||
suggestions.append("放宽 included_tags(减少必须包含的标签)")
|
||
if excluded:
|
||
suggestions.append("放宽 excluded_tags(减少必须排除的标签)")
|
||
if user_id is not None:
|
||
suggestions.append("移除 user_id 过滤")
|
||
if illust_id is not None:
|
||
suggestions.append("移除 illust_id 过滤")
|
||
if ai_type_i is not None:
|
||
suggestions.append("将 ai_type=any(取消 AI 限制)")
|
||
if illust_type_i is not None:
|
||
suggestions.append("将 illust_type=any(取消作品类型限制)")
|
||
if created_from_norm is not None or created_to_norm is not None:
|
||
suggestions.append("扩大 created_from/created_to 时间范围")
|
||
if int(adaptive) == 1:
|
||
suggestions.append("若自适应导致过滤过严,可尝试 adaptive=0 或显式设置 min_*")
|
||
|
||
return ApiError(
|
||
code=ErrorCode.NO_MATCH,
|
||
message="没有匹配的图片。",
|
||
status_code=404,
|
||
details={"hints": {"applied_filters": applied_filters, "suggestions": suggestions}},
|
||
)
|
||
|
||
engine = request.app.state.engine
|
||
Session = create_sessionmaker(engine)
|
||
runtime = await load_runtime_config(engine)
|
||
|
||
proxy_override: str | None = None
|
||
if proxy is not None:
|
||
raw = str(proxy or "").strip()
|
||
if raw:
|
||
proxy_override = normalize_pximg_proxy(raw, extra_hosts=runtime.image_proxy_extra_pximg_mirror_hosts)
|
||
if proxy_override is None:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported proxy", status_code=400)
|
||
|
||
mirror_host_override = proxy_override or pximg_mirror_host_override
|
||
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1 or proxy_override is not None
|
||
runtime_mirror_host = str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
|
||
mirror_host = mirror_host_override or (
|
||
pick_pximg_mirror_host_for_request(headers=request.headers, fallback_host=runtime_mirror_host)
|
||
if use_pixiv_cat
|
||
else runtime_mirror_host
|
||
)
|
||
|
||
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
|
||
|
||
random_defaults = runtime.random_defaults if isinstance(runtime.random_defaults, dict) else {}
|
||
|
||
attempts_source = "query"
|
||
attempts_i = 3
|
||
if attempts is not None:
|
||
try:
|
||
attempts_i = int(attempts)
|
||
except Exception as exc:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported attempts", status_code=400) from exc
|
||
else:
|
||
raw = random_defaults.get("default_attempts")
|
||
if raw is None:
|
||
attempts_source = "fallback"
|
||
attempts_i = 3
|
||
else:
|
||
attempts_source = "runtime"
|
||
try:
|
||
attempts_i = int(raw)
|
||
except Exception:
|
||
attempts_i = 3
|
||
if attempts_i < 1 or attempts_i > 10:
|
||
if attempts_source == "query":
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported attempts", status_code=400)
|
||
attempts_source = "fallback"
|
||
attempts_i = 3
|
||
attempts = int(attempts_i)
|
||
|
||
r18_strict_source = "query"
|
||
r18_strict_i = 1
|
||
if r18_strict is not None:
|
||
try:
|
||
r18_strict_i = int(r18_strict)
|
||
except Exception as exc:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18_strict", status_code=400) from exc
|
||
else:
|
||
raw = random_defaults.get("default_r18_strict")
|
||
if raw is None:
|
||
r18_strict_source = "fallback"
|
||
r18_strict_i = 1
|
||
elif isinstance(raw, bool):
|
||
r18_strict_source = "runtime"
|
||
r18_strict_i = 1 if raw else 0
|
||
else:
|
||
r18_strict_source = "runtime"
|
||
try:
|
||
r18_strict_i = int(raw)
|
||
except Exception:
|
||
r18_strict_i = 1
|
||
if r18_strict_i not in {0, 1}:
|
||
if r18_strict_source == "query":
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18_strict", status_code=400)
|
||
r18_strict_source = "fallback"
|
||
r18_strict_i = 1
|
||
r18_strict = int(r18_strict_i)
|
||
|
||
fail_cooldown_source = "runtime"
|
||
fail_cooldown_ms = random_defaults.get("fail_cooldown_ms")
|
||
try:
|
||
fail_cooldown_ms_i = int(fail_cooldown_ms) if fail_cooldown_ms is not None else None
|
||
except Exception:
|
||
fail_cooldown_ms_i = None
|
||
|
||
if fail_cooldown_ms_i is None:
|
||
fail_cooldown_source = "fallback"
|
||
cooldown_s_raw = (os.environ.get("RANDOM_FAIL_COOLDOWN_SECONDS") or "600").strip()
|
||
try:
|
||
cooldown_s = int(cooldown_s_raw)
|
||
except Exception:
|
||
cooldown_s = 600
|
||
cooldown_s = max(0, min(int(cooldown_s), 24 * 60 * 60))
|
||
fail_cooldown_ms_i = int(cooldown_s) * 1000
|
||
fail_cooldown_ms_i = max(0, min(int(fail_cooldown_ms_i), 24 * 60 * 60 * 1000))
|
||
|
||
request_now = datetime.now(timezone.utc)
|
||
fail_cooldown_before = (
|
||
iso_utc_ms(request_now - timedelta(milliseconds=int(fail_cooldown_ms_i)))
|
||
if int(fail_cooldown_ms_i) > 0
|
||
else None
|
||
)
|
||
|
||
def _parse_iso_dt(value: str | None) -> datetime | None:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
if raw.endswith("Z"):
|
||
raw = raw[:-1] + "+00:00"
|
||
dt = datetime.fromisoformat(raw)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt.astimezone(timezone.utc)
|
||
except Exception:
|
||
return None
|
||
|
||
def _age_days(dt: datetime | None) -> float | None:
|
||
if dt is None:
|
||
return None
|
||
try:
|
||
secs = float((request_now - dt).total_seconds())
|
||
except Exception:
|
||
return None
|
||
if not math.isfinite(secs):
|
||
return None
|
||
return max(0.0, secs / 86400.0)
|
||
|
||
pick_kwargs: dict[str, Any] = {
|
||
"r18": r18,
|
||
"r18_strict": bool(r18_strict),
|
||
"ai_type": ai_type_i,
|
||
"illust_type": illust_type_i,
|
||
"orientation": orientation_map[layout_norm],
|
||
"min_width": int(min_width_i),
|
||
"min_height": int(min_height_i),
|
||
"min_pixels": int(min_pixels_i),
|
||
"min_bookmarks": int(min_bookmarks_i),
|
||
"min_views": int(min_views_i),
|
||
"min_comments": int(min_comments_i),
|
||
"included_tags": included,
|
||
"excluded_tags": excluded,
|
||
"user_id": user_id,
|
||
"illust_id": illust_id,
|
||
"created_from": created_from_norm,
|
||
"created_to": created_to_norm,
|
||
"fail_cooldown_before": fail_cooldown_before,
|
||
}
|
||
|
||
rng = random.Random(seed_norm) if seed_norm else random
|
||
time_boost_enabled = not bool(seed_norm)
|
||
anti_repeat_enabled = bool(time_boost_enabled) and user_id is None and illust_id is None
|
||
recent_image_ids: set[int] = set()
|
||
recent_author_ids: set[int] = set()
|
||
if anti_repeat_enabled:
|
||
recent_image_ids, recent_author_ids = _get_recent_sets(time.monotonic())
|
||
|
||
strategy_raw = (strategy or "").strip().lower()
|
||
strategy_source = "query"
|
||
if not strategy_raw:
|
||
strategy_source = "runtime"
|
||
strategy_raw = str(random_defaults.get("strategy") or "").strip().lower()
|
||
if not strategy_raw:
|
||
strategy_source = "fallback"
|
||
strategy_raw = "quality"
|
||
if strategy_raw not in {"quality", "random"}:
|
||
if strategy_source == "query":
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported strategy", status_code=400)
|
||
strategy_source = "fallback"
|
||
strategy_raw = "quality"
|
||
|
||
strategy_norm = strategy_raw
|
||
|
||
quality_samples_i: int
|
||
quality_samples_source = "query"
|
||
if quality_samples is not None:
|
||
try:
|
||
quality_samples_i = int(quality_samples)
|
||
except Exception as exc:
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported quality_samples", status_code=400) from exc
|
||
else:
|
||
raw = random_defaults.get("quality_samples")
|
||
if raw is None:
|
||
quality_samples_source = "fallback"
|
||
quality_samples_i = 12
|
||
else:
|
||
quality_samples_source = "runtime"
|
||
try:
|
||
quality_samples_i = int(raw)
|
||
except Exception:
|
||
quality_samples_i = 12
|
||
if quality_samples_i < 1 or quality_samples_i > 1000:
|
||
if quality_samples_source == "query":
|
||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported quality_samples", status_code=400)
|
||
quality_samples_source = "fallback"
|
||
quality_samples_i = 12
|
||
|
||
quality_samples_base = int(quality_samples_i)
|
||
quality_samples_multiplier = 1
|
||
if quality_samples is None and strategy_norm == "quality" and bool(time_boost_enabled):
|
||
strictness = 0
|
||
strictness += 3 * int(len(included))
|
||
strictness += 2 * int(len(excluded))
|
||
if int(min_bookmarks_i) > 0:
|
||
strictness += 2
|
||
if int(min_views_i) > 0:
|
||
strictness += 1
|
||
if int(min_comments_i) > 0:
|
||
strictness += 1
|
||
if int(min_pixels_i) > 0:
|
||
strictness += 1
|
||
if int(min_width_i) > 0 or int(min_height_i) > 0:
|
||
strictness += 1
|
||
if ai_type_i is not None:
|
||
strictness += 1
|
||
if illust_type_i is not None:
|
||
strictness += 1
|
||
if orientation_map[layout_norm] is not None:
|
||
strictness += 1
|
||
if created_from_norm is not None or created_to_norm is not None:
|
||
strictness += 1
|
||
if int(r18) == 1:
|
||
strictness += 1
|
||
if bool(anti_repeat_enabled):
|
||
strictness += 1
|
||
|
||
if strictness >= 9:
|
||
quality_samples_multiplier = 4
|
||
elif strictness >= 6:
|
||
quality_samples_multiplier = 3
|
||
elif strictness >= 3:
|
||
quality_samples_multiplier = 2
|
||
else:
|
||
quality_samples_multiplier = 1
|
||
|
||
quality_samples_i = min(1000, int(max(1, int(quality_samples_base) * int(quality_samples_multiplier))))
|
||
|
||
quality_samples_scaled = bool(quality_samples_i != quality_samples_base)
|
||
|
||
recommendation_raw = random_defaults.get("recommendation")
|
||
recommendation_source = "fallback"
|
||
recommendation_obj: dict[str, Any] = {}
|
||
if isinstance(recommendation_raw, dict):
|
||
recommendation_source = "runtime"
|
||
recommendation_obj = dict(recommendation_raw)
|
||
|
||
pick_mode_raw = str(recommendation_obj.get("pick_mode") or _DEFAULT_RECOMMENDATION["pick_mode"]).strip().lower()
|
||
if pick_mode_raw not in {"best", "weighted"}:
|
||
pick_mode_raw = str(_DEFAULT_RECOMMENDATION["pick_mode"])
|
||
|
||
temperature_raw = _as_float(recommendation_obj.get("temperature"), default=float(_DEFAULT_RECOMMENDATION["temperature"]))
|
||
temperature = float(max(0.05, min(float(temperature_raw), 100.0)))
|
||
|
||
score_weights_raw = recommendation_obj.get("score_weights")
|
||
score_weights_obj = score_weights_raw if isinstance(score_weights_raw, dict) else {}
|
||
score_weights: dict[str, float] = {}
|
||
for key, default_value in _DEFAULT_SCORE_WEIGHTS.items():
|
||
v = _as_float(score_weights_obj.get(key), default=float(default_value))
|
||
score_weights[key] = float(max(-100.0, min(float(v), 100.0)))
|
||
|
||
multipliers_default = _DEFAULT_RECOMMENDATION["multipliers"]
|
||
multipliers_raw = recommendation_obj.get("multipliers")
|
||
multipliers_obj = multipliers_raw if isinstance(multipliers_raw, dict) else {}
|
||
multipliers: dict[str, float] = {}
|
||
for key, default_value in multipliers_default.items():
|
||
v = _as_float(multipliers_obj.get(key), default=float(default_value))
|
||
multipliers[key] = float(max(0.0, min(float(v), 100.0)))
|
||
|
||
debug_base = {
|
||
"attempts": int(attempts_i),
|
||
"attempts_source": attempts_source,
|
||
"r18_strict": int(r18_strict_i),
|
||
"r18_strict_source": r18_strict_source,
|
||
"fail_cooldown_ms": int(fail_cooldown_ms_i),
|
||
"fail_cooldown_source": fail_cooldown_source,
|
||
"strategy": strategy_norm,
|
||
"strategy_source": strategy_source,
|
||
"quality_samples": int(quality_samples_i),
|
||
"quality_samples_base": int(quality_samples_base),
|
||
"quality_samples_multiplier": int(quality_samples_multiplier),
|
||
"quality_samples_scaled": bool(quality_samples_scaled),
|
||
"quality_samples_source": quality_samples_source,
|
||
"anti_repeat_enabled": bool(anti_repeat_enabled),
|
||
"time_boost_enabled": bool(time_boost_enabled),
|
||
"recommendation_source": recommendation_source,
|
||
}
|
||
|
||
async def _pick_with_strategy(
|
||
*,
|
||
session: Any,
|
||
exclude_image_ids: list[int] | None = None,
|
||
) -> tuple[Any, dict[str, Any]] | tuple[None, dict[str, Any]]:
|
||
if strategy_norm == "random":
|
||
base_exclude = list(exclude_image_ids or [])
|
||
exclude_set: set[int] = set(int(x) for x in base_exclude)
|
||
if bool(anti_repeat_enabled) and recent_image_ids:
|
||
exclude_set.update(int(x) for x in recent_image_ids)
|
||
|
||
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=list(exclude_set), **pick_kwargs)
|
||
if image is None and bool(anti_repeat_enabled) and recent_image_ids:
|
||
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=base_exclude, **pick_kwargs)
|
||
if image is None:
|
||
return None, {**debug_base, "attempts_used": 1, "picked_by": "random_key"}
|
||
return image, {**debug_base, "attempts_used": 1, "picked_by": "random_key"}
|
||
|
||
def _multiplier_for_image(image: Any) -> float:
|
||
m = 1.0
|
||
|
||
ai = getattr(image, "ai_type", None)
|
||
if ai == 1:
|
||
m *= float(multipliers.get("ai", 1.0))
|
||
elif ai == 0:
|
||
m *= float(multipliers.get("non_ai", 1.0))
|
||
else:
|
||
m *= float(multipliers.get("unknown_ai", 1.0))
|
||
|
||
it = getattr(image, "illust_type", None)
|
||
if it == 0:
|
||
m *= float(multipliers.get("illust", 1.0))
|
||
elif it == 1:
|
||
m *= float(multipliers.get("manga", 1.0))
|
||
elif it == 2:
|
||
m *= float(multipliers.get("ugoira", 1.0))
|
||
else:
|
||
m *= float(multipliers.get("unknown_illust_type", 1.0))
|
||
|
||
if not math.isfinite(float(m)) or float(m) <= 0.0:
|
||
return 0.0
|
||
return float(m)
|
||
|
||
base_exclude_set: set[int] = set(int(x) for x in exclude_image_ids or [])
|
||
exclude_set: set[int] = set(base_exclude_set)
|
||
if bool(anti_repeat_enabled) and recent_image_ids:
|
||
exclude_set.update(int(x) for x in recent_image_ids)
|
||
# candidates: (image, score_total, multiplier, logit, score_base, freshness_contrib, velocity_contrib)
|
||
candidates: list[tuple[Any, float, float, float, float, float, float]] = []
|
||
|
||
# 批量抽样:一次性取 N 个候选(必要时 wrap-around 再取一次),避免 N 次 DB 循环查询。
|
||
# 若用户把某些类别倍率设为 0(例如 manga=0),直接在 SQL 抽样阶段剔除,减少无效候选。
|
||
ai_allowed: set[int | None] = set()
|
||
if float(multipliers.get("ai", 1.0)) > 0.0:
|
||
ai_allowed.add(1)
|
||
if float(multipliers.get("non_ai", 1.0)) > 0.0:
|
||
ai_allowed.add(0)
|
||
if float(multipliers.get("unknown_ai", 1.0)) > 0.0:
|
||
ai_allowed.add(None)
|
||
|
||
illust_allowed: set[int | None] = set()
|
||
if float(multipliers.get("illust", 1.0)) > 0.0:
|
||
illust_allowed.add(0)
|
||
if float(multipliers.get("manga", 1.0)) > 0.0:
|
||
illust_allowed.add(1)
|
||
if float(multipliers.get("ugoira", 1.0)) > 0.0:
|
||
illust_allowed.add(2)
|
||
if float(multipliers.get("unknown_illust_type", 1.0)) > 0.0:
|
||
illust_allowed.add(None)
|
||
|
||
if not ai_allowed or not illust_allowed:
|
||
return None, {
|
||
**debug_base,
|
||
"attempts_used": 1,
|
||
"picked_by": "quality_weighted" if pick_mode_raw == "weighted" else "quality_best",
|
||
"candidates_drawn": 0,
|
||
"candidates_accepted": 0,
|
||
"quality_pick_mode": pick_mode_raw,
|
||
"quality_temperature": float(temperature),
|
||
}
|
||
|
||
images = await pick_random_images(
|
||
session,
|
||
r=rng.random(),
|
||
limit=int(quality_samples_i),
|
||
exclude_image_ids=list(exclude_set),
|
||
ai_type_allowed=ai_allowed,
|
||
illust_type_allowed=illust_allowed,
|
||
**pick_kwargs,
|
||
)
|
||
if not images and bool(anti_repeat_enabled) and recent_image_ids:
|
||
images = await pick_random_images(
|
||
session,
|
||
r=rng.random(),
|
||
limit=int(quality_samples_i),
|
||
exclude_image_ids=list(base_exclude_set),
|
||
ai_type_allowed=ai_allowed,
|
||
illust_type_allowed=illust_allowed,
|
||
**pick_kwargs,
|
||
)
|
||
|
||
drawn = int(len(images))
|
||
accepted = 0
|
||
for image in images:
|
||
score_base = _quality_score(image, weights=score_weights)
|
||
|
||
# 时间因素(适度):默认使用 created_at_pixiv;若缺失则 freshness 退化到 added_at,
|
||
# 以便新导入/新补全的图片也能获得“新鲜感”提升。
|
||
freshness_w = float(score_weights.get("freshness", 0.0)) if bool(time_boost_enabled) else 0.0
|
||
velocity_w = float(score_weights.get("bookmark_velocity", 0.0)) if bool(time_boost_enabled) else 0.0
|
||
freshness_contrib = 0.0
|
||
velocity_contrib = 0.0
|
||
|
||
if freshness_w != 0.0:
|
||
# 半衰期固定为 21 天:足够提升新图,但不会让老图完全失去竞争力。
|
||
half_life_days = 21.0
|
||
dt_created = _parse_iso_dt(getattr(image, "created_at_pixiv", None))
|
||
if dt_created is None:
|
||
dt_created = _parse_iso_dt(getattr(image, "added_at", None))
|
||
age_days = _age_days(dt_created)
|
||
if age_days is not None:
|
||
try:
|
||
freshness_contrib = float(freshness_w) * math.exp(-float(age_days) / float(half_life_days))
|
||
except Exception:
|
||
freshness_contrib = 0.0
|
||
|
||
if velocity_w != 0.0:
|
||
dt_created = _parse_iso_dt(getattr(image, "created_at_pixiv", None))
|
||
age_days = _age_days(dt_created)
|
||
bookmark_count = _as_nonneg_int(getattr(image, "bookmark_count", None))
|
||
if age_days is not None and bookmark_count > 0:
|
||
try:
|
||
# +2 天平滑,避免“刚发布/刚补全”的极端值。
|
||
denom = float(age_days) + 2.0
|
||
velocity_term = math.log1p(float(bookmark_count) / max(1.0, denom))
|
||
velocity_contrib = float(velocity_w) * float(velocity_term)
|
||
except Exception:
|
||
velocity_contrib = 0.0
|
||
|
||
score = float(score_base) + float(freshness_contrib) + float(velocity_contrib)
|
||
multiplier = _multiplier_for_image(image)
|
||
if multiplier <= 0.0:
|
||
continue
|
||
|
||
logit = float(score) / float(temperature) + math.log(float(multiplier))
|
||
if bool(anti_repeat_enabled):
|
||
try:
|
||
if int(getattr(image, "id", 0) or 0) in recent_image_ids:
|
||
logit -= 8.0
|
||
except Exception:
|
||
pass
|
||
try:
|
||
uid = getattr(image, "user_id", None)
|
||
if uid is not None and int(uid) in recent_author_ids:
|
||
logit -= 2.5
|
||
except Exception:
|
||
pass
|
||
candidates.append(
|
||
(
|
||
image,
|
||
float(score),
|
||
float(multiplier),
|
||
float(logit),
|
||
float(score_base),
|
||
float(freshness_contrib),
|
||
float(velocity_contrib),
|
||
)
|
||
)
|
||
accepted += 1
|
||
|
||
if not candidates:
|
||
return None, {
|
||
**debug_base,
|
||
"attempts_used": 1,
|
||
"picked_by": "quality_weighted" if pick_mode_raw == "weighted" else "quality_best",
|
||
"candidates_drawn": int(drawn),
|
||
"candidates_accepted": int(accepted),
|
||
"quality_pick_mode": pick_mode_raw,
|
||
"quality_temperature": float(temperature),
|
||
}
|
||
|
||
if pick_mode_raw == "best":
|
||
picked = max(candidates, key=lambda x: x[3])
|
||
picked_by = "quality_best"
|
||
else:
|
||
max_logit = max(x[3] for x in candidates)
|
||
weights = [math.exp(float(x[3]) - float(max_logit)) for x in candidates]
|
||
total = float(sum(weights))
|
||
if not math.isfinite(total) or total <= 0.0:
|
||
picked = max(candidates, key=lambda x: x[3])
|
||
picked_by = "quality_best"
|
||
else:
|
||
r = float(rng.random()) * total
|
||
idx = 0
|
||
for i, w in enumerate(weights):
|
||
r -= float(w)
|
||
if r <= 0:
|
||
idx = i
|
||
break
|
||
picked = candidates[int(max(0, min(idx, len(candidates) - 1)))]
|
||
picked_by = "quality_weighted"
|
||
|
||
best_image, best_score, best_multiplier, _best_logit, best_base, best_fresh, best_vel = picked
|
||
|
||
return (
|
||
best_image,
|
||
{
|
||
**debug_base,
|
||
"attempts_used": 1,
|
||
"picked_by": picked_by,
|
||
"candidates_drawn": int(drawn),
|
||
"candidates_accepted": int(accepted),
|
||
"quality_pick_mode": pick_mode_raw,
|
||
"quality_temperature": float(temperature),
|
||
"quality_score": float(best_score),
|
||
"quality_score_base": float(best_base),
|
||
"quality_score_freshness": float(best_fresh),
|
||
"quality_score_bookmark_velocity": float(best_vel),
|
||
"quality_multiplier": float(best_multiplier),
|
||
},
|
||
)
|
||
|
||
def _needs_opportunistic_hydrate(image: Any) -> bool:
|
||
return (
|
||
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, "illust_type", None) is None
|
||
or getattr(image, "user_id", None) is None
|
||
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 format in {"json", "simple_json"} or (format == "image" and redirect == 1):
|
||
tags: list[str] = []
|
||
debug: dict[str, Any] = {}
|
||
async with Session() as session:
|
||
image, debug = await _pick_with_strategy(session=session)
|
||
if image is None:
|
||
raise _no_match_error()
|
||
if format == "json":
|
||
tags = await get_tag_names_for_image(session, image_id=image.id)
|
||
|
||
if bool(anti_repeat_enabled):
|
||
try:
|
||
_record_recent(
|
||
now=time.monotonic(),
|
||
image_id=int(image.id),
|
||
user_id=int(image.user_id) if getattr(image, "user_id", None) is not None else None,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if _needs_opportunistic_hydrate(image):
|
||
background_tasks.add_task(
|
||
_best_effort,
|
||
enqueue_opportunistic_hydrate_metadata,
|
||
engine,
|
||
illust_id=int(image.illust_id),
|
||
reason="random",
|
||
timeout_s=2.5,
|
||
)
|
||
|
||
if format == "image" and redirect == 1:
|
||
qp: list[tuple[str, str]] = []
|
||
if proxy_override is not None:
|
||
qp.append(("proxy", str(proxy_override)))
|
||
else:
|
||
if int(pixiv_cat) == 1:
|
||
qp.append(("pixiv_cat", "1"))
|
||
if pximg_mirror_host_override is not None:
|
||
qp.append(("pximg_mirror_host", str(pximg_mirror_host_override)))
|
||
qs = ("?" + "&".join([f"{k}={v}" for k, v in qp])) if qp else ""
|
||
resp = RedirectResponse(
|
||
url=f"/i/{image.id}.{image.ext}{qs}",
|
||
status_code=302,
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
try:
|
||
if getattr(resp, "background", None) is None:
|
||
resp.background = background_tasks
|
||
except Exception:
|
||
pass
|
||
return resp
|
||
|
||
origin_url = None if runtime.hide_origin_url_in_public_json else image.original_url
|
||
|
||
imgproxy_url = None
|
||
try:
|
||
cfg = load_imgproxy_config_from_settings(request.app.state.settings)
|
||
except Exception:
|
||
cfg = None
|
||
if cfg is not None:
|
||
try:
|
||
if runtime.hide_origin_url_in_public_json:
|
||
base = str(getattr(request, "base_url", "") or "").rstrip("/")
|
||
source_url = f"{base}/i/{image.id}.{image.ext}"
|
||
else:
|
||
source_url = str(image.original_url)
|
||
imgproxy_url = build_signed_processing_url(cfg, source_url=source_url, extension=str(image.ext))
|
||
except Exception:
|
||
imgproxy_url = None
|
||
|
||
if format == "simple_json":
|
||
return {
|
||
"ok": True,
|
||
"code": "OK",
|
||
"request_id": getattr(getattr(request, "state", None), "request_id", None) or "req_unknown",
|
||
"data": {
|
||
"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,
|
||
"illust_type": getattr(image, "illust_type", None),
|
||
"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,
|
||
},
|
||
},
|
||
"urls": {
|
||
"proxy": f"/i/{image.id}.{image.ext}",
|
||
"origin": origin_url,
|
||
"imgproxy": imgproxy_url,
|
||
},
|
||
"debug": {
|
||
**debug,
|
||
},
|
||
},
|
||
}
|
||
|
||
return {
|
||
"ok": True,
|
||
"code": "OK",
|
||
"request_id": getattr(getattr(request, "state", None), "request_id", None) or "req_unknown",
|
||
"data": {
|
||
"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,
|
||
"illust_type": getattr(image, "illust_type", None),
|
||
"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,
|
||
"urls": {
|
||
"proxy": f"/i/{image.id}.{image.ext}",
|
||
"origin": origin_url,
|
||
"imgproxy": imgproxy_url,
|
||
"legacy_single": f"/{image.illust_id}.{image.ext}",
|
||
"legacy_multi": f"/{image.illust_id}-{image.page_index + 1}.{image.ext}",
|
||
},
|
||
"debug": {
|
||
**debug,
|
||
},
|
||
},
|
||
}
|
||
|
||
tried_ids: set[int] = set()
|
||
last_error: ApiError | None = None
|
||
attempts_i = int(attempts)
|
||
runtime_stream = runtime
|
||
|
||
for _ in range(attempts_i):
|
||
async with Session() as session:
|
||
image, _debug = await _pick_with_strategy(session=session, exclude_image_ids=list(tried_ids))
|
||
if image is None:
|
||
break
|
||
image_id = int(image.id)
|
||
origin_url = str(image.original_url)
|
||
source_url = rewrite_pximg_to_mirror(origin_url, mirror_host=mirror_host) if use_pixiv_cat else origin_url
|
||
illust_id_for_hydrate = int(image.illust_id)
|
||
needs_hydrate = _needs_opportunistic_hydrate(image)
|
||
should_mark_ok = image.last_ok_at is None or image.last_error_code is not None
|
||
user_id_for_recent = int(image.user_id) if getattr(image, "user_id", None) is not None else None
|
||
|
||
transport = getattr(request.app.state, "httpx_transport", None)
|
||
proxy_uri = None
|
||
if not use_pixiv_cat:
|
||
picked = await select_proxy_uri_for_url(
|
||
engine,
|
||
request.app.state.settings,
|
||
runtime_stream,
|
||
url=origin_url,
|
||
)
|
||
if picked is not None:
|
||
proxy_uri = picked.uri
|
||
try:
|
||
resp = await stream_url(
|
||
source_url,
|
||
transport=transport,
|
||
proxy=proxy_uri,
|
||
cache_control="no-store",
|
||
range_header=request.headers.get("Range"),
|
||
)
|
||
if bool(anti_repeat_enabled):
|
||
try:
|
||
_record_recent(now=time.monotonic(), image_id=int(image_id), user_id=user_id_for_recent)
|
||
except Exception:
|
||
pass
|
||
if should_mark_ok:
|
||
background_tasks.add_task(_best_effort, mark_image_ok, engine, image_id=image_id, now=iso_utc_ms(), timeout_s=1.5)
|
||
if needs_hydrate:
|
||
background_tasks.add_task(
|
||
_best_effort,
|
||
enqueue_opportunistic_hydrate_metadata,
|
||
engine,
|
||
illust_id=illust_id_for_hydrate,
|
||
reason="random",
|
||
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=image_id,
|
||
now=iso_utc_ms(),
|
||
error_code=exc.code.value,
|
||
error_message=exc.message,
|
||
timeout_s=1.5,
|
||
)
|
||
tried_ids.add(image_id)
|
||
last_error = exc
|
||
continue
|
||
raise
|
||
|
||
if last_error is None:
|
||
raise _no_match_error()
|
||
|
||
raise ApiError(
|
||
code=ErrorCode.UPSTREAM_STREAM_ERROR,
|
||
message="多次尝试后上游请求仍失败。",
|
||
status_code=502,
|
||
details={
|
||
"attempts_used": len(tried_ids),
|
||
"last_upstream_code": last_error.code.value,
|
||
},
|
||
)
|