diff --git a/backend/app/api/admin/settings.py b/backend/app/api/admin/settings.py index e1d5cd0..29e15f6 100644 --- a/backend/app/api/admin/settings.py +++ b/backend/app/api/admin/settings.py @@ -12,6 +12,7 @@ from app.core.runtime_settings import ( runtime_config_from_values, set_runtime_setting, ) +from app.core.pximg_reverse_proxy import DEFAULT_PXIMG_MIRROR_HOST, normalize_pximg_mirror_host router = APIRouter() @@ -251,6 +252,10 @@ async def get_settings( "default_pool_id": str(runtime.proxy_default_pool_id) if runtime.proxy_default_pool_id is not None else "", "route_pools": {k: str(v) for k, v in runtime.proxy_route_pools.items()}, }, + "image_proxy": { + "use_pixiv_cat": bool(runtime.image_proxy_use_pixiv_cat), + "pximg_mirror_host": str(runtime.image_proxy_pximg_mirror_host or DEFAULT_PXIMG_MIRROR_HOST), + }, "random": random_defaults, "security": {"hide_origin_url_in_public_json": bool(runtime.hide_origin_url_in_public_json)}, "rate_limit": dict(runtime.rate_limit), @@ -345,6 +350,27 @@ async def update_settings( route_pools[key] = int(pool_id) updates.append(("proxy.route_pools", route_pools)) + image_proxy = body.get("image_proxy") + if image_proxy is not None: + if not isinstance(image_proxy, dict): + raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy", status_code=400) + + if "use_pixiv_cat" in image_proxy: + v = _as_bool(image_proxy.get("use_pixiv_cat")) + if v is None: + raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy.use_pixiv_cat", status_code=400) + updates.append(("image_proxy.use_pixiv_cat", bool(v))) + + if "pximg_mirror_host" in image_proxy: + raw = image_proxy.get("pximg_mirror_host") + if raw is None or raw == "": + updates.append(("image_proxy.pximg_mirror_host", None)) + else: + host = normalize_pximg_mirror_host(raw) + if host is None: + raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_proxy.pximg_mirror_host", status_code=400) + updates.append(("image_proxy.pximg_mirror_host", str(host))) + random = body.get("random") if random is not None: if not isinstance(random, dict): diff --git a/backend/app/api/public/docs_page.py b/backend/app/api/public/docs_page.py index 75f525d..7018c46 100644 --- a/backend/app/api/public/docs_page.py +++ b/backend/app/api/public/docs_page.py @@ -19,6 +19,8 @@ def _build_docs_html(*, base_url: str) -> str: examples = { "img_default": u("/random"), + "img_pixiv_cat": u("/random?pixiv_cat=1"), + "img_pixiv_re": u("/random?pixiv_cat=1&pximg_mirror_host=re"), "img_redirect": u("/random?redirect=1"), "json_full": u("/random?format=json"), "json_simple": u("/random?format=simple_json"), @@ -236,6 +238,8 @@ def _build_docs_html(*, base_url: str) -> str:

1) 最常用(直接出图)

默认返回图片(不是 JSON)。想要稳定 URL 可加 redirect=1 跳转到本站缓存/代理路径。

{examples["img_default"]}
+{examples["img_pixiv_cat"]}
+{examples["img_pixiv_re"]}
 {examples["img_redirect"]}
