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:
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() {
/>
+
+ 图片加速
+
+
+
+
+
+
+
+
随机接口设置
|