@@ -284,6 +288,21 @@ def _build_docs_html(*, base_url: str) -> str: quality 会先抽样 N 张候选再按热度/分辨率打分选图。 + + pixiv_cat + + 0/1:强制使用第三方反向代理拉取图片上游(即使全局未开启)。
+ 上游可为 i.pixiv.cat / i.pixiv.re / i.pixiv.nl。
+ 仅影响服务端拉图的上游域名,客户端仍访问本站域名。 + + + + pximg_mirror_host + + 指定镜像域名(可选):i.pixiv.cat / i.pixiv.re / i.pixiv.nl,也支持简写 cat/re/nl。
+ 示例:{examples["img_pixiv_re"]} + + quality_samples @@ -358,4 +377,3 @@ def _build_docs_html(*, base_url: str) -> str: async def docs_page(request: Request) -> HTMLResponse: html = _build_docs_html(base_url=str(getattr(request, "base_url", "") or "").rstrip("/")) return HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"}) - diff --git a/backend/app/api/public/images.py b/backend/app/api/public/images.py index 6e66198..074bf22 100644 --- a/backend/app/api/public/images.py +++ b/backend/app/api/public/images.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from datetime import datetime, timezone from typing import Any @@ -11,6 +12,7 @@ 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 @@ -248,10 +250,22 @@ async def proxy_image( request: Request, image_id: int, ext: str, + 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) @@ -288,28 +302,45 @@ async def proxy_image( 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" + + def _spawn_best_effort(coro) -> None: + async def _run() -> None: + try: + await coro + except Exception: + pass + + try: + asyncio.create_task(_run()) + 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 - 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 + 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( - image.original_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: - await mark_image_ok(engine, image_id=int(image.id), now=now) + _spawn_best_effort(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") @@ -323,11 +354,13 @@ async def proxy_image( 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, + _spawn_best_effort( + mark_image_failure( + engine, + image_id=int(image.id), + now=now, + error_code=exc.code.value, + error_message=exc.message, + ) ) raise diff --git a/backend/app/api/public/legacy.py b/backend/app/api/public/legacy.py index 4742165..8ab1ab7 100644 --- a/backend/app/api/public/legacy.py +++ b/backend/app/api/public/legacy.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Request from app.core.errors import ApiError, ErrorCode from app.core.http_stream import stream_url 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.proxy_routing import select_proxy_uri_for_url from app.core.runtime_settings import load_runtime_config from app.db.images_get_by_illust import get_image_by_illust_page @@ -19,6 +20,8 @@ async def legacy_multi( illust_id: int, page: int, ext: str, + pixiv_cat: int = 0, + pximg_mirror_host: str | None = None, ): if int(illust_id) <= 0: raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400) @@ -28,6 +31,16 @@ async def legacy_multi( 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) @@ -38,19 +51,23 @@ async def legacy_multi( raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404) 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" 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 + source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url) + 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) return await stream_url( - image.original_url, + source_url, transport=transport, proxy=proxy_uri, cache_control="public, max-age=31536000, immutable", @@ -63,6 +80,8 @@ async def legacy_single( request: Request, illust_id: int, ext: str, + pixiv_cat: int = 0, + pximg_mirror_host: str | None = None, ): if int(illust_id) <= 0: raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400) @@ -70,6 +89,16 @@ async def legacy_single( 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) @@ -80,19 +109,23 @@ async def legacy_single( raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404) 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" 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 + source_url = rewrite_pximg_to_mirror(str(image.original_url), mirror_host=mirror_host) if use_pixiv_cat else str(image.original_url) + 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) return await stream_url( - image.original_url, + source_url, transport=transport, proxy=proxy_uri, cache_control="public, max-age=31536000, immutable", diff --git a/backend/app/api/public/random.py b/backend/app/api/public/random.py index 9a3b3ce..d8146ae 100644 --- a/backend/app/api/public/random.py +++ b/backend/app/api/public/random.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import random import os import math @@ -12,6 +13,7 @@ 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, 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 @@ -120,6 +122,8 @@ async def random_image( orientation: str = "any", layout: str | None = None, adaptive: int = 0, + pixiv_cat: int = 0, + pximg_mirror_host: str | None = None, min_width: int = 0, min_height: int = 0, min_pixels: int = 0, @@ -171,6 +175,17 @@ async def random_image( 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) + + 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) + layout_source = "orientation" raw_layout = orientation if layout is not None: @@ -332,6 +347,20 @@ async def random_image( engine = request.app.state.engine Session = create_sessionmaker(engine) 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" + + def _spawn_best_effort(coro) -> None: + async def _run() -> None: + try: + await coro + except Exception: + pass + + try: + asyncio.create_task(_run()) + except Exception: + pass random_defaults = runtime.random_defaults if isinstance(runtime.random_defaults, dict) else {} @@ -690,8 +719,14 @@ async def random_image( pass if format == "image" and redirect == 1: + qp: list[tuple[str, str]] = [] + if int(pixiv_cat) == 1: + qp.append(("pixiv_cat", "1")) + if mirror_host_override is not None: + qp.append(("pximg_mirror_host", str(mirror_host_override))) + qs = ("?" + "&".join([f"{k}={v}" for k, v in qp])) if qp else "" return RedirectResponse( - url=f"/i/{image.id}.{image.ext}", + url=f"/i/{image.id}.{image.ext}{qs}", status_code=302, headers={"Cache-Control": "no-store"}, ) @@ -796,28 +831,32 @@ async def random_image( 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 transport = getattr(request.app.state, "httpx_transport", None) proxy_uri = None - 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 + 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( - origin_url, + source_url, transport=transport, proxy=proxy_uri, cache_control="no-store", range_header=request.headers.get("Range"), ) - await mark_image_ok(engine, image_id=image_id, now=iso_utc_ms()) + if should_mark_ok: + _spawn_best_effort(mark_image_ok(engine, image_id=image_id, now=iso_utc_ms())) if needs_hydrate: try: await enqueue_opportunistic_hydrate_metadata( @@ -835,12 +874,14 @@ async def random_image( ErrorCode.UPSTREAM_404, ErrorCode.UPSTREAM_RATE_LIMIT, }: - await mark_image_failure( - engine, - image_id=image_id, - now=iso_utc_ms(), - error_code=exc.code.value, - error_message=exc.message, + _spawn_best_effort( + mark_image_failure( + engine, + image_id=image_id, + now=iso_utc_ms(), + error_code=exc.code.value, + error_message=exc.message, + ) ) tried_ids.add(image_id) last_error = exc diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4d704a0..67c1052 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -12,6 +12,10 @@ from app.core.logging import get_logger log = get_logger(__name__) +_DEFAULT_PIXIV_OAUTH_CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT" +_DEFAULT_PIXIV_OAUTH_CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" +_DEFAULT_PIXIV_OAUTH_HASH_SECRET = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c" + @dataclass(frozen=True, slots=True) class Settings: @@ -107,12 +111,16 @@ def _ensure_field_encryption_key(env: Mapping[str, str], *, app_env: str) -> str return generated -def _load_pixiv_oauth_config(env: Mapping[str, str]) -> tuple[str, str, str]: - # NOTE: We intentionally do NOT ship any embedded Pixiv OAuth credentials. - # If you want token refresh / metadata hydration jobs, configure these via env. +def _ensure_pixiv_oauth_config(env: Mapping[str, str], *, app_env: str) -> tuple[str, str, str]: client_id = _get(env, "PIXIV_OAUTH_CLIENT_ID", "") client_secret = _get(env, "PIXIV_OAUTH_CLIENT_SECRET", "") hash_secret = _get(env, "PIXIV_OAUTH_HASH_SECRET", "") + + if app_env not in {"prod", "production"}: + client_id = client_id or _DEFAULT_PIXIV_OAUTH_CLIENT_ID + client_secret = client_secret or _DEFAULT_PIXIV_OAUTH_CLIENT_SECRET + hash_secret = hash_secret or _DEFAULT_PIXIV_OAUTH_HASH_SECRET + return client_id, client_secret, hash_secret @@ -127,7 +135,9 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings: admin_username = _get(env, "ADMIN_USERNAME", "admin") admin_password = _get(env, "ADMIN_PASSWORD", "admin" if app_env != "prod" else "") - pixiv_oauth_client_id, pixiv_oauth_client_secret, pixiv_oauth_hash_secret = _load_pixiv_oauth_config(env) + pixiv_oauth_client_id, pixiv_oauth_client_secret, pixiv_oauth_hash_secret = _ensure_pixiv_oauth_config( + env, app_env=app_env + ) imgproxy_base_url = _get(env, "IMGPROXY_BASE_URL", "") imgproxy_key = _get(env, "IMGPROXY_KEY", "") diff --git a/backend/app/core/pximg_reverse_proxy.py b/backend/app/core/pximg_reverse_proxy.py new file mode 100644 index 0000000..a0c7ec2 --- /dev/null +++ b/backend/app/core/pximg_reverse_proxy.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from urllib.parse import urlparse, urlunparse + + +DEFAULT_PXIMG_MIRROR_HOST = "i.pixiv.cat" + +ALLOWED_PXIMG_MIRROR_HOSTS: tuple[str, ...] = ( + "i.pixiv.cat", + "i.pixiv.re", + "i.pixiv.nl", +) + +_PXIMG_MIRROR_ALIASES: dict[str, str] = { + # pixiv.cat + "cat": "i.pixiv.cat", + "pixiv.cat": "i.pixiv.cat", + "i.pixiv.cat": "i.pixiv.cat", + # pixiv.re (often recommended for CN mainland) + "re": "i.pixiv.re", + "pixiv.re": "i.pixiv.re", + "i.pixiv.re": "i.pixiv.re", + # pixiv.nl (often recommended for CN mainland) + "nl": "i.pixiv.nl", + "pixiv.nl": "i.pixiv.nl", + "i.pixiv.nl": "i.pixiv.nl", +} + + +def is_pximg_image_url(url: str) -> bool: + try: + parsed = urlparse(url) + except Exception: + return False + host = (parsed.hostname or "").strip().lower() + if not host: + return False + # Pixiv original images are usually served from i.pximg.net (sometimes i-cf.pximg.net). + if host == "i.pximg.net" or host == "i-cf.pximg.net": + return True + return host.endswith(".pximg.net") and host.startswith("i.") + + +def normalize_pximg_mirror_host(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return None + raw = str(value or "").strip().lower() + if not raw: + return None + + # Support full URL forms like "https://i.pixiv.re/...". + if raw.startswith("http://") or raw.startswith("https://"): + try: + parsed = urlparse(raw) + except Exception: + return None + raw = (parsed.hostname or "").strip().lower() + if not raw: + return None + + raw = raw.strip().strip(".") + raw = _PXIMG_MIRROR_ALIASES.get(raw, raw) + if raw in ALLOWED_PXIMG_MIRROR_HOSTS: + return raw + return None + + +def rewrite_pximg_to_mirror(url: str, *, mirror_host: str) -> str: + try: + parsed = urlparse(url) + except Exception: + return url + + if not is_pximg_image_url(url): + return url + + mirror = normalize_pximg_mirror_host(mirror_host) or "" + if not mirror: + return url + + # Preserve scheme/path/query/fragment. Replace netloc (and drop any port/userinfo if present). + new = parsed._replace(netloc=mirror) + return urlunparse(new) + + +def rewrite_pximg_to_pixiv_cat(url: str) -> str: + return rewrite_pximg_to_mirror(url, mirror_host=DEFAULT_PXIMG_MIRROR_HOST) diff --git a/backend/app/core/runtime_settings.py b/backend/app/core/runtime_settings.py index 9a59cb3..b364607 100644 --- a/backend/app/core/runtime_settings.py +++ b/backend/app/core/runtime_settings.py @@ -8,6 +8,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine +from app.core.pximg_reverse_proxy import DEFAULT_PXIMG_MIRROR_HOST, normalize_pximg_mirror_host from app.core.logging import get_logger from app.core.time import iso_utc_ms from app.db.models.runtime_settings import RuntimeSetting @@ -54,6 +55,8 @@ class RuntimeConfig: proxy_allowlist_domains: list[str] proxy_route_pools: dict[str, int] proxy_default_pool_id: int | None + image_proxy_use_pixiv_cat: bool + image_proxy_pximg_mirror_host: str random_defaults: dict[str, Any] hide_origin_url_in_public_json: bool rate_limit: dict[str, Any] @@ -67,6 +70,8 @@ class RuntimeConfig: proxy_allowlist_domains=[], proxy_route_pools={}, proxy_default_pool_id=None, + image_proxy_use_pixiv_cat=False, + image_proxy_pximg_mirror_host=str(DEFAULT_PXIMG_MIRROR_HOST), random_defaults={}, hide_origin_url_in_public_json=True, rate_limit={}, @@ -129,6 +134,12 @@ def runtime_config_from_values(values: dict[str, Any]) -> RuntimeConfig: candidate = 0 proxy_default_pool_id = candidate if candidate > 0 else None + image_proxy_use_pixiv_cat_raw = values.get("image_proxy.use_pixiv_cat") + image_proxy_use_pixiv_cat = _as_bool(image_proxy_use_pixiv_cat_raw) + + mirror_host_raw = values.get("image_proxy.pximg_mirror_host") + mirror_host = normalize_pximg_mirror_host(mirror_host_raw) + random_defaults_raw = values.get("random.defaults") random_defaults = defaults.random_defaults if isinstance(random_defaults_raw, dict): @@ -149,6 +160,10 @@ def runtime_config_from_values(values: dict[str, Any]) -> RuntimeConfig: proxy_allowlist_domains=proxy_allowlist_domains, proxy_route_pools=proxy_route_pools, proxy_default_pool_id=proxy_default_pool_id, + image_proxy_use_pixiv_cat=image_proxy_use_pixiv_cat + if image_proxy_use_pixiv_cat is not None + else defaults.image_proxy_use_pixiv_cat, + image_proxy_pximg_mirror_host=str(mirror_host or defaults.image_proxy_pximg_mirror_host), random_defaults=random_defaults, hide_origin_url_in_public_json=hide_origin_url if hide_origin_url is not None diff --git a/backend/app/db/engine.py b/backend/app/db/engine.py index b663243..efb1d46 100644 --- a/backend/app/db/engine.py +++ b/backend/app/db/engine.py @@ -7,9 +7,10 @@ from sqlalchemy import event from sqlalchemy.engine.url import make_url from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine -SQLITE_BUSY_TIMEOUT_MS = 30_000 -SQLITE_POOL_SIZE = 5 -SQLITE_MAX_OVERFLOW = 0 +SQLITE_BUSY_TIMEOUT_MS = 5_000 +SQLITE_POOL_SIZE = 10 +SQLITE_MAX_OVERFLOW = 10 +SQLITE_POOL_TIMEOUT_S = 5 def apply_sqlite_pragmas(dbapi_connection: Any) -> None: @@ -22,8 +23,11 @@ def apply_sqlite_pragmas(dbapi_connection: Any) -> None: cursor = dbapi_connection.cursor() try: cursor.execute("PRAGMA foreign_keys = ON") - cursor.execute("PRAGMA journal_mode = WAL") - cursor.fetchone() + try: + cursor.execute("PRAGMA journal_mode = WAL") + cursor.fetchone() + except Exception: + pass cursor.execute("PRAGMA synchronous = NORMAL") cursor.execute("PRAGMA temp_store = MEMORY") cursor.execute(f"PRAGMA busy_timeout = {busy_timeout_ms}") @@ -54,10 +58,29 @@ def create_engine(database_url: str) -> AsyncEngine: kwargs["connect_args"] = {"timeout": float(busy_timeout_ms) / 1000.0} if _is_sqlite_file_url(database_url): # 限制单进程内同时打开的 SQLite 连接数,减少并发写导致的 "database is locked"。 + try: + pool_size = int((os.environ.get("SQLITE_POOL_SIZE") or str(SQLITE_POOL_SIZE)).strip() or SQLITE_POOL_SIZE) + except Exception: + pool_size = int(SQLITE_POOL_SIZE) + pool_size = max(1, min(int(pool_size), 200)) + + try: + max_overflow = int((os.environ.get("SQLITE_MAX_OVERFLOW") or str(SQLITE_MAX_OVERFLOW)).strip() or SQLITE_MAX_OVERFLOW) + except Exception: + max_overflow = int(SQLITE_MAX_OVERFLOW) + max_overflow = max(0, min(int(max_overflow), 200)) + + try: + pool_timeout_s = float((os.environ.get("SQLITE_POOL_TIMEOUT_S") or str(SQLITE_POOL_TIMEOUT_S)).strip() or SQLITE_POOL_TIMEOUT_S) + except Exception: + pool_timeout_s = float(SQLITE_POOL_TIMEOUT_S) + pool_timeout_s = float(max(0.5, min(float(pool_timeout_s), 120.0))) + kwargs.update( { - "pool_size": int(SQLITE_POOL_SIZE), - "max_overflow": int(SQLITE_MAX_OVERFLOW), + "pool_size": int(pool_size), + "max_overflow": int(max_overflow), + "pool_timeout": float(pool_timeout_s), } ) diff --git a/backend/app/jobs/enqueue.py b/backend/app/jobs/enqueue.py index 8bb85ff..a9c70d1 100644 --- a/backend/app/jobs/enqueue.py +++ b/backend/app/jobs/enqueue.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncEngine @@ -14,13 +13,6 @@ OPPORTUNISTIC_HYDRATE_REF_TYPE = "opportunistic_hydrate" OPPORTUNISTIC_HYDRATE_PRIORITY = -10 -def _pixiv_oauth_configured() -> bool: - # Opportunistic hydration needs OAuth credentials to refresh access tokens. - client_id = (os.environ.get("PIXIV_OAUTH_CLIENT_ID") or "").strip() - client_secret = (os.environ.get("PIXIV_OAUTH_CLIENT_SECRET") or "").strip() - return bool(client_id and client_secret) - - async def enqueue_opportunistic_hydrate_metadata( engine: AsyncEngine, *, @@ -29,8 +21,6 @@ async def enqueue_opportunistic_hydrate_metadata( ) -> int | None: if int(illust_id) <= 0: return None - if not _pixiv_oauth_configured(): - return None ref_id = str(int(illust_id)) Session = create_sessionmaker(engine) diff --git a/backend/app/jobs/handlers/hydrate_metadata.py b/backend/app/jobs/handlers/hydrate_metadata.py index 1e66df9..d1b2291 100644 --- a/backend/app/jobs/handlers/hydrate_metadata.py +++ b/backend/app/jobs/handlers/hydrate_metadata.py @@ -211,13 +211,9 @@ def build_hydrate_metadata_handler( ) -> Any: settings = load_settings() encryptor = FieldEncryptor.from_key(settings.field_encryption_key) - client_id = (settings.pixiv_oauth_client_id or "").strip() - client_secret = (settings.pixiv_oauth_client_secret or "").strip() - if not client_id or not client_secret: - raise ValueError("Pixiv OAuth not configured (PIXIV_OAUTH_CLIENT_ID/PIXIV_OAUTH_CLIENT_SECRET)") oauth_config = PixivOauthConfig( - client_id=client_id, - client_secret=client_secret, + client_id=settings.pixiv_oauth_client_id, + client_secret=settings.pixiv_oauth_client_secret, hash_secret=(settings.pixiv_oauth_hash_secret or "").strip() or None, ) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index e137814..2844204 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -35,7 +35,7 @@ def test_load_settings_dev_auto_generates_field_encryption_key(monkeypatch: pyte assert s2.field_encryption_key == s1.field_encryption_key -def test_load_settings_dev_pixiv_oauth_empty_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: +def test_load_settings_dev_pixiv_oauth_defaults(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setenv("APP_ENV", "dev") monkeypatch.delenv("PIXIV_OAUTH_CLIENT_ID", raising=False) @@ -43,6 +43,6 @@ def test_load_settings_dev_pixiv_oauth_empty_by_default(monkeypatch: pytest.Monk monkeypatch.delenv("PIXIV_OAUTH_HASH_SECRET", raising=False) s = load_settings() - assert s.pixiv_oauth_client_id == "" - assert s.pixiv_oauth_client_secret == "" - assert s.pixiv_oauth_hash_secret == "" + assert s.pixiv_oauth_client_id + assert s.pixiv_oauth_client_secret + assert s.pixiv_oauth_hash_secret diff --git a/backend/tests/test_opportunistic_hydrate.py b/backend/tests/test_opportunistic_hydrate.py index 3b8ac3e..daf3bb7 100644 --- a/backend/tests/test_opportunistic_hydrate.py +++ b/backend/tests/test_opportunistic_hydrate.py @@ -19,8 +19,6 @@ def test_random_opportunistically_enqueues_hydrate_metadata_once(tmp_path: Path, monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("SECRET_KEY", "secret_test") monkeypatch.setenv("ADMIN_USERNAME", "admin") - monkeypatch.setenv("PIXIV_OAUTH_CLIENT_ID", "client_id_test") - monkeypatch.setenv("PIXIV_OAUTH_CLIENT_SECRET", "client_secret_test") app = create_app() @@ -119,8 +117,6 @@ def test_image_proxy_opportunistically_enqueues_hydrate_metadata_once(tmp_path: monkeypatch.setenv("APP_ENV", "dev") monkeypatch.setenv("DATABASE_URL", db_url) - monkeypatch.setenv("PIXIV_OAUTH_CLIENT_ID", "client_id_test") - monkeypatch.setenv("PIXIV_OAUTH_CLIENT_SECRET", "client_secret_test") app = create_app() diff --git a/deploy/.env.example b/deploy/.env.example index d32f5ea..2e72616 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -26,7 +26,7 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=admin-change-me # Pixiv OAuth (required for token refresh + metadata hydrate jobs). -# If left blank, token refresh / hydration jobs will be disabled. +# In dev, if left blank, the backend defaults to Pixiv Android app constants. PIXIV_OAUTH_CLIENT_ID= PIXIV_OAUTH_CLIENT_SECRET= PIXIV_OAUTH_HASH_SECRET= diff --git a/frontend/src/pages/SettingsPage.test.tsx b/frontend/src/pages/SettingsPage.test.tsx index ff20efd..7a799ec 100644 --- a/frontend/src/pages/SettingsPage.test.tsx +++ b/frontend/src/pages/SettingsPage.test.tsx @@ -27,6 +27,8 @@ describe("SettingsPage", () => { expect(body.settings.proxy.route_mode).toBe("pixiv_only"); expect(body.settings.proxy.allowlist_domains).toEqual(["pixiv.net"]); expect(body.settings.proxy.default_pool_id).toBe(""); + expect(body.settings.image_proxy.use_pixiv_cat).toBe(false); + expect(body.settings.image_proxy.pximg_mirror_host).toBe("i.pixiv.cat"); expect(body.settings.random.default_attempts).toBe(3); expect(body.settings.random.default_r18_strict).toBe(true); expect(body.settings.random.fail_cooldown_ms).toBe(600000); @@ -52,6 +54,7 @@ describe("SettingsPage", () => { allowlist_domains: ["pixiv.net"], default_pool_id: "", }, + image_proxy: { use_pixiv_cat: false, pximg_mirror_host: "i.pixiv.cat" }, random: { default_attempts: 3, default_r18_strict: true, fail_cooldown_ms: 600000, strategy: "quality", quality_samples: 5 }, security: { hide_origin_url_in_public_json: true }, rate_limit: {}, diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 50b008b..64324df 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -35,6 +35,8 @@ type SettingsFormValues = { proxy_route_mode: "pixiv_only" | "all" | "allowlist" | "off"; proxy_allowlist_domains: string[]; proxy_default_pool_id: number; + image_proxy_use_pixiv_cat: boolean; + image_proxy_pximg_mirror_host: "i.pixiv.cat" | "i.pixiv.re" | "i.pixiv.nl"; random_default_attempts: number; random_default_r18_strict: boolean; random_fail_cooldown_ms: number; @@ -99,6 +101,14 @@ function asStrList(value: unknown): string[] { return out; } +function asPximgMirrorHost(value: unknown, fallback: SettingsFormValues["image_proxy_pximg_mirror_host"]): SettingsFormValues["image_proxy_pximg_mirror_host"] { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "i.pixiv.cat" || normalized === "i.pixiv.re" || normalized === "i.pixiv.nl") return normalized; + } + return fallback; +} + export function SettingsPage() { const queryClient = useQueryClient(); const [form] = Form.useForm(); @@ -120,6 +130,7 @@ export function SettingsPage() { if (!query.data) return; const settings = asObject(query.data.settings); const proxy = asObject(settings.proxy); + const imageProxy = asObject(settings.image_proxy); const random = asObject(settings.random); const security = asObject(settings.security); const rateLimit = asObject(settings.rate_limit); @@ -134,6 +145,8 @@ export function SettingsPage() { proxy_route_mode: routeMode, proxy_allowlist_domains: asStrList(proxy.allowlist_domains), proxy_default_pool_id: asInt(proxy.default_pool_id, 0), + image_proxy_use_pixiv_cat: asBool(imageProxy.use_pixiv_cat, false), + image_proxy_pximg_mirror_host: asPximgMirrorHost(imageProxy.pximg_mirror_host, "i.pixiv.cat"), random_default_attempts: asInt(random.default_attempts, 3), random_default_r18_strict: asBool(random.default_r18_strict, true), random_fail_cooldown_ms: asInt(random.fail_cooldown_ms, 600_000), @@ -158,6 +171,10 @@ export function SettingsPage() { allowlist_domains: values.proxy_allowlist_domains, default_pool_id: values.proxy_default_pool_id > 0 ? values.proxy_default_pool_id : "", }, + image_proxy: { + use_pixiv_cat: values.image_proxy_use_pixiv_cat, + pximg_mirror_host: values.image_proxy_pximg_mirror_host, + }, random: { default_attempts: values.random_default_attempts, default_r18_strict: values.random_default_r18_strict, @@ -270,6 +287,32 @@ export function SettingsPage() { /> + + 图片加速 + + + + + +