feat: in-app notification center and LLM provider plugin framework

Two 3.3 platform features sharing core infra (notification events, DB
migrations/wiring, config model, API registration), committed together
because they cannot be split at file granularity without broken
intermediate builds.

Notification center:
- New `inboxmessage` table (migration 16) + repo with coalescing
  (unread rows with the same dedup_key increment a count; `once` kinds
  like update_available notify per-version) and 500-row pruning.
- SystemEvent contract (kind/severity/dedup_key/payload) with new
  DownloaderUnavailable / UpdateAvailable / UpdateApplied events;
  NotificationManager.send_event persists to the inbox first, then
  broadcasts to external providers only when notification.enable.
- SSE `notification` frames driven by an in-process revision counter;
  REST endpoints for list/unread-count/read/read-all/delete/clear.
- Wire-ups: qBittorrent auth now reports last_auth_error
  (credentials|banned|unreachable) and stops retrying rejected
  credentials to avoid a WebUI IP ban; the startup wait loop aborts
  early on credential errors and emits a notification; a 24h
  update-check tick surfaces new versions; apply/rollback emit results.
- WebUI bell + unread badge + dropdown inbox (severity, coalesce count,
  relative time, deep links), SSE-driven with a 30s poll fallback.

LLM provider plugin framework + subscription auth:
- LLMParser refactored to a thin facade over a provider adapter
  contract + registry; the three built-ins (openai/anthropic/gemini)
  move to adapters with zero behaviour change (existing tests unchanged).
- base_url presets for zai/minimax/deepseek/kimi (mainland + overseas)
  reusing the OpenAI-compatible adapter, with a json_object structured-
  output fallback for providers without json_schema.
- Downloadable signed plugins: catalog + installer clone the update
  system's ed25519 verify / zip-slip-guarded extract pipeline (unsigned
  refused, catalog signed against downgrade); versioned-module-name
  loader hot-loads without restart.
- Subscription auth: `llmcredential` table (migration 17) stores OAuth
  tokens server-side only (never sent to the frontend); device-flow
  auth runs a server-side polling task keyed by an opaque handle so the
  device_code stays off the wire; AuthExpiredError trips the breaker and
  notifies. Two grey-area device-flow plugins (github-copilot,
  codex-chatgpt) under plugins/, flagged at-your-own-risk.
- WebUI: grouped provider select (built-in/preset/subscription/
  downloadable), install with risk confirmation, device-flow connect
  dialog, connected-account chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-06 09:43:45 +02:00
parent 2cdf146f91
commit e6d39e5624
76 changed files with 6507 additions and 342 deletions

View File

@@ -5,6 +5,7 @@ from .bangumi import router as bangumi_router
from .config import router as config_router
from .downloader import router as downloader_router
from .events import router as events_router
from .llm import router as llm_router
from .log import router as log_router
from .notification import router as notification_router
from .passkey import router as passkey_router
@@ -31,3 +32,4 @@ v1.include_router(search_router)
v1.include_router(setup_router)
v1.include_router(notification_router)
v1.include_router(update_router)
v1.include_router(llm_router)

View File

@@ -1,6 +1,5 @@
import asyncio
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@@ -96,7 +95,7 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
class LLMModelsRequest(BaseModel):
provider: Literal["openai", "anthropic", "gemini"] = "openai"
provider: str = "openai"
api_key: str = ""
base_url: str = ""
@@ -117,7 +116,8 @@ async def list_llm_models(req: LLMModelsRequest):
"""
api_key = req.api_key
if not api_key or api_key == _MASK:
api_key = settings.llm.api_key
# 按提供商取已保存密钥(providers[id] 覆盖 → 扁平字段兜底)
api_key, _, _ = settings.llm.effective(req.provider)
if not api_key:
raise HTTPException(status_code=400, detail="LLM API key is required")
try:

View File

@@ -16,7 +16,9 @@ from module.api.deps import get_context
from module.api.log import _read_log_tail
from module.conf import LOG_PATH, VERSION
from module.core import AppContext
from module.database import Database
from module.downloader import DownloadClient
from module.notification.inbox import inbox_revision
from module.security.api import get_current_user
from module.update import get_update_progress
@@ -30,6 +32,7 @@ _TICK_SECONDS = 1
_STATUS_EVERY = 3
_DOWNLOADER_EVERY = 5
_LOG_EVERY = 10
_NOTIFICATION_EVERY = 3
# 下载器查询的超时上限(秒)。下载器不可达时 qB/aria2 的认证重试可能阻塞
# 20-30s,而 SSE 是单连接串行推送——不设上限会把 status/log 事件一起卡住。
@@ -67,6 +70,15 @@ async def _downloader_payload() -> list[dict] | None:
return None
async def _notification_payload() -> dict:
"""通知中心未读数 + 最新消息 id(前端据 latest_id 区分新消息与已读操作)。"""
async with Database() as db:
return {
"unread_count": await db.inbox.unread_count(),
"latest_id": await db.inbox.latest_id(),
}
async def _log_payload() -> str | None:
"""读取日志尾部;日志文件不存在时返回 None(跳过本次推送)。"""
if not LOG_PATH.exists():
@@ -79,6 +91,7 @@ async def _event_generator(request: Request, ctx: AppContext):
"""按各自节拍推送 status/downloader/log 事件的核心循环。"""
tick = 0
last_update: str | None = None
last_inbox_rev: int | None = None
while True:
if await request.is_disconnected():
break
@@ -106,6 +119,20 @@ async def _event_generator(request: Request, ctx: AppContext):
if log_text is not None:
yield {"event": "log", "data": log_text}
# 通知中心:只在修订号变化时查库推送(写入/已读/删除都会 bump),
# 平时每 3 tick 只做一次整数比较,不产生数据库查询。
if tick % _NOTIFICATION_EVERY == 0:
rev = inbox_revision()
if rev != last_inbox_rev:
try:
payload = await _notification_payload()
except Exception:
logger.debug("SSE: notification payload unavailable", exc_info=True)
else:
last_inbox_rev = rev
payload["revision"] = rev
yield {"event": "notification", "data": json.dumps(payload)}
await asyncio.sleep(_TICK_SECONDS)
tick += _TICK_SECONDS

View File

@@ -0,0 +1,340 @@
"""LLM 提供商与订阅认证 API(/config/llm/*)。
- providers:内置 + 预设 + 已安装插件的合并视图(含连接状态)。
- auth begin/complete/status/disconnect:订阅类提供商的授权流程;
PKCE verifier / device_code 等敏感中间态留服务端 ``_PENDING``,
device-code 轮询在服务端 asyncio task 完成,token 永不下发前端。
"""
import asyncio
import json
import logging
import secrets
import time
from dataclasses import dataclass, field
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from module.api.deps import get_context
from module.conf import VERSION
from module.core import AppContext
from module.database import Database
from module.parser.analyser.llm import _build_http_client
from module.parser.analyser.providers.base import (
AdapterContext,
AuthChallenge,
AuthExpiredError,
LLMProviderAdapter,
)
from module.parser.analyser.providers.credentials import (
CredentialStore,
bump_auth_generation,
)
from module.parser.analyser.providers.registry import registry
from module.security.api import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config/llm", tags=["config"])
# 不透明 handle -> 待完成授权的服务端明细。``blob`` 是适配器 begin_auth
# 返回的 state(device_code / PKCE verifier 等敏感态),只留服务端,绝不
# 下发前端——前端只拿到 handle 用于轮询 status / 回传 redirect code。
_PENDING: dict[str, "PendingAuth"] = {}
_PENDING_TTL = 900.0
# 保留 device-flow 轮询任务的强引用,防止被 GC。
_POLL_TASKS: set = set()
@dataclass
class PendingAuth:
provider_id: str
created: float
expires: float
blob: str = "" # 适配器 challenge.state(敏感,服务端专用)
extra: dict = field(default_factory=dict)
def _prune_pending(now: float) -> None:
for state in [s for s, p in _PENDING.items() if p.expires < now]:
_PENDING.pop(state, None)
def _make_adapter(provider_id: str) -> LLMProviderAdapter:
adapter_cls = registry.resolve(provider_id)
ctx = AdapterContext(
model=adapter_cls.info.default_model,
build_http_client=_build_http_client,
credentials=CredentialStore(provider_id),
)
return adapter_cls(ctx)
async def _provider_status(provider_id: str) -> dict:
"""返回可下发前端的连接状态(绝不含 token)。"""
tokens = await CredentialStore(provider_id).load()
if tokens is None:
return {"connected": False, "account_label": "", "expires_at": None}
return {
"connected": True,
"account_label": tokens.account_label,
"expires_at": tokens.expires_at,
}
class ProviderView(BaseModel):
id: str
display_name: str
auth_kind: str
builtin: bool
needs_base_url: bool
preset_base_url: str
default_model: str
plugin_version: str | None = None
connected: bool = False
account_label: str = ""
expires_at: float | None = None
class ProvidersResponse(BaseModel):
providers: list[ProviderView]
@router.get(
"/providers",
response_model=ProvidersResponse,
dependencies=[Depends(get_current_user)],
)
async def list_providers():
infos = registry.list_infos()
# 订阅类提供商的连接状态在一个 DB 会话里批量取,避免每个提供商各开一次连接。
statuses: dict[str, dict] = {}
subscription_ids = [i.id for i in infos if i.auth_kind != "api_key"]
if subscription_ids:
async with Database() as db:
for pid in subscription_ids:
tokens = await db.llm_credential.get(pid)
statuses[pid] = (
{"connected": False, "account_label": "", "expires_at": None}
if tokens is None
else {
"connected": True,
"account_label": tokens.account_label,
"expires_at": tokens.expires_at,
}
)
views: list[ProviderView] = []
for info in infos:
status = statuses.get(info.id, {})
views.append(
ProviderView(
id=info.id,
display_name=info.display_name,
auth_kind=info.auth_kind,
builtin=info.builtin,
needs_base_url=info.needs_base_url,
preset_base_url=info.preset_base_url,
default_model=info.default_model,
plugin_version=info.plugin_version,
connected=status.get("connected", False),
account_label=status.get("account_label", ""),
expires_at=status.get("expires_at"),
)
)
return ProvidersResponse(providers=views)
@router.post(
"/providers/{provider_id}/install",
dependencies=[Depends(get_current_user)],
)
async def install_provider(provider_id: str, ctx: AppContext = Depends(get_context)):
from module.llm_plugins import PluginInstaller
from module.notification import LLMPluginInstallFailedEvent
installer = PluginInstaller(app_version=VERSION)
result = await installer.install(provider_id)
if not result.success:
try:
await ctx.notifier.send_event(
LLMPluginInstallFailedEvent(
plugin_id=provider_id,
version=result.version,
message=result.message,
)
)
except Exception:
logger.debug("install-failure notification best-effort failed")
raise HTTPException(status_code=400, detail=result.message)
return {"success": True, "version": result.version}
@router.delete(
"/providers/{provider_id}",
dependencies=[Depends(get_current_user)],
)
async def uninstall_provider(provider_id: str):
from module.llm_plugins import PluginInstaller
installer = PluginInstaller(app_version=VERSION)
result = await installer.uninstall(provider_id)
if not result.success:
raise HTTPException(status_code=400, detail=result.message)
return {"success": True}
class AuthCompleteRequest(BaseModel):
state: str
code: str = ""
def _poll_interval(blob: str) -> int:
try:
return max(1, int(json.loads(blob).get("interval", 5)))
except (json.JSONDecodeError, TypeError, ValueError):
return 5
async def _device_poll_loop(provider_id: str, handle: str, expires_in: int) -> None:
"""device flow 服务端轮询:到期前按 interval 反复调 complete_auth,
成功即写库并 bump generation。前端只需轮询 auth/status。"""
deadline = time.monotonic() + min(float(expires_in), _PENDING_TTL)
try:
adapter = _make_adapter(provider_id)
except ValueError:
_PENDING.pop(handle, None)
return
try:
while time.monotonic() < deadline:
pending = _PENDING.get(handle)
if pending is None:
return # 被取消 / 已完成 / 断开
await asyncio.sleep(_poll_interval(pending.blob))
pending = _PENDING.get(handle)
if pending is None:
return
try:
tokens = await adapter.complete_auth(pending.blob)
except AuthExpiredError:
continue # authorization_pending / slow_down:继续轮询
except Exception as e: # noqa: BLE001
logger.warning("device auth poll failed for %s: %s", provider_id, e)
continue
await CredentialStore(provider_id).save(tokens)
bump_auth_generation(provider_id)
_PENDING.pop(handle, None)
logger.info("LLM provider %s connected via device flow", provider_id)
return
finally:
await adapter.aclose()
_PENDING.pop(handle, None)
def _store_pending(provider_id: str, challenge: AuthChallenge) -> str:
"""把敏感 blob 存服务端,返回给前端的不透明 handle。"""
now = time.monotonic()
_prune_pending(now)
handle = secrets.token_urlsafe(24)
_PENDING[handle] = PendingAuth(
provider_id=provider_id,
created=now,
expires=now + min(float(challenge.expires_in), _PENDING_TTL),
blob=challenge.state,
)
return handle
@router.post(
"/providers/{provider_id}/auth/begin",
dependencies=[Depends(get_current_user)],
)
async def auth_begin(provider_id: str):
try:
adapter = _make_adapter(provider_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
challenge = await adapter.begin_auth()
except NotImplementedError:
raise HTTPException(
status_code=400, detail="Provider does not support interactive auth"
)
finally:
await adapter.aclose()
handle = _store_pending(provider_id, challenge)
if challenge.method == "device_code":
# 服务端起轮询任务;前端只轮询 auth/status。
task = asyncio.create_task(
_device_poll_loop(provider_id, handle, challenge.expires_in)
)
_POLL_TASKS.add(task)
task.add_done_callback(_POLL_TASKS.discard)
# 返回前端:state 用不透明 handle,绝不含 device_code / verifier。
return {
"method": challenge.method,
"authorize_url": challenge.authorize_url,
"user_code": challenge.user_code,
"verification_uri": challenge.verification_uri,
"expires_in": challenge.expires_in,
"state": handle,
}
@router.post(
"/providers/{provider_id}/auth/complete",
dependencies=[Depends(get_current_user)],
)
async def auth_complete(provider_id: str, req: AuthCompleteRequest):
"""redirect_paste 流程:前端回传授权 code;device flow 由服务端轮询完成。"""
pending = _PENDING.get(req.state)
if pending is None or pending.provider_id != provider_id:
raise HTTPException(status_code=400, detail="Unknown or expired auth state")
try:
adapter = _make_adapter(provider_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
try:
tokens = await adapter.complete_auth(pending.blob, req.code)
except Exception as e:
logger.warning("LLM auth complete failed for %s: %s", provider_id, e)
raise HTTPException(status_code=400, detail="Authorization failed")
finally:
await adapter.aclose()
await CredentialStore(provider_id).save(tokens)
bump_auth_generation(provider_id)
_PENDING.pop(req.state, None)
return {"connected": True, "account_label": tokens.account_label}
@router.get(
"/providers/{provider_id}/auth/status",
dependencies=[Depends(get_current_user)],
)
async def auth_status(provider_id: str):
return await _provider_status(provider_id)
@router.delete(
"/providers/{provider_id}/auth",
dependencies=[Depends(get_current_user)],
)
async def auth_disconnect(provider_id: str):
store = CredentialStore(provider_id)
tokens = await store.load()
if tokens is not None:
try:
adapter = _make_adapter(provider_id)
except ValueError:
adapter = None
if adapter is not None:
try:
await adapter.revoke(tokens)
except Exception as e:
logger.debug("revoke best-effort failed for %s: %s", provider_id, e)
finally:
await adapter.aclose()
await store.clear()
bump_auth_generation(provider_id)
return {"connected": False}

View File

@@ -1,13 +1,21 @@
"""Notification API endpoints."""
"""Notification API endpoints.
包含两部分:外部推送 provider 的测试端点,以及站内通知中心
(/notification/messages*)的收件箱增删查改。
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from module.database import Database, get_db
from module.models import ResponseModel
from module.models.config import NotificationProvider as ProviderConfig
from module.notification import NotificationManager
from module.notification.inbox import bump_inbox_revision
from module.security.api import get_current_user
logger = logging.getLogger(__name__)
@@ -122,3 +130,145 @@ async def test_provider_config(request: TestProviderConfigRequest):
message_zh=f"测试失败: {e}",
message_en=f"Test failed: {e}",
)
# ---------------------------------------------------------------------------
# 站内通知中心(收件箱)
# ---------------------------------------------------------------------------
class InboxMessageOut(BaseModel):
"""收件箱消息。payload 为按 kind 约定的结构化字段,供前端本地化渲染。"""
id: int
kind: str
severity: str
title: str
body: str
payload: Optional[dict] = None
read: bool
count: int
created_at: str
updated_at: str
class InboxListResponse(BaseModel):
messages: list[InboxMessageOut]
total: int
unread_count: int
def _to_out(message) -> InboxMessageOut:
payload = None
if message.payload:
try:
payload = json.loads(message.payload)
except (json.JSONDecodeError, TypeError):
payload = None
return InboxMessageOut(
id=message.id,
kind=message.kind,
severity=message.severity,
title=message.title,
body=message.body,
payload=payload,
read=message.read,
count=message.count,
created_at=message.created_at,
updated_at=message.updated_at,
)
@router.get(
"/messages",
response_model=InboxListResponse,
dependencies=[Depends(get_current_user)],
)
async def list_messages(
unread_only: bool = False,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Database = Depends(get_db),
):
messages = await db.inbox.list(unread_only=unread_only, limit=limit, offset=offset)
total = await db.inbox.count_all(unread_only=unread_only)
unread = await db.inbox.unread_count()
return InboxListResponse(
messages=[_to_out(m) for m in messages],
total=total,
unread_count=unread,
)
@router.get(
"/messages/unread-count",
dependencies=[Depends(get_current_user)],
)
async def unread_count(db: Database = Depends(get_db)):
return {"unread_count": await db.inbox.unread_count()}
@router.post(
"/messages/read-all",
response_model=ResponseModel,
dependencies=[Depends(get_current_user)],
)
async def mark_all_read(db: Database = Depends(get_db)):
changed = await db.inbox.mark_all_read()
bump_inbox_revision()
return ResponseModel(
status=True,
status_code=200,
msg_en=f"Marked {changed} messages as read.",
msg_zh=f"已将 {changed} 条消息标记为已读。",
)
@router.post(
"/messages/{message_id}/read",
response_model=ResponseModel,
dependencies=[Depends(get_current_user)],
)
async def mark_read(message_id: int, db: Database = Depends(get_db)):
if not await db.inbox.mark_read(message_id):
raise HTTPException(status_code=404, detail="Message not found")
bump_inbox_revision()
return ResponseModel(
status=True,
status_code=200,
msg_en="Message marked as read.",
msg_zh="消息已标记为已读。",
)
@router.delete(
"/messages/{message_id}",
response_model=ResponseModel,
dependencies=[Depends(get_current_user)],
)
async def delete_message(message_id: int, db: Database = Depends(get_db)):
if not await db.inbox.delete(message_id):
raise HTTPException(status_code=404, detail="Message not found")
bump_inbox_revision()
return ResponseModel(
status=True,
status_code=200,
msg_en="Message deleted.",
msg_zh="消息已删除。",
)
@router.delete(
"/messages",
response_model=ResponseModel,
dependencies=[Depends(get_current_user)],
)
async def clear_messages(db: Database = Depends(get_db)):
removed = await db.inbox.clear()
bump_inbox_revision()
return ResponseModel(
status=True,
status_code=200,
msg_en=f"Cleared {removed} messages.",
msg_zh=f"已清空 {removed} 条消息。",
)

View File

@@ -18,9 +18,13 @@ from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from module.conf import settings
from module.core import AppContext
from module.notification import UpdateAppliedEvent
from module.security.api import get_current_user
from module.update import updater
from .deps import get_context
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/update", tags=["update"])
@@ -63,11 +67,28 @@ async def check_update(channel: str | None = None, force: bool = False):
return result.model_dump()
async def _notify_apply_result(ctx: AppContext, result) -> None:
"""把更新结果写入通知中心(在 schedule_restart 之前落库,重启后仍可见)。"""
try:
await ctx.notifier.send_event(
UpdateAppliedEvent(
version=result.version or "",
success=result.success,
message=result.message or "",
)
)
except Exception:
logger.warning("Failed to emit update-result notification", exc_info=True)
@router.post("/apply", dependencies=[Depends(get_current_user)])
async def apply_update(channel: str | None = None):
async def apply_update(
channel: str | None = None, ctx: AppContext = Depends(get_context)
):
"""下载并落地最新更新;成功后安排进程重启以生效。"""
ch = channel or settings.update.channel
result = await updater.apply_update(ch)
await _notify_apply_result(ctx, result)
if result.success and result.restart_required:
schedule_restart()
return JSONResponse(
@@ -77,9 +98,10 @@ async def apply_update(channel: str | None = None):
@router.post("/rollback", dependencies=[Depends(get_current_user)])
async def rollback_update():
async def rollback_update(ctx: AppContext = Depends(get_context)):
"""回滚到上一个覆盖层版本(无备份则回退到镜像版本);成功后安排重启。"""
result = await updater.rollback()
await _notify_apply_result(ctx, result)
if result.success and result.restart_required:
schedule_restart()
return JSONResponse(

View File

@@ -55,26 +55,43 @@ class Checker:
return True
@staticmethod
async def check_downloader() -> bool:
async def check_downloader_detail() -> tuple[bool, str | None]:
"""下载器可达性检查,附失败原因。
返回 ``(ok, reason)``;reason 取值 unreachable | credentials | banned,
成功时为 None。上层(启动等待循环)据此区分「等它启动」和「密码错误,
等也没用」两类故障。
"""
from module.downloader import DownloadClient
# Mock downloader always succeeds
if settings.downloader.type == "mock":
logger.info("Using MockDownloader - skipping connection check")
return True
return True, None
# Delegate to the concrete client's own auth/capabilities instead of
# grepping the downloader's homepage HTML (which only ever matches
# qBittorrent, so aria2 and future backends could never pass).
# DownloadClient() 的构造本身可能抛(下载器类型无法识别),必须一并
# 纳入 try——否则一个配置错误会击穿启动等待循环。
client = None
try:
async with DownloadClient() as dl_client:
return dl_client.authed
client = DownloadClient()
async with client as dl_client:
return dl_client.authed, None
except ConnectionError:
logger.error("Downloader connect failed.")
return False
reason = getattr(client, "last_auth_error", None)
return False, reason or "unreachable"
except Exception as e:
logger.error(f"Downloader connect failed: {e}")
return False
reason = getattr(client, "last_auth_error", None)
return False, reason or "unreachable"
@staticmethod
async def check_downloader() -> bool:
ok, _ = await Checker.check_downloader_detail()
return ok
@staticmethod
def check_img_cache() -> bool:

View File

@@ -15,7 +15,7 @@ from module.database import Database
from module.downloader.download_client import shutdown as downloader_shutdown
from module.models import ResponseModel
from module.network.request_url import reset_shared_client
from module.notification import NotificationManager
from module.notification import DownloaderUnavailableEvent, NotificationManager
from module.parser.analyser.mikan_parser import reset_cache as reset_mikan_cache
from module.parser.analyser.tmdb_parser import reset_cache as reset_tmdb_cache
from module.parser.title_parser import reset_cache as reset_llm_parser
@@ -30,7 +30,13 @@ from module.update import (
run_migrations,
)
from .loops import calendar_tick, offset_scan_tick, rename_tick, rss_tick
from .loops import (
calendar_tick,
offset_scan_tick,
rename_tick,
rss_tick,
update_check_tick,
)
from .scheduler import PeriodicTask, Scheduler
logger = logging.getLogger(__name__)
@@ -41,8 +47,10 @@ DOWNLOADER_STATUS_TTL = 60
# Background loop cadence (seconds).
OFFSET_SCAN_INTERVAL = 6 * 60 * 60
CALENDAR_REFRESH_INTERVAL = 24 * 60 * 60
UPDATE_CHECK_INTERVAL = 24 * 60 * 60
OFFSET_SCAN_INITIAL_DELAY = 60
CALENDAR_INITIAL_DELAY = 120
UPDATE_CHECK_INITIAL_DELAY = 300
# Downloader wait-retry loop on startup.
_DOWNLOADER_MAX_RETRIES = 10
@@ -76,6 +84,7 @@ class AppContext:
self.analyser = analyser
# Downloader-status TTL cache (was ProgramStatus.check_downloader_status).
self._downloader_status = False
self._downloader_reason: str | None = None
self._downloader_last_check: float = 0.0
self._startup_done = False
self._lifecycle_lock = asyncio.Lock()
@@ -126,6 +135,13 @@ class AppContext:
interval=lambda: CALENDAR_REFRESH_INTERVAL,
initial_delay=CALENDAR_INITIAL_DELAY,
),
PeriodicTask(
name="update_check",
run=lambda: update_check_tick(notifier),
interval=lambda: UPDATE_CHECK_INTERVAL,
initial_delay=UPDATE_CHECK_INITIAL_DELAY,
enabled=lambda: settings_obj.update.auto_check,
),
]
)
return cls(settings_obj, notifier, scheduler, analyser)
@@ -154,7 +170,10 @@ class AppContext:
not self._downloader_status
or (now - self._downloader_last_check) >= DOWNLOADER_STATUS_TTL
):
self._downloader_status = await Checker.check_downloader()
(
self._downloader_status,
self._downloader_reason,
) = await Checker.check_downloader_detail()
self._downloader_last_check = now
return self._downloader_status
@@ -217,6 +236,17 @@ class AppContext:
async def _wait_for_downloader(self) -> None:
retry_count = 0
while not await self.check_downloader_status():
reason = self._downloader_reason or "unreachable"
if reason in ("credentials", "banned"):
# 凭据错误/IP 被封时等待没有意义,继续探测反而会累积失败
# 登录、触发(或加剧)qB 的 WebUI IP ban——立即放弃并通知。
logger.error(
"Downloader rejected authentication (%s); giving up. "
"Program will continue but download functions will not work.",
reason,
)
await self._notify_downloader_unavailable(reason)
return
retry_count += 1
logger.warning(
f"Downloader is not running. (attempt {retry_count}/{_DOWNLOADER_MAX_RETRIES})"
@@ -227,10 +257,23 @@ class AppContext:
"Please check downloader settings and network/proxy configuration. "
"Program will continue but download functions will not work."
)
await self._notify_downloader_unavailable("unreachable")
break
logger.info("Waiting for downloader to start...")
await asyncio.sleep(_DOWNLOADER_RETRY_INTERVAL)
async def _notify_downloader_unavailable(self, reason: str) -> None:
try:
await self.notifier.send_event(
DownloaderUnavailableEvent(
host=self.settings.downloader.host, reason=reason
)
)
except Exception:
logger.warning(
"Failed to emit downloader-unavailable notification", exc_info=True
)
async def _run_start_tasks(self) -> None:
"""Body of the background start task: wait for the downloader, then start loops."""
await self._wait_for_downloader()

View File

@@ -11,8 +11,9 @@ from module.conf import settings
from module.database import Database
from module.downloader import DownloadClient
from module.manager import Renamer, TorrentManager, eps_complete
from module.notification import NotificationManager
from module.notification import NotificationManager, UpdateAvailableEvent
from module.rss import RSSAnalyser, RSSEngine
from module.update import updater
from .offset_scanner import OffsetScanner
@@ -46,7 +47,9 @@ async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None
)
# Run RSS Engine
events = await engine.refresh_rss(client)
if settings.notification.enable and events:
if events:
# 站内通知中心要求事件总是送达 notifier(send_event 内部先落库,
# 再按 settings.notification.enable 决定是否外发)。
# One provider's exception never blocks another event (send_event
# itself gathers across providers); gathering across events too
# means a tick with several RSS failures doesn't serialize their
@@ -73,10 +76,32 @@ async def offset_scan_tick(notifier: NotificationManager) -> None:
# 扫描结果由 OffsetScanner.scan_all 自己记录,这里不再重复记一行
scanner = OffsetScanner()
events = await scanner.scan_all()
if settings.notification.enable and events:
if events:
await asyncio.gather(*[notifier.send_event(e) for e in events])
async def update_check_tick(notifier: NotificationManager) -> None:
"""每日检查一次 GitHub Release,发现新版本时写入通知中心。
``UpdateAvailableEvent.once=True``(按最新版本号去重),同一版本终生只
提醒一次;检查失败(网络/限流)静默跳过,避免每天误报。
"""
result = await updater.check_update(settings.update.channel, force=False)
if result.error:
logger.debug("Update check failed: %s", result.error)
return
if not result.has_update:
return
await notifier.send_event(
UpdateAvailableEvent(
current=result.current,
latest=result.latest or "",
channel=result.channel,
notes=result.notes or "",
)
)
async def calendar_tick() -> None:
"""Refresh bangumi calendar metadata."""
async with Database() as db:

View File

@@ -9,6 +9,8 @@ from module.models import Bangumi, User
from .aria2 import Aria2GidDatabase
from .bangumi import BangumiDatabase
from .engine import async_engine, async_session_factory
from .inbox import InboxDatabase
from .llm_credential import LLMCredentialDatabase
from .migrations import ( # noqa: F401 (re-exported for existing importers)
CURRENT_SCHEMA_VERSION,
MIGRATIONS,
@@ -43,6 +45,8 @@ class Database:
self.bangumi = BangumiDatabase(self.session)
self.user = UserDatabase(self.session)
self.aria2 = Aria2GidDatabase(self.session)
self.inbox = InboxDatabase(self.session)
self.llm_credential = LLMCredentialDatabase(self.session)
async def __aenter__(self):
return self

View File

@@ -0,0 +1,155 @@
"""通知中心消息表的仓储。合并/去重语义见 ``upsert``。"""
import logging
from typing import Optional
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import delete, func, select
from module.models.inbox import InboxMessage, utcnow_iso
logger = logging.getLogger(__name__)
DEFAULT_KEEP = 500
class InboxDatabase:
def __init__(self, session: AsyncSession):
self.session = session
async def upsert(
self,
*,
kind: str,
severity: str = "info",
title: str = "",
body: str = "",
payload: Optional[str] = None,
dedup_key: Optional[str] = None,
once: bool = False,
keep: int = DEFAULT_KEEP,
) -> Optional[InboxMessage]:
"""写入一条通知。
- ``once=True``:同 ``dedup_key`` 的行(无论已读与否)存在则跳过,
返回 None(如"新版本可用"每个版本只提醒一次)。
- 否则若存在同 ``dedup_key`` 的**未读**行:合并——count+1、刷新
updated_at、内容以最新为准(持续性故障不刷屏)。
- 其余情况插入新行,并裁剪到最近 ``keep`` 条。
"""
if dedup_key is not None:
statement = select(InboxMessage).where(InboxMessage.dedup_key == dedup_key)
if not once:
statement = statement.where(InboxMessage.read == False) # noqa: E712
result = await self.session.execute(statement)
existing = result.scalars().first()
if existing is not None:
if once:
return None
existing.count += 1
existing.severity = severity
existing.title = title
existing.body = body
existing.payload = payload
existing.updated_at = utcnow_iso()
self.session.add(existing)
await self.session.commit()
return existing
message = InboxMessage(
kind=kind,
severity=severity,
title=title,
body=body,
payload=payload,
dedup_key=dedup_key,
)
self.session.add(message)
await self.session.commit()
await self.prune(keep=keep)
return message
async def list(
self, unread_only: bool = False, limit: int = 50, offset: int = 0
) -> list[InboxMessage]:
statement = select(InboxMessage)
if unread_only:
statement = statement.where(InboxMessage.read == False) # noqa: E712
statement = (
statement.order_by(
InboxMessage.updated_at.desc(), # type: ignore[attr-defined]
InboxMessage.id.desc(), # type: ignore[attr-defined]
)
.limit(limit)
.offset(offset)
)
result = await self.session.execute(statement)
return list(result.scalars().all())
async def count_all(self, unread_only: bool = False) -> int:
statement = select(func.count()).select_from(InboxMessage)
if unread_only:
statement = statement.where(InboxMessage.read == False) # noqa: E712
result = await self.session.execute(statement)
return int(result.scalar_one())
async def unread_count(self) -> int:
return await self.count_all(unread_only=True)
async def latest_id(self) -> int:
result = await self.session.execute(
select(func.max(InboxMessage.id)).select_from(InboxMessage)
)
return int(result.scalar_one() or 0)
async def mark_read(self, message_id: int) -> bool:
message = await self.session.get(InboxMessage, message_id)
if message is None:
return False
message.read = True
self.session.add(message)
await self.session.commit()
return True
async def mark_all_read(self) -> int:
result = await self.session.execute(
update(InboxMessage)
.where(InboxMessage.read == False) # noqa: E712
.values(read=True)
)
await self.session.commit()
return int(result.rowcount or 0)
async def delete(self, message_id: int) -> bool:
message = await self.session.get(InboxMessage, message_id)
if message is None:
return False
await self.session.delete(message)
await self.session.commit()
return True
async def clear(self) -> int:
result = await self.session.execute(delete(InboxMessage))
await self.session.commit()
return int(result.rowcount or 0)
async def prune(self, keep: int = DEFAULT_KEEP) -> int:
"""删除最近 ``keep`` 条以外的旧消息,返回删除数量。"""
stale_ids_stmt = (
select(InboxMessage.id)
.order_by(
InboxMessage.updated_at.desc(), # type: ignore[attr-defined]
InboxMessage.id.desc(), # type: ignore[attr-defined]
)
.offset(keep)
)
result = await self.session.execute(stale_ids_stmt)
stale_ids = list(result.scalars().all())
if not stale_ids:
return 0
await self.session.execute(
delete(InboxMessage).where(InboxMessage.id.in_(stale_ids)) # type: ignore[attr-defined]
)
await self.session.commit()
return len(stale_ids)

View File

@@ -0,0 +1,69 @@
"""订阅提供商凭据仓储。读写以 TokenSet 为交换格式(extra 透明 JSON 编解码)。"""
import json
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import func, select
from module.models.llm_credential import LLMCredential, utcnow_iso
from module.parser.analyser.providers.base import TokenSet
logger = logging.getLogger(__name__)
class LLMCredentialDatabase:
def __init__(self, session: AsyncSession):
self.session = session
async def _row(self, provider_id: str) -> Optional[LLMCredential]:
result = await self.session.execute(
select(LLMCredential).where(LLMCredential.provider_id == provider_id)
)
return result.scalars().first()
async def get(self, provider_id: str) -> Optional[TokenSet]:
row = await self._row(provider_id)
if row is None:
return None
extra: dict = {}
if row.extra:
try:
extra = json.loads(row.extra)
except json.JSONDecodeError:
logger.warning("Corrupt credential extra for %s", provider_id)
return TokenSet(
access_token=row.access_token,
refresh_token=row.refresh_token,
expires_at=row.expires_at,
account_label=row.account_label,
extra=extra,
)
async def upsert(self, provider_id: str, tokens: TokenSet) -> None:
row = await self._row(provider_id)
if row is None:
row = LLMCredential(provider_id=provider_id)
row.access_token = tokens.access_token
row.refresh_token = tokens.refresh_token
row.expires_at = tokens.expires_at
row.account_label = tokens.account_label
row.extra = json.dumps(tokens.extra, ensure_ascii=False)
row.updated_at = utcnow_iso()
self.session.add(row)
await self.session.commit()
async def delete(self, provider_id: str) -> bool:
row = await self._row(provider_id)
if row is None:
return False
await self.session.delete(row)
await self.session.commit()
return True
async def count(self) -> int:
result = await self.session.execute(
select(func.count()).select_from(LLMCredential)
)
return int(result.scalar_one())

View File

@@ -18,6 +18,8 @@ from sqlalchemy import Connection, Engine, inspect, text
from sqlmodel import SQLModel
from module.models import Bangumi, User
from module.models.inbox import InboxMessage
from module.models.llm_credential import LLMCredential
from module.models.passkey import Passkey
from module.models.rss import RSSItem
from module.models.torrent import Torrent
@@ -25,7 +27,15 @@ from module.models.torrent import Torrent
logger = logging.getLogger(__name__)
# 所有需要进行空值填充的表模型
TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]
TABLE_MODELS: list[type[SQLModel]] = [
Bangumi,
RSSItem,
Torrent,
User,
Passkey,
InboxMessage,
LLMCredential,
]
# already_applied 守卫:接收 inspector,返回该迁移是否已生效
AppliedCheck = Callable[[Any], bool]
@@ -330,6 +340,49 @@ MIGRATIONS: tuple[Migration, ...] = (
("ALTER TABLE aria2_gid ADD COLUMN renamed_paths TEXT DEFAULT NULL",),
column_exists("aria2_gid", "renamed_paths"),
),
Migration(
16,
"create inboxmessage table for the in-app notification center",
(
"""CREATE TABLE IF NOT EXISTS inboxmessage (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
payload TEXT,
dedup_key TEXT,
read BOOLEAN NOT NULL DEFAULT 0,
count INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)""",
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_kind ON inboxmessage(kind)",
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_dedup_key "
"ON inboxmessage(dedup_key)",
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_read ON inboxmessage(read)",
),
table_exists("inboxmessage"),
),
Migration(
17,
"create llmcredential table for subscription LLM provider tokens",
(
"""CREATE TABLE IF NOT EXISTS llmcredential (
id INTEGER PRIMARY KEY,
provider_id TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
expires_at REAL,
account_label TEXT NOT NULL DEFAULT '',
extra TEXT,
updated_at TEXT NOT NULL DEFAULT ''
)""",
"CREATE UNIQUE INDEX IF NOT EXISTS ix_llmcredential_provider_id "
"ON llmcredential(provider_id)",
),
table_exists("llmcredential"),
),
)
# 由迁移列表派生,新增迁移时无需手动同步

View File

@@ -33,6 +33,9 @@ class QbDownloader:
self.ssl = ssl
self._client: httpx.AsyncClient | None = None
self._authed = False
# 最近一次 auth 失败的原因(unreachable | credentials | banned),
# 供上层(checker/startup 等待循环)区分故障类型;成功后清空。
self.last_auth_error: str | None = None
# Single-flights the reactive 403 re-login below so concurrent
# requests don't each fire their own login attempt and trip
# qBittorrent's IP ban (#1046-adjacent).
@@ -81,18 +84,34 @@ class QbDownloader:
resp.status_code == 200 and resp.text.startswith("Ok")
) or resp.status_code == 204:
self._authed = True
self.last_auth_error = None
return True
elif (
resp.status_code == 200 and resp.text.startswith("Fails")
) or resp.status_code == 401:
# 密码错误重试只会触发 qB 的 WebUI IP ban(默认 5 次失败
# 即封禁,之后全部 403),必须立刻停下并明确提示。
self.last_auth_error = "credentials"
logger.error(
"qBittorrent rejected the username or password. "
"Check the downloader credentials in AutoBangumi settings; "
"not retrying to avoid a WebUI IP ban."
)
break
elif resp.status_code == 403:
self.last_auth_error = "banned"
logger.error("Login refused by qBittorrent Server")
logger.info("Please release the IP in qBittorrent Server")
break
else:
self.last_auth_error = "unreachable"
logger.error(
f"Can't login qBittorrent Server {self.host} by {self.username}, retry in 5 seconds."
)
await asyncio.sleep(5)
times += 1
except httpx.ConnectError as e:
self.last_auth_error = "unreachable"
if use_https:
logger.error(
"Cannot connect to qBittorrent Server via HTTPS. "
@@ -105,6 +124,7 @@ class QbDownloader:
await asyncio.sleep(10)
times += 1
except Exception as e:
self.last_auth_error = "unreachable"
if use_https and "ssl" in str(e).lower():
logger.error(
"TLS/SSL error connecting to qBittorrent. "

View File

@@ -132,6 +132,14 @@ class DownloadClient:
logger.error("Unsupported downloader type: %s", downloader_type)
raise Exception(f"Unsupported downloader type: {downloader_type}")
@property
def last_auth_error(self) -> str | None:
"""最近一次认证失败的原因(unreachable | credentials | banned)。
仅 qBittorrent 客户端会区分原因;aria2/mock 无此属性时返回 None。
"""
return getattr(self.client, "last_auth_error", None)
def _supports(self, capability: str, op: str) -> bool:
"""Whether the concrete client can perform ``op`` (log once if not)."""
caps = getattr(self.client, "capabilities", None)

View File

@@ -0,0 +1,3 @@
from .installer import DEFAULT_PLUGINS_ROOT, InstallResult, PluginInstaller
__all__ = ["DEFAULT_PLUGINS_ROOT", "InstallResult", "PluginInstaller"]

View File

@@ -0,0 +1,240 @@
"""LLM 提供商插件安装管线(克隆 update.updater 的下载/验签/解包模式)。
安全边界与更新系统一致:
1. catalog.json 本身也验签(防索引篡改/降级);
2. 每个插件 zip 校验 sha256 + ed25519 签名,拒绝未签名/坏签名;
3. _safe_extract 防 zip-slip;
4. manifest schema/id/entry/min_ab_version 逐项校验。
插件依赖仅限 stdlib + httpx + pydantic(应用已有),无 pip 安装。
"""
import asyncio
import hashlib
import json
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import httpx
import semver
from module.database import Database
from module.update.signing import DEFAULT_PUBKEY_PATH, verify_bundle_signature
logger = logging.getLogger(__name__)
# 插件目录与 catalog 托管位置。
GITHUB_OWNER = "EstrellaXD"
GITHUB_REPO = "Auto_Bangumi"
_CATALOG_TAG = "llm-plugins"
_RELEASE_BASE = (
f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{_CATALOG_TAG}"
)
_DL_HEADERS = {"User-Agent": "AutoBangumi-Plugins"}
DEFAULT_PLUGINS_ROOT = Path("config") / "plugins"
_MANIFEST_SCHEMA = 1
def _meets_min_version(app_version: str, min_ab: str) -> bool:
"""app_version 是否满足 min_ab。非 semver 的开发版(DEV_VERSION)放行。"""
try:
return semver.VersionInfo.parse(app_version).compare(min_ab) >= 0
except ValueError:
return True
@dataclass
class InstallResult:
success: bool
version: str = ""
message: str = ""
def _safe_extract(zip_path: Path, dest: Path) -> None:
"""解压 zip 到 dest,拒绝绝对路径/`..` 越界成员(防 zip-slip)。"""
import zipfile
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)
dest_resolved = dest.resolve()
with zipfile.ZipFile(zip_path) as zf:
for member in zf.namelist():
target = (dest / member).resolve()
if not target.is_relative_to(dest_resolved):
raise ValueError(f"Unsafe path in plugin: {member}")
zf.extractall(dest)
def _sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
class PluginInstaller:
def __init__(
self,
*,
root: Optional[Path] = None,
pubkey_path: Optional[Path] = None,
client: Optional[httpx.AsyncClient] = None,
app_version: str = "0.0.0",
) -> None:
self.root = root if root is not None else DEFAULT_PLUGINS_ROOT
self.pubkey_path = pubkey_path or DEFAULT_PUBKEY_PATH
self._client = client
self.app_version = app_version
self._lock = asyncio.Lock()
async def _get_client(self) -> httpx.AsyncClient:
if self._client is not None:
return self._client
from module.network.request_url import get_shared_client
return await get_shared_client()
async def _download_bytes(self, url: str) -> bytes:
client = await self._get_client()
resp = await client.get(url, headers=_DL_HEADERS)
resp.raise_for_status()
return resp.content
async def _download_text(self, url: str) -> str:
client = await self._get_client()
resp = await client.get(url, headers=_DL_HEADERS)
resp.raise_for_status()
return resp.text
async def fetch_catalog(self) -> list[dict]:
"""拉取并验签 catalog.json,返回插件条目列表。"""
catalog_bytes = await self._download_bytes(f"{_RELEASE_BASE}/catalog.json")
sig = await self._download_text(f"{_RELEASE_BASE}/catalog.json.sig")
tmp = self.root / ".catalog.json"
self.root.mkdir(parents=True, exist_ok=True)
tmp.write_bytes(catalog_bytes)
try:
if not verify_bundle_signature(tmp, sig, self.pubkey_path):
raise ValueError("catalog signature verification failed")
finally:
tmp.unlink(missing_ok=True)
catalog = json.loads(catalog_bytes)
if not isinstance(catalog, dict) or catalog.get("schema") != _MANIFEST_SCHEMA:
raise ValueError("Unsupported catalog schema")
plugins = catalog.get("plugins", [])
return plugins if isinstance(plugins, list) else []
def _is_builtin(self, plugin_id: str) -> bool:
from module.parser.analyser.providers.builtin import BUILTIN
from module.parser.analyser.providers.presets import PRESET_ADAPTERS
return plugin_id in BUILTIN or plugin_id in PRESET_ADAPTERS
async def install(self, plugin_id: str) -> InstallResult:
async with self._lock:
try:
return await self._install(plugin_id)
except Exception as e: # noqa: BLE001 - 统一转成失败结果
logger.warning("Plugin install failed for %s: %s", plugin_id, e)
return InstallResult(success=False, message=str(e))
async def _install(self, plugin_id: str) -> InstallResult:
if self._is_builtin(plugin_id):
return InstallResult(
success=False, message=f"{plugin_id} is a builtin provider"
)
catalog = await self.fetch_catalog()
entry = next((p for p in catalog if p.get("id") == plugin_id), None)
if entry is None:
return InstallResult(
success=False, message=f"Plugin not found in catalog: {plugin_id}"
)
version = entry.get("version", "")
min_ab = entry.get("min_ab_version", "0.0.0")
if not _meets_min_version(self.app_version, min_ab):
return InstallResult(
success=False,
message=f"Plugin requires AutoBangumi >= {min_ab}",
)
asset = entry.get("asset") or f"{plugin_id}-{version}.zip"
zip_bytes = await self._download_bytes(f"{_RELEASE_BASE}/{asset}")
if _sha256_bytes(zip_bytes) != entry.get("sha256"):
return InstallResult(
success=False, message="Plugin sha256 checksum mismatch"
)
staging = self.root / ".staging" / plugin_id
staging.parent.mkdir(parents=True, exist_ok=True)
zip_path = self.root / ".staging" / f"{plugin_id}.zip"
zip_path.write_bytes(zip_bytes)
sig = await self._download_text(f"{_RELEASE_BASE}/{asset}.sig")
if not verify_bundle_signature(zip_path, sig, self.pubkey_path):
zip_path.unlink(missing_ok=True)
return InstallResult(success=False, message="Plugin signature invalid")
_safe_extract(zip_path, staging)
zip_path.unlink(missing_ok=True)
manifest = self._read_manifest(staging)
problem = self._validate_manifest(manifest, plugin_id)
if problem:
shutil.rmtree(staging, ignore_errors=True)
return InstallResult(success=False, message=problem)
# promote:config/plugins/<id>/<version>/ + installed.json
target = self.root / plugin_id / version
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(staging), str(target))
(self.root / plugin_id / "installed.json").write_text(
json.dumps({"version": version}), encoding="utf-8"
)
self._reload_registry(plugin_id)
return InstallResult(success=True, version=version)
def _read_manifest(self, unpacked: Path) -> dict:
data = json.loads((unpacked / "plugin.json").read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("plugin.json is not an object")
return data
def _validate_manifest(self, manifest: dict, plugin_id: str) -> Optional[str]:
if manifest.get("schema") != _MANIFEST_SCHEMA:
return "Unsupported plugin manifest schema"
if manifest.get("id") != plugin_id:
return "Plugin id mismatch between catalog and manifest"
entry = manifest.get("entry", "")
if ":" not in entry:
return "Invalid plugin entry"
min_ab = manifest.get("min_ab_version", "0.0.0")
if not _meets_min_version(self.app_version, min_ab):
return f"Plugin requires AutoBangumi >= {min_ab}"
return None
def _reload_registry(self, plugin_id: str) -> None:
from module.parser import title_parser
from module.parser.analyser.providers.registry import registry
# 让全局注册表从本安装器的插件根扫描(生产环境两者默认同为
# config/plugins;测试用 tmp_path 时也据此对齐)。
registry._plugins_root = self.root
registry.invalidate(plugin_id)
title_parser.reset_cache()
async def uninstall(self, plugin_id: str) -> InstallResult:
async with self._lock:
if self._is_builtin(plugin_id):
return InstallResult(
success=False, message=f"{plugin_id} is a builtin provider"
)
plugin_dir = self.root / plugin_id
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
async with Database() as db:
await db.llm_credential.delete(plugin_id)
self._reload_registry(plugin_id)
return InstallResult(success=True)

View File

@@ -0,0 +1,65 @@
"""从已安装的插件树按 manifest.entry 加载适配器类。
用版本化的模块名(``ab_llm_plugin_{id}_{ver}``)+ spec_from_file_location
加载,不污染 sys.path,升级时新版本是全新模块名 → 热加载无需重启。
"""
import importlib.util
import logging
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _module_name(plugin_id: str, version: str) -> str:
safe = plugin_id.replace("-", "_")
return f"ab_llm_plugin_{safe}_{version.replace('.', '_')}"
def load_adapter_class(plugin_dir: Path, manifest: dict) -> type:
"""加载并返回 manifest.entry 指定的适配器类。
entry 形如 ``pkg.module:ClassName``。校验类继承 LLMProviderAdapter 且
info.id 与 manifest id 一致(防止 zip 内混入冒名适配器)。
"""
from module.parser.analyser.providers.base import LLMProviderAdapter
entry = manifest.get("entry", "")
if ":" not in entry:
raise ValueError(f"Invalid plugin entry: {entry!r}")
module_path, class_name = entry.split(":", 1)
rel = module_path.replace(".", "/") + ".py"
file_path = plugin_dir / rel
if not file_path.exists():
raise ValueError(f"Plugin entry module not found: {rel}")
mod_name = _module_name(manifest["id"], manifest["version"])
spec = importlib.util.spec_from_file_location(
mod_name,
file_path,
submodule_search_locations=[str(plugin_dir)],
)
if spec is None or spec.loader is None:
raise ValueError(f"Cannot load plugin module: {mod_name}")
module: Any = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(mod_name, None)
raise
adapter_cls = getattr(module, class_name, None)
if adapter_cls is None:
raise ValueError(f"Plugin class not found: {class_name}")
if not (
isinstance(adapter_cls, type) and issubclass(adapter_cls, LLMProviderAdapter)
):
raise ValueError(f"{class_name} is not an LLMProviderAdapter")
if adapter_cls.info.id != manifest["id"]:
raise ValueError(
f"Adapter id {adapter_cls.info.id!r} != manifest id {manifest['id']!r}"
)
return adapter_cls

View File

@@ -1,5 +1,7 @@
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
from .config import Config
from .inbox import InboxMessage
from .llm_credential import LLMCredential
from .passkey import Passkey, PasskeyCreate, PasskeyDelete, PasskeyList
from .response import APIResponse, ResponseModel
from .rss import RSSItem, RSSUpdate

View File

@@ -232,17 +232,26 @@ class Notification(BaseModel):
return self
class LLMProviderOverride(BaseModel):
"""单个提供商的凭据/模型/端点覆盖(键名含 api_key,掩码机制自动生效)。"""
api_key: str = Field(default="", description="Provider API key")
model: str = Field(default="", description="Model override")
base_url: str = Field(default="", description="Base URL override")
class LLM(BaseModel):
"""LLM 标题解析配置,支持多提供商。
``provider="openai"`` 表示任意 OpenAI 兼容端点(DeepSeek/Ollama/
LM Studio/OpenRouter/OneAPI 等均可通过 ``base_url`` 接入)。
扁平的 ``api_key/model/base_url`` 是历史字段(老用户零迁移);
``providers`` 按提供商 id 存放各自的覆盖值,取值见 ``effective()``。
"""
enable: bool = Field(default=False, description="Enable LLM parser")
provider: Literal["openai", "anthropic", "gemini"] = Field(
default="openai", description="LLM provider"
)
# 开放为任意注册表 id:内置三家 + base_url 预设 + 已安装插件
provider: str = Field(default="openai", min_length=1, description="LLM provider id")
api_key: str = Field(default="", description="LLM api key")
model: str = Field(default="gpt-5-mini", description="LLM model name")
base_url: str = Field(
@@ -280,6 +289,24 @@ class LLM(BaseModel):
ge=0,
description="Seconds to skip LLM calls after failure_threshold is reached",
)
providers: dict[str, LLMProviderOverride] = Field(
default_factory=dict,
description="Per-provider overrides keyed by provider id",
)
def effective(self, provider_id: str | None = None) -> tuple[str, str, str]:
"""返回 (api_key, model, base_url)。
有 providers[id] 覆盖项时用其值(可为空,交由适配器回退到该提供商
自己的默认端点/型号);不跨提供商回退到扁平的 openai 字段——否则
DeepSeek 等会误收到 openai 的 model/base_url。仅当无覆盖项(内置
openai 默认路径)才用扁平字段。
"""
pid = provider_id or self.provider
override = self.providers.get(pid)
if override is None:
return self.api_key, self.model, self.base_url
return override.api_key, override.model, override.base_url
# [Deprecated] 旧版 OpenAI 解析配置,仅保留用于读取旧配置文件(向后兼容)。
@@ -357,7 +384,8 @@ class Update(BaseModel):
"""在线自动更新配置。
``channel`` 决定检查更新时挑选稳定版还是包含预发布(beta)版本;
``auto_check`` 控制前端是否在进入设置页时自动检查一次更新。
``auto_check`` 控制自动检查更新:前端进入设置页时检查一次,
后端每 24 小时检查一次并把新版本写入通知中心。
"""
channel: Literal["stable", "beta"] = Field(

View File

@@ -0,0 +1,29 @@
"""站内通知(通知中心)消息模型。
一行 = 一条通知。``kind`` + JSON ``payload`` 供前端按语言渲染文案,
``title``/``body`` 为 ``describe()`` 生成的中文兜底文案(未知 kind 时直接展示)。
时间戳沿用 RSSItem 的约定:UTC ISO-8601 字符串(字典序即时间序)。
"""
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class InboxMessage(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
kind: str = Field("", index=True)
severity: str = Field("info") # info | warning | error
title: str = Field("")
body: str = Field("")
payload: Optional[str] = Field(None) # JSON 字符串
dedup_key: Optional[str] = Field(None, index=True)
read: bool = Field(False, index=True)
count: int = Field(1) # 合并计数(同 dedup_key 未读重复出现的次数)
created_at: str = Field(default_factory=utcnow_iso)
updated_at: str = Field(default_factory=utcnow_iso)

View File

@@ -0,0 +1,25 @@
"""订阅类 LLM 提供商的凭据行(OAuth/device-flow token)。
刻意存数据库而非 config:token 永远不进 ``settings.dict()``,因此不可能
经 ``GET /config/get`` 泄漏;前端只能通过 auth/status 看到连接状态。
"""
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class LLMCredential(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
provider_id: str = Field("", index=True, unique=True)
access_token: str = Field("")
refresh_token: str = Field("")
expires_at: Optional[float] = Field(None) # epoch 秒
account_label: str = Field("")
extra: Optional[str] = Field(None) # JSON 字符串(provider 自定义)
updated_at: str = Field(default_factory=utcnow_iso)

View File

@@ -1,19 +1,29 @@
from .base import NotificationProvider
from .events import (
DownloaderUnavailableEvent,
DownloadFailureEvent,
LLMAuthFailureEvent,
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
UpdateAvailableEvent,
)
from .manager import NotificationManager
from .providers import PROVIDER_REGISTRY
__all__ = [
"DownloadFailureEvent",
"DownloaderUnavailableEvent",
"LLMAuthFailureEvent",
"LLMPluginInstallFailedEvent",
"NotificationManager",
"NotificationProvider",
"OffsetReviewEvent",
"PROVIDER_REGISTRY",
"RssFailureEvent",
"SystemEvent",
"UpdateAppliedEvent",
"UpdateAvailableEvent",
]

View File

@@ -3,19 +3,38 @@
刻意与 ``Notification``(module/models/bangumi.py,仅描述「新集数下载完成」)
分开:RSS 订阅失败、下载添加失败、偏移量待确认三类事件携带的字段与集数通知完全
无关,硬塞进 ``Notification`` 只会制造出没有意义的 season/episode 占位值。
每个事件同时服务两个消费方:
- 外部推送(Telegram/Bark 等):``describe() -> (标题, 正文)`` 渲染中文文案;
- 站内通知中心:``kind``/``severity``/``once``/``dedup_key()``/``payload()``
提供结构化字段,前端按 kind + payload 做多语言渲染,describe() 文案作为
未知 kind 的兜底展示。``once=True`` 表示同 dedup_key 终生只入库一次
(如"新版本可用",已读后同一版本不再提醒)。
"""
from dataclasses import dataclass
from typing import ClassVar, Optional
@dataclass(slots=True, frozen=True)
class RssFailureEvent:
"""RSS 订阅连接状态从正常变为异常(仅状态翻转时触发一次,而非每个 tick)。"""
kind: ClassVar[str] = "rss_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
rss_name: str
rss_url: str
error: str
def dedup_key(self) -> Optional[str]:
return f"rss_failure:{self.rss_url}"
def payload(self) -> dict:
return {"rss_name": self.rss_name, "rss_url": self.rss_url, "error": self.error}
def describe(self) -> tuple[str, str]:
"""返回该事件的默认 (标题, 正文)。"""
return (
@@ -28,9 +47,22 @@ class RssFailureEvent:
class DownloadFailureEvent:
"""匹配到的种子在(内置 HTTP 重试后)仍添加下载器失败。"""
kind: ClassVar[str] = "download_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
official_title: str
torrent_name: str
def dedup_key(self) -> Optional[str]:
return f"download_failure:{self.official_title}"
def payload(self) -> dict:
return {
"official_title": self.official_title,
"torrent_name": self.torrent_name,
}
def describe(self) -> tuple[str, str]:
return (
"种子添加失败",
@@ -43,9 +75,19 @@ class DownloadFailureEvent:
class OffsetReviewEvent:
"""番剧被标记为需要人工确认季度/集数偏移。"""
kind: ClassVar[str] = "offset_review"
severity: ClassVar[str] = "warning"
once: ClassVar[bool] = False
official_title: str
reason: str
def dedup_key(self) -> Optional[str]:
return f"offset_review:{self.official_title}"
def payload(self) -> dict:
return {"official_title": self.official_title, "reason": self.reason}
def describe(self) -> tuple[str, str]:
return (
"集数偏移待确认",
@@ -53,5 +95,167 @@ class OffsetReviewEvent:
)
@dataclass(slots=True, frozen=True)
class DownloaderUnavailableEvent:
"""下载器不可用:连不上、用户名/密码错误或 IP 被封禁。
``reason`` 只进 payload 不进 dedup_key——同一台下载器从 unreachable 翻转
到 credentials 时合并为一条(内容以最新为准),不产生两行。
"""
kind: ClassVar[str] = "downloader_unavailable"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
host: str
reason: str # unreachable | credentials | banned
def dedup_key(self) -> Optional[str]:
return f"downloader:{self.host}"
def payload(self) -> dict:
return {"host": self.host, "reason": self.reason}
def describe(self) -> tuple[str, str]:
details = {
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
"banned": "IP 已被下载器封禁,请在下载器 WebUI 中解封或重启下载器。",
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
}
detail = details.get(self.reason, details["unreachable"])
return ("下载器连接异常", f"下载器:{self.host}\n{detail}")
@dataclass(slots=True, frozen=True)
class UpdateAvailableEvent:
"""检查到可用的新版本。"""
kind: ClassVar[str] = "update_available"
severity: ClassVar[str] = "info"
once: ClassVar[bool] = True
current: str
latest: str
channel: str
notes: str = ""
def dedup_key(self) -> Optional[str]:
return f"update_available:{self.latest}"
def payload(self) -> dict:
return {
"current": self.current,
"latest": self.latest,
"channel": self.channel,
"notes": self.notes,
}
def describe(self) -> tuple[str, str]:
return (
"发现新版本",
f"当前版本:{self.current}\n最新版本:{self.latest}({self.channel} 频道)\n"
"可前往 设置 → 软件更新 升级。",
)
@dataclass(slots=True, frozen=True)
class UpdateAppliedEvent:
"""更新应用结果(成功或失败)。每次结果都单独入库,不做去重。"""
once: ClassVar[bool] = False
version: str
success: bool
message: str = ""
@property
def kind(self) -> str:
return "update_applied" if self.success else "update_failed"
@property
def severity(self) -> str:
return "info" if self.success else "error"
def dedup_key(self) -> Optional[str]:
return None
def payload(self) -> dict:
return {"version": self.version, "message": self.message}
def describe(self) -> tuple[str, str]:
if self.success:
return ("程序更新完成", f"已更新到 {self.version},重启后生效。")
return ("程序更新失败", f"版本:{self.version}\n原因:{self.message}")
@dataclass(slots=True, frozen=True)
class LLMAuthFailureEvent:
"""订阅类 LLM 提供商凭据失效(刷新失败),需要用户重新连接。"""
kind: ClassVar[str] = "llm_auth_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
provider_id: str
account_label: str = ""
message: str = ""
def dedup_key(self) -> Optional[str]:
return f"llm_auth:{self.provider_id}"
def payload(self) -> dict:
return {
"provider_id": self.provider_id,
"account_label": self.account_label,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
account = f"({self.account_label})" if self.account_label else ""
return (
"LLM 提供商凭据失效",
f"提供商:{self.provider_id}{account}\n{self.message}\n"
"请前往 设置 → LLM 解析器 重新连接。",
)
@dataclass(slots=True, frozen=True)
class LLMPluginInstallFailedEvent:
"""LLM 提供商插件安装失败(下载/签名校验/兼容性)。"""
kind: ClassVar[str] = "llm_plugin_install_failed"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
plugin_id: str
version: str = ""
message: str = ""
def dedup_key(self) -> Optional[str]:
return f"llm_plugin_install:{self.plugin_id}"
def payload(self) -> dict:
return {
"plugin_id": self.plugin_id,
"version": self.version,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
return (
"LLM 插件安装失败",
f"插件:{self.plugin_id} {self.version}\n原因:{self.message}",
)
# 除「新集数」以外的通知事件的联合类型。
SystemEvent = RssFailureEvent | DownloadFailureEvent | OffsetReviewEvent
SystemEvent = (
RssFailureEvent
| DownloadFailureEvent
| OffsetReviewEvent
| DownloaderUnavailableEvent
| UpdateAvailableEvent
| UpdateAppliedEvent
| LLMAuthFailureEvent
| LLMPluginInstallFailedEvent
)

View File

@@ -0,0 +1,48 @@
"""站内通知中心的持久化 sink。
``record_event`` 把 SystemEvent 落库(``async with Database()``,
session-per-operation);``inbox_revision`` 是进程内单调递增的修订号,
任何入库/已读/删除操作都会 bump,SSE 循环据此决定是否需要查库推送,
避免每个 tick 都打一次数据库。AB 为单进程部署,进程内计数即可。
"""
import json
import logging
from module.database import Database
from module.notification.events import SystemEvent
logger = logging.getLogger(__name__)
INBOX_KEEP = 500
_revision: int = 1
def inbox_revision() -> int:
return _revision
def bump_inbox_revision() -> None:
global _revision
_revision += 1
async def record_event(event: SystemEvent) -> int:
"""事件入库,返回消息 id;被 once 去重跳过时返回 0。"""
title, body = event.describe()
async with Database() as db:
message = await db.inbox.upsert(
kind=event.kind,
severity=event.severity,
title=title,
body=body,
payload=json.dumps(event.payload(), ensure_ascii=False),
dedup_key=event.dedup_key(),
once=event.once,
keep=INBOX_KEEP,
)
if message is None:
return 0
bump_inbox_revision()
return message.id

View File

@@ -8,6 +8,7 @@ from module.conf import settings
from module.database import Database
from module.models.bangumi import Notification
from module.notification.events import SystemEvent
from module.notification.inbox import record_event
if TYPE_CHECKING:
from module.models.config import NotificationProvider as ProviderConfig
@@ -115,12 +116,24 @@ class NotificationManager:
await self._broadcast("notification", send_one)
async def send_event(self, event: SystemEvent):
"""Send a system event (RSS failure, download failure, offset review)
to all enabled providers.
"""Persist a system event to the in-app inbox, then broadcast it.
持久化不受 ``settings.notification.enable`` 影响(该开关只管外部
推送),失败也不阻塞外部广播。
Args:
event: The system event to send.
"""
try:
await record_event(event)
except Exception:
logger.warning(
"Failed to persist inbox message for %s",
type(event).__name__,
exc_info=True,
)
if not settings.notification.enable:
return
async def send_one(provider: "NotificationProvider"):
await provider.send_event(event)

View File

@@ -1,100 +1,45 @@
"""多提供商 LLM 标题解析器。
"""多提供商 LLM 标题解析器(门面)。
支持三类提供商:
- ``openai``:任意 OpenAI 兼容端点(官方 API、DeepSeek、Ollama、
LM Studio、OpenRouter、OneAPI 等,经 ``base_url`` 接入)
- ``anthropic``:Anthropic Claude
- ``gemini``:Google Gemini
具体提供商逻辑在 ``providers/`` 包中(内置 openai/anthropic/gemini 适配器
+ 注册表);本模块保留三样东西以维持既有 patch/import 路径:
各提供商的 ``parse()`` 返回统一的 Episode 形状 dict;任何 API 错误、
拒答或输出不可解析时返回 None,由调用方(title_parser)决定回退策略。
- ``_build_http_client``:代理感知的 httpx 客户端工厂(测试 patch
``module.parser.analyser.llm.httpx`` / ``AsyncProxyTransport``);
- ``Episode``/``EPISODE_JSON_SCHEMA``/``DEFAULT_PROMPT`` 等共享契约的
重导出(物理定义在 providers/schema.py);
- ``LLMParser``:构造签名不变的薄门面,将 ``_openai_client`` 等内部
客户端属性双向委托给适配器(测试直接改写/重绑这些属性)。
"""
import json
import logging
from typing import Any, Optional
from typing import Any
import httpx
from httpx_socks import AsyncProxyTransport
from pydantic import BaseModel
from module.conf import settings
# anthropic / openai / google-genai 合计导入约 0.7s,而 LLM 默认关闭。
# 这些重型 SDK 改为在真正构造/调用某提供商时才导入(见 __init__ 与各
# _parse_* 方法),把这段耗时移出容器启动路径。httpx / httpx_socks 保持
# 顶层导入:它们本就在网络层被加载,且测试按模块级名字 patch 它们。
from .providers.base import AdapterContext
from .providers.credentials import CredentialStore
from .providers.registry import registry
from .providers.schema import ( # noqa: F401 (re-exported for existing importers)
DEFAULT_PROMPT,
EPISODE_JSON_SCHEMA,
GEMINI_JSON_INSTRUCTION,
Episode,
)
logger = logging.getLogger(__name__)
class Episode(BaseModel):
"""LLM 结构化输出的目标形状(与 models.bangumi.Episode 字段一致)。"""
title_en: Optional[str]
title_zh: Optional[str]
title_jp: Optional[str]
season: int
season_raw: str
episode: int
sub: str
group: str
resolution: str
source: str
# Anthropic 结构化输出要求:所有属性均列入 required,
# additionalProperties 为 false,且不带 min/max 等约束
EPISODE_JSON_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"title_en": {"type": ["string", "null"]},
"title_zh": {"type": ["string", "null"]},
"title_jp": {"type": ["string", "null"]},
"season": {"type": "integer"},
"season_raw": {"type": "string"},
"episode": {"type": "integer"},
"sub": {"type": "string"},
"group": {"type": "string"},
"resolution": {"type": "string"},
"source": {"type": "string"},
},
"required": [
"title_en",
"title_zh",
"title_jp",
"season",
"season_raw",
"episode",
"sub",
"group",
"resolution",
"source",
],
"additionalProperties": False,
}
DEFAULT_PROMPT = """\
You will now play the role of a super assistant.
Your task is to extract structured data from unstructured text content and output it in JSON format.
If you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.
But Do not fabricate data!
"""
# Gemini 走 response_mime_type + 提示词描述 JSON 形状(避免 SDK schema
# 类型与 mypy 冲突),因此在提示词里显式给出字段列表
GEMINI_JSON_INSTRUCTION = (
"Output a single JSON object with exactly these keys: "
"title_en (string or null), title_zh (string or null), "
"title_jp (string or null), season (integer), season_raw (string), "
"episode (integer), sub (string), group (string), "
"resolution (string), source (string)."
# 测试通过这些属性名直接操作/重绑底层 SDK 客户端;门面把它们的读写
# 都转发给适配器实例。
_ADAPTER_ATTRS = frozenset(
{"_openai_client", "_anthropic_client", "_gemini_client", "_http_client"}
)
class LLMParser:
"""按 provider 分发的 LLM 标题解析器。"""
"""按 provider 分发的 LLM 标题解析器(适配器门面)。"""
def __init__(
self,
@@ -104,76 +49,51 @@ class LLMParser:
base_url: str = "",
timeout: float = 20.0,
) -> None:
"""初始化解析器并构建对应提供商的异步客户端。
"""初始化解析器并构建对应提供商的适配器。
Args:
api_key: 提供商 API 密钥,必填。
provider: "openai" | "anthropic" | "gemini"。
api_key: 提供商 API 密钥(api_key 类提供商必填)。
provider: 注册表中的提供商 id。
model: 模型名。
base_url: 自定义端点,仅 openai 提供商使用;空串表示官方 API。
base_url: 自定义端点;空串表示官方 API。
timeout: 单次 LLM 请求超时秒数。
Raises:
ValueError: api_key 为空或 provider 不受支持。
ValueError: api_key 为空(api_key 类提供商)或 provider 不受支持。
"""
if not api_key:
adapter_cls = registry.resolve(provider)
if adapter_cls.info.auth_kind == "api_key" and not api_key:
raise ValueError("API key is required.")
self.provider = provider
self.model = model
self._http_client: httpx.AsyncClient | None = None
if provider == "openai":
from openai import AsyncOpenAI
self._http_client = _build_http_client(timeout)
self._openai_client = AsyncOpenAI(
self._adapter = adapter_cls(
AdapterContext(
model=model,
api_key=api_key,
base_url=base_url or None,
base_url=base_url,
timeout=timeout,
http_client=self._http_client,
build_http_client=_build_http_client,
# 订阅类适配器 parse/list_models 时读取凭据;api_key 类忽略。
credentials=CredentialStore(provider),
)
elif provider == "anthropic":
import anthropic
)
self._http_client = _build_http_client(timeout)
self._anthropic_client = anthropic.AsyncAnthropic(
api_key=api_key,
timeout=timeout,
http_client=self._http_client,
)
elif provider == "gemini":
from google import genai
def __getattr__(self, name: str) -> Any:
# 仅在常规查找失败时触发:把适配器持有的客户端属性透出。
return getattr(object.__getattribute__(self, "_adapter"), name)
self._gemini_client = genai.Client(api_key=api_key)
def __setattr__(self, name: str, value: Any) -> None:
if name in _ADAPTER_ATTRS:
setattr(self._adapter, name, value)
else:
raise ValueError(f"Unsupported LLM provider: {provider}")
object.__setattr__(self, name, value)
async def aclose(self) -> None:
if self._http_client is not None:
await self._http_client.aclose()
await self._adapter.aclose()
async def list_models(self) -> list[str]:
"""列出当前提供商可用的模型 id(升序)。
openai 走 ``/models``(自定义 base_url 时即该端点的模型表);
gemini 只保留支持 generateContent 的模型并去掉 ``models/`` 前缀。
SDK 异常原样抛出,由调用方决定如何呈现。
"""
ids: list[str]
if self.provider == "openai":
openai_page = await self._openai_client.models.list()
ids = [m.id for m in openai_page.data]
elif self.provider == "anthropic":
anthropic_page = await self._anthropic_client.models.list()
ids = [m.id for m in anthropic_page.data]
else:
ids = []
async for m in await self._gemini_client.aio.models.list():
actions = getattr(m, "supported_actions", None)
if actions and "generateContent" not in actions:
continue
name = m.name or ""
ids.append(name.removeprefix("models/"))
return sorted(ids)
"""列出当前提供商可用的模型 id(升序)。SDK 异常原样抛出。"""
return await self._adapter.list_models()
async def parse(self, raw: str, asdict: bool = True) -> dict | None:
"""解析种子标题,返回 Episode 形状的 dict;失败返回 None。
@@ -182,93 +102,10 @@ class LLMParser:
raw: 待解析的原始标题。
asdict: 保留的兼容参数,结果始终为 dict(或 None)。
"""
if self.provider == "openai":
result = await self._parse_openai(raw)
elif self.provider == "anthropic":
result = await self._parse_anthropic(raw)
else:
result = await self._parse_gemini(raw)
result = await self._adapter.parse(raw)
logger.debug("LLM(%s) parsed result: %s", self.provider, result)
return result
async def _parse_openai(self, raw: str) -> dict | None:
import openai as openai_sdk
try:
resp = await self._openai_client.beta.chat.completions.parse(
model=self.model,
messages=[
{"role": "system", "content": DEFAULT_PROMPT},
{"role": "user", "content": raw},
],
response_format=Episode,
# temperature 置 0,让结果更稳定可复现
temperature=0,
)
parsed = resp.choices[0].message.parsed
except openai_sdk.OpenAIError as e:
logger.warning("OpenAI parse failed for '%s': %s", raw, e)
return None
if parsed is None:
logger.warning("OpenAI returned no parsed content for '%s'", raw)
return None
return parsed.model_dump()
async def _parse_anthropic(self, raw: str) -> dict | None:
import anthropic
try:
resp = await self._anthropic_client.messages.create(
model=self.model,
max_tokens=1024,
system=DEFAULT_PROMPT,
messages=[{"role": "user", "content": raw}],
output_config={
"format": {"type": "json_schema", "schema": EPISODE_JSON_SCHEMA}
},
)
except (
anthropic.RateLimitError,
anthropic.APIStatusError,
anthropic.APIConnectionError,
) as e:
logger.warning("Anthropic parse failed for '%s': %s", raw, e)
return None
if resp.stop_reason == "refusal":
logger.warning("Anthropic refused to parse '%s'", raw)
return None
try:
text = next(b.text for b in resp.content if b.type == "text")
return json.loads(text)
except (StopIteration, json.JSONDecodeError) as e:
logger.warning("Cannot decode Anthropic output for '%s': %s", raw, e)
return None
async def _parse_gemini(self, raw: str) -> dict | None:
from google.genai import errors as genai_errors
from google.genai import types as genai_types
try:
resp = await self._gemini_client.aio.models.generate_content(
model=self.model,
contents=f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}\n\n{raw}",
config=genai_types.GenerateContentConfig(
response_mime_type="application/json"
),
)
text = resp.text
except genai_errors.APIError as e:
logger.warning("Gemini parse failed for '%s': %s", raw, e)
return None
if not text:
logger.warning("Gemini returned no text for '%s'", raw)
return None
try:
return json.loads(text)
except json.JSONDecodeError as e:
logger.warning("Cannot decode Gemini output for '%s': %s", raw, e)
return None
def _build_http_client(timeout: float) -> httpx.AsyncClient:
http_timeout = httpx.Timeout(

View File

@@ -0,0 +1,20 @@
from .base import (
AdapterContext,
AuthChallenge,
AuthExpiredError,
LLMProviderAdapter,
ProviderInfo,
TokenSet,
)
from .registry import ProviderRegistry, registry
__all__ = [
"AdapterContext",
"AuthChallenge",
"AuthExpiredError",
"LLMProviderAdapter",
"ProviderInfo",
"ProviderRegistry",
"TokenSet",
"registry",
]

View File

@@ -0,0 +1,110 @@
"""LLM 提供商适配器契约。
一个提供商 = 一个 `LLMProviderAdapter` 子类:负责客户端构造、
`parse`/`list_models`,以及(订阅类提供商)认证流程钩子。内置三家与
国产预设走 api_key;订阅插件(device_code/oauth)另行实现认证钩子。
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Callable, ClassVar, Literal, Optional
import httpx
from pydantic import BaseModel
class ProviderInfo(BaseModel):
"""提供商静态描述,驱动前端表单与注册表列举。"""
id: str
display_name: str
auth_kind: Literal["api_key", "oauth", "device_code"] = "api_key"
builtin: bool = False
needs_base_url: bool = False
preset_base_url: str = ""
default_model: str = ""
# OpenAI 兼容端点是否支持 json_schema 结构化输出;False 时降级为
# json_object + schema 写入提示词(DeepSeek/MiniMax)。
supports_json_schema: bool = True
plugin_version: Optional[str] = None
class AuthChallenge(BaseModel):
"""begin_auth 的产物:引导用户完成授权所需的全部信息。"""
method: Literal["redirect_paste", "device_code"]
authorize_url: Optional[str] = None
user_code: Optional[str] = None
verification_uri: Optional[str] = None
expires_in: int = 600
state: str
class TokenSet(BaseModel):
"""一次成功认证得到的凭据集(服务端持久化,永不下发前端)。"""
access_token: str
refresh_token: str = ""
expires_at: Optional[float] = None # epoch 秒
account_label: str = ""
extra: dict = {}
class AuthExpiredError(Exception):
"""凭据失效且刷新失败——调用方应立即熔断并通知用户重新连接。"""
@dataclass
class AdapterContext:
"""适配器可触达的全部依赖(除此之外不读全局配置)。"""
model: str
api_key: str = ""
base_url: str = ""
timeout: float = 20.0
# llm._build_http_client——代理感知的 httpx 客户端工厂(保持其
# patch 路径在 llm.py,因此以依赖注入方式传入)。
build_http_client: Callable[[float], httpx.AsyncClient] = None # type: ignore[assignment]
# M3 起为 CredentialStore;api_key 类适配器恒为 None。
credentials: Any = None
class LLMProviderAdapter(ABC):
"""提供商适配器基类。"""
info: ClassVar[ProviderInfo]
_http_client: Optional[httpx.AsyncClient] = None
def __init__(self, ctx: AdapterContext) -> None:
self.ctx = ctx
# 模型留空时回退到本提供商的默认型号(预设/插件各有自己的默认,
# 不会误用 openai 的 gpt-5-mini)。
self.model = ctx.model or self.info.default_model
@abstractmethod
async def parse(self, raw: str) -> dict | None:
"""解析种子标题为 Episode 形状 dict;失败返回 None。"""
@abstractmethod
async def list_models(self) -> list[str]:
"""列出可用模型 id(升序)。"""
async def aclose(self) -> None:
if self._http_client is not None:
await self._http_client.aclose()
# ------------------------------------------------------------ auth hooks
# 仅 oauth / device_code 适配器覆写;api_key 类默认不支持。
async def begin_auth(self) -> AuthChallenge:
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
async def refresh(self, tokens: TokenSet) -> TokenSet:
raise NotImplementedError(f"{self.info.id} does not support token refresh")
async def revoke(self, tokens: TokenSet) -> None:
"""注销远端凭据,尽力而为;默认无操作。"""

View File

@@ -0,0 +1,226 @@
"""内置三家提供商适配器(openai / anthropic / gemini,API-Key 直连)。
解析与客户端构造逻辑自 LLMParser 原样搬入;重型 SDK 仍然懒导入
(LLM 默认关闭,导入 ~0.7s 不应进启动路径)。
"""
import json
import logging
from .base import AdapterContext, LLMProviderAdapter, ProviderInfo
from .schema import (
DEFAULT_PROMPT,
EPISODE_JSON_SCHEMA,
GEMINI_JSON_INSTRUCTION,
Episode,
)
logger = logging.getLogger(__name__)
class OpenAIAdapter(LLMProviderAdapter):
"""任意 OpenAI 兼容端点(官方 API、DeepSeek、Ollama、OneAPI 等)。"""
info = ProviderInfo(
id="openai",
display_name="OpenAI",
auth_kind="api_key",
builtin=True,
needs_base_url=True,
default_model="gpt-5-mini",
)
def __init__(self, ctx: AdapterContext) -> None:
super().__init__(ctx)
from openai import AsyncOpenAI
self._http_client = ctx.build_http_client(ctx.timeout)
self._openai_client = AsyncOpenAI(
api_key=ctx.api_key,
# 配置留空时回退到预设端点(国产提供商预设子类)
base_url=ctx.base_url or self.info.preset_base_url or None,
timeout=ctx.timeout,
http_client=self._http_client,
)
async def parse(self, raw: str) -> dict | None:
if not self.info.supports_json_schema:
return await self._parse_json_object(raw)
return await self._parse_structured(raw)
async def _parse_structured(self, raw: str) -> dict | None:
import openai as openai_sdk
try:
resp = await self._openai_client.beta.chat.completions.parse(
model=self.model,
messages=[
{"role": "system", "content": DEFAULT_PROMPT},
{"role": "user", "content": raw},
],
response_format=Episode,
# temperature 置 0,让结果更稳定可复现
temperature=0,
)
parsed = resp.choices[0].message.parsed
except openai_sdk.OpenAIError as e:
logger.warning("OpenAI parse failed for '%s': %s", raw, e)
return None
if parsed is None:
logger.warning("OpenAI returned no parsed content for '%s'", raw)
return None
return parsed.model_dump()
async def _parse_json_object(self, raw: str) -> dict | None:
"""json_object 模式:schema 写入提示词,响应本地校验为 Episode。"""
import openai as openai_sdk
from pydantic import ValidationError
try:
resp = await self._openai_client.chat.completions.create(
model=self.model,
messages=[
{
"role": "system",
"content": f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}",
},
{"role": "user", "content": raw},
],
response_format={"type": "json_object"},
temperature=0,
)
content = resp.choices[0].message.content
except openai_sdk.OpenAIError as e:
logger.warning("OpenAI-compatible parse failed for '%s': %s", raw, e)
return None
if not content:
logger.warning("OpenAI-compatible returned no content for '%s'", raw)
return None
try:
return Episode(**json.loads(content)).model_dump()
except (json.JSONDecodeError, TypeError, ValidationError) as e:
logger.warning(
"Cannot decode OpenAI-compatible output for '%s': %s", raw, e
)
return None
async def list_models(self) -> list[str]:
openai_page = await self._openai_client.models.list()
return sorted(m.id for m in openai_page.data)
class AnthropicAdapter(LLMProviderAdapter):
"""Anthropic Claude(官方 API Key)。"""
info = ProviderInfo(
id="anthropic",
display_name="Anthropic",
auth_kind="api_key",
builtin=True,
default_model="claude-haiku-4-5",
)
def __init__(self, ctx: AdapterContext) -> None:
super().__init__(ctx)
import anthropic
self._http_client = ctx.build_http_client(ctx.timeout)
self._anthropic_client = anthropic.AsyncAnthropic(
api_key=ctx.api_key,
timeout=ctx.timeout,
http_client=self._http_client,
)
async def parse(self, raw: str) -> dict | None:
import anthropic
try:
resp = await self._anthropic_client.messages.create(
model=self.model,
max_tokens=1024,
system=DEFAULT_PROMPT,
messages=[{"role": "user", "content": raw}],
output_config={
"format": {"type": "json_schema", "schema": EPISODE_JSON_SCHEMA}
},
)
except (
anthropic.RateLimitError,
anthropic.APIStatusError,
anthropic.APIConnectionError,
) as e:
logger.warning("Anthropic parse failed for '%s': %s", raw, e)
return None
if resp.stop_reason == "refusal":
logger.warning("Anthropic refused to parse '%s'", raw)
return None
try:
text = next(b.text for b in resp.content if b.type == "text")
return json.loads(text)
except (StopIteration, json.JSONDecodeError) as e:
logger.warning("Cannot decode Anthropic output for '%s': %s", raw, e)
return None
async def list_models(self) -> list[str]:
anthropic_page = await self._anthropic_client.models.list()
return sorted(m.id for m in anthropic_page.data)
class GeminiAdapter(LLMProviderAdapter):
"""Google Gemini(AI Studio API Key)。"""
info = ProviderInfo(
id="gemini",
display_name="Google Gemini",
auth_kind="api_key",
builtin=True,
default_model="gemini-2.5-flash",
)
def __init__(self, ctx: AdapterContext) -> None:
super().__init__(ctx)
from google import genai
self._gemini_client = genai.Client(api_key=ctx.api_key)
async def parse(self, raw: str) -> dict | None:
from google.genai import errors as genai_errors
from google.genai import types as genai_types
try:
resp = await self._gemini_client.aio.models.generate_content(
model=self.model,
contents=f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}\n\n{raw}",
config=genai_types.GenerateContentConfig(
response_mime_type="application/json"
),
)
text = resp.text
except genai_errors.APIError as e:
logger.warning("Gemini parse failed for '%s': %s", raw, e)
return None
if not text:
logger.warning("Gemini returned no text for '%s'", raw)
return None
try:
return json.loads(text)
except json.JSONDecodeError as e:
logger.warning("Cannot decode Gemini output for '%s': %s", raw, e)
return None
async def list_models(self) -> list[str]:
ids: list[str] = []
async for m in await self._gemini_client.aio.models.list():
actions = getattr(m, "supported_actions", None)
if actions and "generateContent" not in actions:
continue
name = m.name or ""
ids.append(name.removeprefix("models/"))
return sorted(ids)
BUILTIN: dict[str, type[LLMProviderAdapter]] = {
"openai": OpenAIAdapter,
"anthropic": AnthropicAdapter,
"gemini": GeminiAdapter,
}

View File

@@ -0,0 +1,50 @@
"""订阅提供商凭据访问 + 进程内 auth generation 计数。
- ``CredentialStore``:适配器唯一被允许的凭据通道(provider 域内
load/save/clear),内部 session-per-operation 开库。
- ``auth_generation``:连接/断开时 bump(静默刷新不 bump),
title_parser 把它掺进单例键实现"换号即重建、刷新不抖动"。
AB 单进程部署,进程内计数即可。
"""
from typing import Optional
from .base import TokenSet
_generations: dict[str, int] = {}
def auth_generation(provider_id: str) -> int:
return _generations.get(provider_id, 0)
def bump_auth_generation(provider_id: str) -> None:
_generations[provider_id] = auth_generation(provider_id) + 1
class CredentialStore:
"""单一提供商的凭据读写句柄(注入 AdapterContext.credentials)。"""
def __init__(self, provider_id: str) -> None:
self.provider_id = provider_id
async def load(self) -> Optional[TokenSet]:
# 延迟导入打破 database ↔ providers 循环(database 的 repo 依赖
# providers.base 的 TokenSet)。
from module.database import Database
async with Database() as db:
return await db.llm_credential.get(self.provider_id)
async def save(self, tokens: TokenSet) -> None:
"""持久化凭据(含静默刷新——不 bump generation)。"""
from module.database import Database
async with Database() as db:
await db.llm_credential.upsert(self.provider_id, tokens)
async def clear(self) -> None:
from module.database import Database
async with Database() as db:
await db.llm_credential.delete(self.provider_id)

View File

@@ -0,0 +1,91 @@
"""国产提供商 base_url 预设(纯数据,全部复用 OpenAIAdapter)。
这些提供商的订阅(Coding Plan 等)都是"发 API Key + OpenAI 兼容端点",
无需 OAuth;预设只是替用户省去手抄 base_url。大陆/海外端点分开列
(智谱 Coding Plan 的 Key 只在 coding 专用端点有效,与普通 v4 不互通)。
端点核实日期:2026-07(来源:各家官方文档 / Claude Code 接入指南)。
"""
from dataclasses import dataclass
from .base import ProviderInfo
from .builtin import OpenAIAdapter
@dataclass(frozen=True)
class ProviderPreset:
id: str
display_name: str
base_url: str
default_model: str
supports_json_schema: bool = True
PRESETS: tuple[ProviderPreset, ...] = (
ProviderPreset(
id="zai",
display_name="智谱 GLM (Z.ai 海外)",
base_url="https://api.z.ai/api/paas/v4",
default_model="glm-4.7",
),
ProviderPreset(
id="zai-cn",
display_name="智谱 GLM (大陆 Coding Plan)",
base_url="https://open.bigmodel.cn/api/coding/paas/v4",
default_model="glm-4.7",
),
ProviderPreset(
id="minimax",
display_name="MiniMax (海外)",
base_url="https://api.minimax.io/v1",
default_model="MiniMax-M2.5",
supports_json_schema=False,
),
ProviderPreset(
id="minimax-cn",
display_name="MiniMax (大陆)",
base_url="https://api.minimaxi.com/v1",
default_model="MiniMax-M2.5",
supports_json_schema=False,
),
ProviderPreset(
id="deepseek",
display_name="DeepSeek",
base_url="https://api.deepseek.com",
default_model="deepseek-v4-flash",
supports_json_schema=False,
),
ProviderPreset(
id="kimi",
display_name="Moonshot Kimi (海外)",
base_url="https://api.moonshot.ai/v1",
default_model="kimi-k2.5",
),
ProviderPreset(
id="kimi-cn",
display_name="Moonshot Kimi (大陆)",
base_url="https://api.moonshot.cn/v1",
default_model="kimi-k2.5",
),
)
def _make_adapter(preset: ProviderPreset) -> type[OpenAIAdapter]:
info = ProviderInfo(
id=preset.id,
display_name=preset.display_name,
auth_kind="api_key",
builtin=False,
needs_base_url=True,
preset_base_url=preset.base_url,
default_model=preset.default_model,
supports_json_schema=preset.supports_json_schema,
)
class_name = f"{preset.id.replace('-', '_').title().replace('_', '')}PresetAdapter"
return type(class_name, (OpenAIAdapter,), {"info": info})
PRESET_ADAPTERS: dict[str, type[OpenAIAdapter]] = {
preset.id: _make_adapter(preset) for preset in PRESETS
}

View File

@@ -0,0 +1,99 @@
"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件的统一入口。
- ``list_infos()`` 只读静态描述(内置/预设直接读 info;插件读 manifest,
首次不触发插件 Python 导入);
- ``resolve()`` 返回适配器类,插件在首次 resolve 时才 import(懒加载);
- ``invalidate()`` 在安装/卸载/升级后丢弃插件缓存。
"""
import json
import logging
from pathlib import Path
from typing import Optional
from .base import LLMProviderAdapter, ProviderInfo
from .builtin import BUILTIN
from .presets import PRESET_ADAPTERS
logger = logging.getLogger(__name__)
class ProviderRegistry:
def __init__(self, plugins_root: Optional[Path] = None) -> None:
# 延迟到实际使用时再解析默认值,避免 import 期触达文件系统
self._plugins_root = plugins_root
self._plugin_cache: dict[str, type[LLMProviderAdapter]] = {}
@property
def plugins_root(self) -> Path:
if self._plugins_root is not None:
return self._plugins_root
return Path("config") / "plugins"
def _installed_manifests(self) -> dict[str, tuple[Path, dict]]:
"""扫描已安装插件目录,返回 {id: (版本目录, manifest)},不导入代码。"""
result: dict[str, tuple[Path, dict]] = {}
root = self.plugins_root
if not root.exists():
return result
for plugin_dir in root.iterdir():
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
continue
pointer = plugin_dir / "installed.json"
if not pointer.exists():
continue
try:
version = json.loads(pointer.read_text(encoding="utf-8"))["version"]
version_dir = plugin_dir / version
manifest = json.loads(
(version_dir / "plugin.json").read_text(encoding="utf-8")
)
except Exception as e: # noqa: BLE001 - 单个坏插件不应拖垮列举
logger.warning("Skipping broken plugin %s: %s", plugin_dir.name, e)
continue
result[manifest["id"]] = (version_dir, manifest)
return result
def list_infos(self) -> list[ProviderInfo]:
"""列出全部可用提供商的静态描述(内置 → 预设 → 已安装插件)。"""
infos = [cls.info for cls in (*BUILTIN.values(), *PRESET_ADAPTERS.values())]
for plugin_id, (_, manifest) in self._installed_manifests().items():
infos.append(
ProviderInfo(
id=plugin_id,
display_name=manifest.get("name", plugin_id),
auth_kind=manifest.get("auth_kind", "api_key"),
builtin=False,
needs_base_url=manifest.get("needs_base_url", False),
default_model=manifest.get("default_model", ""),
plugin_version=manifest.get("version"),
)
)
return infos
def resolve(self, provider_id: str) -> type[LLMProviderAdapter]:
"""按 id 解析适配器类;未知 id 抛 ValueError。"""
adapter_cls = BUILTIN.get(provider_id) or PRESET_ADAPTERS.get(provider_id)
if adapter_cls is not None:
return adapter_cls
if provider_id in self._plugin_cache:
return self._plugin_cache[provider_id]
installed = self._installed_manifests().get(provider_id)
if installed is not None:
from module.llm_plugins.loader import load_adapter_class
version_dir, manifest = installed
loaded = load_adapter_class(version_dir, manifest)
self._plugin_cache[provider_id] = loaded
return loaded
raise ValueError(f"Unsupported LLM provider: {provider_id}")
def invalidate(self, provider_id: str | None = None) -> None:
"""插件安装/卸载后失效缓存。"""
if provider_id is None:
self._plugin_cache.clear()
else:
self._plugin_cache.pop(provider_id, None)
registry = ProviderRegistry()

View File

@@ -0,0 +1,74 @@
"""LLM 解析的共享输出契约:Episode 形状、JSON schema 与提示词。
物理上从 llm.py 抽出以打破 llm ↔ providers 的循环导入;llm.py 原地
重导出这些名字,外部 import 路径(含测试)保持不变。
"""
from typing import Any, Optional
from pydantic import BaseModel
class Episode(BaseModel):
"""LLM 结构化输出的目标形状(与 models.bangumi.Episode 字段一致)。"""
title_en: Optional[str]
title_zh: Optional[str]
title_jp: Optional[str]
season: int
season_raw: str
episode: int
sub: str
group: str
resolution: str
source: str
# Anthropic 结构化输出要求:所有属性均列入 required,
# additionalProperties 为 false,且不带 min/max 等约束
EPISODE_JSON_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"title_en": {"type": ["string", "null"]},
"title_zh": {"type": ["string", "null"]},
"title_jp": {"type": ["string", "null"]},
"season": {"type": "integer"},
"season_raw": {"type": "string"},
"episode": {"type": "integer"},
"sub": {"type": "string"},
"group": {"type": "string"},
"resolution": {"type": "string"},
"source": {"type": "string"},
},
"required": [
"title_en",
"title_zh",
"title_jp",
"season",
"season_raw",
"episode",
"sub",
"group",
"resolution",
"source",
],
"additionalProperties": False,
}
DEFAULT_PROMPT = """\
You will now play the role of a super assistant.
Your task is to extract structured data from unstructured text content and output it in JSON format.
If you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.
But Do not fabricate data!
"""
# Gemini 走 response_mime_type + 提示词描述 JSON 形状(避免 SDK schema
# 类型与 mypy 冲突),因此在提示词里显式给出字段列表
GEMINI_JSON_INSTRUCTION = (
"Output a single JSON object with exactly these keys: "
"title_en (string or null), title_zh (string or null), "
"title_jp (string or null), season (integer), season_raw (string), "
"episode (integer), sub (string), group (string), "
"resolution (string), source (string)."
)

View File

@@ -13,10 +13,31 @@ from module.parser.analyser import (
tmdb_parser,
torrent_parser,
)
from module.parser.analyser.providers.base import AuthExpiredError
from module.parser.analyser.providers.credentials import auth_generation
from module.parser.analyser.raw_parser import _detect_non_episodic_type
logger = logging.getLogger(__name__)
# 保留 fire-and-forget 通知任务的强引用,防止其在运行完成前被 GC。
_notify_tasks: set = set()
def _notify_auth_failure(provider_id: str, message: str) -> None:
"""凭据失效时向通知中心投递事件(fire-and-forget,不阻塞解析路径)。"""
from module.notification import LLMAuthFailureEvent, NotificationManager
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
event = LLMAuthFailureEvent(provider_id=provider_id, message=message)
task = loop.create_task(NotificationManager().send_event(event))
_notify_tasks.add(task)
task.add_done_callback(_notify_tasks.discard)
# Lazy singleton: building an LLMParser (and its underlying SDK client) is not
# free, so keep one around and only rebuild it when the relevant settings change.
# mode 不影响客户端构建(调用时读取),不参与缓存键。
@@ -32,7 +53,9 @@ _llm_semaphore_limit: int | None = None
def _get_llm_parser(kwargs: dict) -> LLMParser:
global _llm_parser, _llm_parser_kwargs
if _llm_parser is None or _llm_parser_kwargs != kwargs:
_llm_parser = LLMParser(**kwargs)
# auth_gen 只参与"是否重建"的比较,不是构造参数
ctor_kwargs = {k: v for k, v in kwargs.items() if k != "auth_gen"}
_llm_parser = LLMParser(**ctor_kwargs)
_llm_parser_kwargs = kwargs
return _llm_parser
@@ -96,12 +119,15 @@ async def _llm_parse(raw: str) -> Episode | None:
if _llm_breaker_until > now:
logger.warning("LLM parser breaker is open; skipping '%s'", raw)
return None
api_key, model, base_url = conf.effective()
parser_kwargs = {
"provider": conf.provider,
"api_key": conf.api_key,
"model": conf.model,
"base_url": conf.base_url,
"api_key": api_key,
"model": model,
"base_url": base_url,
"timeout": conf.timeout,
# 换号/断开时 bump → 单例重建;静默刷新不 bump(token 不进键)。
"auth_gen": auth_generation(conf.provider),
}
cache_key = (tuple(sorted(parser_kwargs.items())), raw)
if conf.cache_ttl > 0:
@@ -112,7 +138,19 @@ async def _llm_parse(raw: str) -> Episode | None:
return cached_episode
_llm_cache.pop(cache_key, None)
try:
llm = _get_llm_parser(parser_kwargs)
try:
llm = _get_llm_parser(parser_kwargs)
except ValueError as e:
# 未知 provider id(如配置里手改出拼写错误):明确指出并熔断,
# 避免每个标题都刷一行含糊的失败日志。
logger.error(
"Unknown LLM provider '%s' (%s); check settings.llm.provider",
conf.provider,
e,
)
_llm_breaker_until = time.monotonic() + conf.failure_backoff
_cache_llm_result(cache_key, None, conf.cache_ttl)
return None
semaphore = _get_llm_semaphore(conf.max_concurrency)
async with semaphore:
episode_dict = await asyncio.wait_for(
@@ -127,6 +165,17 @@ async def _llm_parse(raw: str) -> Episode | None:
_llm_breaker_until = 0.0
_cache_llm_result(cache_key, episode, conf.cache_ttl)
return episode
except AuthExpiredError as e:
# 刷新已失败,重试没有意义:跳过失败阈值直接熔断,等用户重连。
logger.error(
"LLM credentials for '%s' expired and refresh failed: %s",
conf.provider,
e,
)
_llm_breaker_until = time.monotonic() + conf.failure_backoff
_cache_llm_result(cache_key, None, conf.cache_ttl)
_notify_auth_failure(conf.provider, str(e))
return None
except Exception as e:
logger.warning(f"LLM cannot parse '{raw}': {type(e).__name__}: {e}")
_record_llm_failure(conf)

View File

@@ -613,7 +613,8 @@ class TestListLLMModels:
def test_masked_key_falls_back_to_saved_key(self, authed_client):
parser = self._mock_parser(models=["m"])
saved = MagicMock()
saved.llm.api_key = "sk-saved"
# 掩码回退现在按提供商取值(providers[id] 覆盖 → 扁平字段兜底)
saved.llm.effective.return_value = ("sk-saved", "", "")
with (
patch("module.api.config.settings", saved),
patch("module.api.config.LLMParser", return_value=parser) as cls,
@@ -627,7 +628,7 @@ class TestListLLMModels:
def test_missing_key_returns_400(self, authed_client):
saved = MagicMock()
saved.llm.api_key = ""
saved.llm.effective.return_value = ("", "", "")
with patch("module.api.config.settings", saved):
response = authed_client.post(
"/api/v1/config/llm/models",

View File

@@ -206,3 +206,83 @@ class TestLogPayload:
result = await _log_payload()
assert result == "hello world\n"
# ---------------------------------------------------------------------------
# notification event(通知中心未读数推送)
# ---------------------------------------------------------------------------
class TestNotificationEvent:
def _generator(self, tmp_path):
from module.api.events import _event_generator
request = AsyncMock()
request.is_disconnected = AsyncMock(return_value=False)
ctx = MagicMock()
ctx.is_running = True
ctx.first_run = False
patches = (
patch(
"module.api.events._downloader_payload",
new=AsyncMock(return_value=None),
),
patch("module.api.events.LOG_PATH", tmp_path / "missing.log"),
patch(
"module.api.events._notification_payload",
new=AsyncMock(return_value={"unread_count": 2, "latest_id": 9}),
),
patch("module.api.events.asyncio.sleep", new=AsyncMock()),
)
return _event_generator(request, ctx), patches
async def _collect(self, gen, n):
events = []
for _ in range(n):
events.append(await asyncio.wait_for(gen.__anext__(), timeout=1.0))
return events
async def test_first_tick_emits_notification_frame(self, tmp_path):
from module.notification.inbox import inbox_revision
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
try:
events = await self._collect(gen, 3)
finally:
await gen.aclose()
by_name = {e["event"]: e for e in events}
assert set(by_name) == {"status", "downloader", "notification"}
data = json.loads(by_name["notification"]["data"])
assert data["unread_count"] == 2
assert data["latest_id"] == 9
assert data["revision"] == inbox_revision()
async def test_no_frame_when_revision_unchanged(self, tmp_path):
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
try:
await self._collect(gen, 3) # 第一个 tick
# tick3 status、tick5 downloader —— 中间不应再有 notification
more = await self._collect(gen, 2)
finally:
await gen.aclose()
assert [e["event"] for e in more] == ["status", "downloader"]
async def test_frame_emitted_after_revision_bump(self, tmp_path):
from module.notification.inbox import bump_inbox_revision, inbox_revision
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
try:
await self._collect(gen, 3) # 第一个 tick
bump_inbox_revision()
# tick3:status + notification(修订号变了)
more = await self._collect(gen, 2)
finally:
await gen.aclose()
assert [e["event"] for e in more] == ["status", "notification"]
assert json.loads(more[1]["data"])["revision"] == inbox_revision()

View File

@@ -0,0 +1,255 @@
"""LLM 提供商/认证 API:/config/llm/providers + auth begin/complete/status/disconnect。"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from module.api import v1
from module.parser.analyser.providers.base import (
AuthChallenge,
LLMProviderAdapter,
ProviderInfo,
TokenSet,
)
from module.security.api import get_current_user
class FakeDeviceAdapter(LLMProviderAdapter):
info = ProviderInfo(
id="fake-device",
display_name="Fake Device",
auth_kind="device_code",
builtin=False,
)
async def parse(self, raw):
return None
async def list_models(self):
return []
async def begin_auth(self) -> AuthChallenge:
return AuthChallenge(
method="device_code",
user_code="WXYZ-1234",
verification_uri="https://example.com/device",
state="state-123",
)
async def complete_auth(self, state, user_input=""):
return TokenSet(access_token="at", account_label="tester@example.com")
async def revoke(self, tokens):
return None
@pytest.fixture
def app():
app = FastAPI()
app.include_router(v1, prefix="/api")
app.state.ctx = MagicMock()
return app
@pytest.fixture
def authed_client(app):
async def mock_user():
return "testuser"
app.dependency_overrides[get_current_user] = mock_user
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def unauthed_client(app):
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_pending():
from module.api import llm as llm_api
llm_api._PENDING.clear()
yield
for task in list(llm_api._POLL_TASKS):
task.cancel()
llm_api._POLL_TASKS.clear()
llm_api._PENDING.clear()
@patch("module.security.api.DEV_AUTH_BYPASS", False)
def test_providers_requires_auth(unauthed_client):
assert unauthed_client.get("/api/v1/config/llm/providers").status_code == 401
def test_list_providers_includes_builtins_and_presets(authed_client):
# 全为 api_key 提供商(无插件安装)→ 不触碰 DB
resp = authed_client.get("/api/v1/config/llm/providers")
assert resp.status_code == 200
ids = {p["id"] for p in resp.json()["providers"]}
assert {"openai", "anthropic", "gemini", "kimi", "deepseek"} <= ids
def test_auth_begin_returns_opaque_handle_and_hides_device_code(authed_client):
from module.api import llm as llm_api
async def _noop(*args, **kwargs):
return None
with (
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
# 让后台轮询任务空转,避免它在断言前清掉 pending
patch("module.api.llm._device_poll_loop", new=_noop),
):
resp = authed_client.post("/api/v1/config/llm/providers/fake-device/auth/begin")
assert resp.status_code == 200
body = resp.json()
assert body["user_code"] == "WXYZ-1234"
assert body["method"] == "device_code"
# 返回的是不透明 handle,绝非适配器原始 state(device_code 藏服务端 blob)
handle = body["state"]
assert handle and handle != "state-123"
assert "state-123" not in json.dumps(body)
pending = llm_api._PENDING[handle]
assert pending.provider_id == "fake-device"
assert pending.blob == "state-123" # 敏感态只在服务端
def test_auth_complete_saves_credential(authed_client):
from module.api import llm as llm_api
from module.api.llm import PendingAuth
llm_api._PENDING["handle-1"] = PendingAuth(
provider_id="fake-device", created=0.0, expires=9e18, blob="blob-secret"
)
save = AsyncMock()
with (
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
patch("module.api.llm.CredentialStore") as store_cls,
):
store_cls.return_value.save = save
resp = authed_client.post(
"/api/v1/config/llm/providers/fake-device/auth/complete",
json={"state": "handle-1", "code": ""},
)
assert resp.status_code == 200
assert resp.json()["connected"] is True
assert resp.json()["account_label"] == "tester@example.com"
save.assert_awaited_once()
assert "handle-1" not in llm_api._PENDING # 用完即清
async def test_device_poll_loop_saves_on_success():
"""服务端轮询:complete_auth 成功即写库 + bump generation + 清 pending。"""
from module.api import llm as llm_api
from module.api.llm import PendingAuth
llm_api._PENDING["h"] = PendingAuth(
provider_id="fake-device",
created=0.0,
expires=9e18,
blob=json.dumps({"device_code": "DC", "interval": 1}),
)
save = AsyncMock()
try:
with (
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
patch("module.api.llm.CredentialStore") as store_cls,
patch("module.api.llm.bump_auth_generation") as bump,
patch("module.api.llm.asyncio.sleep", new_callable=AsyncMock),
):
store_cls.return_value.save = save
await llm_api._device_poll_loop("fake-device", "h", expires_in=900)
save.assert_awaited_once()
bump.assert_called_once_with("fake-device")
assert "h" not in llm_api._PENDING
finally:
llm_api._PENDING.pop("h", None)
def test_auth_complete_unknown_state_returns_400(authed_client):
resp = authed_client.post(
"/api/v1/config/llm/providers/fake-device/auth/complete",
json={"state": "nope", "code": ""},
)
assert resp.status_code == 400
def test_auth_status_reports_connected(authed_client):
with patch("module.api.llm.CredentialStore") as store_cls:
store_cls.return_value.load = AsyncMock(
return_value=TokenSet(access_token="at", account_label="me@x.com")
)
resp = authed_client.get("/api/v1/config/llm/providers/fake-device/auth/status")
assert resp.status_code == 200
body = resp.json()
assert body["connected"] is True
assert body["account_label"] == "me@x.com"
assert "access_token" not in body # token 绝不下发
def test_install_success(authed_client):
from module.llm_plugins import InstallResult
installer = MagicMock()
installer.install = AsyncMock(
return_value=InstallResult(success=True, version="1.0.0")
)
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
resp = authed_client.post("/api/v1/config/llm/providers/fake-copilot/install")
assert resp.status_code == 200
assert resp.json()["version"] == "1.0.0"
def test_install_failure_returns_400_and_notifies(authed_client, app):
from module.llm_plugins import InstallResult
from module.notification import LLMPluginInstallFailedEvent
app.state.ctx.notifier.send_event = AsyncMock()
installer = MagicMock()
installer.install = AsyncMock(
return_value=InstallResult(success=False, message="Plugin signature invalid")
)
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
resp = authed_client.post("/api/v1/config/llm/providers/fake-copilot/install")
assert resp.status_code == 400
event = app.state.ctx.notifier.send_event.await_args.args[0]
assert isinstance(event, LLMPluginInstallFailedEvent)
assert event.plugin_id == "fake-copilot"
def test_uninstall_builtin_returns_400(authed_client):
from module.llm_plugins import InstallResult
installer = MagicMock()
installer.uninstall = AsyncMock(
return_value=InstallResult(
success=False, message="openai is a builtin provider"
)
)
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
resp = authed_client.delete("/api/v1/config/llm/providers/openai")
assert resp.status_code == 400
def test_disconnect_clears_and_bumps_generation(authed_client):
from module.api import llm as llm_api
clear = AsyncMock()
with (
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
patch("module.api.llm.CredentialStore") as store_cls,
patch("module.api.llm.bump_auth_generation") as bump,
):
store_cls.return_value.clear = clear
store_cls.return_value.load = AsyncMock(return_value=TokenSet(access_token="a"))
resp = authed_client.delete("/api/v1/config/llm/providers/fake-device/auth")
assert resp.status_code == 200
clear.assert_awaited_once()
bump.assert_called_once_with("fake-device")

View File

@@ -0,0 +1,165 @@
"""通知中心 REST API(/notification/messages*)。"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from module.api import v1
from module.database import get_db
from module.models import InboxMessage
from module.security.api import get_current_user
@pytest.fixture
def mock_db():
return MagicMock()
@pytest.fixture
def app(mock_db):
app = FastAPI()
app.include_router(v1, prefix="/api")
async def _override_get_db():
yield mock_db
app.dependency_overrides[get_db] = _override_get_db
return app
@pytest.fixture
def authed_client(app):
async def mock_user():
return "testuser"
app.dependency_overrides[get_current_user] = mock_user
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def unauthed_client(app):
return TestClient(app)
def _message(**overrides) -> InboxMessage:
fields = dict(
id=1,
kind="rss_failure",
severity="error",
title="RSS 订阅连接异常",
body="feed down",
payload='{"rss_name": "Feed", "error": "boom"}',
dedup_key="rss_failure:u",
read=False,
count=3,
created_at="2026-07-05T10:00:00+00:00",
updated_at="2026-07-05T12:00:00+00:00",
)
fields.update(overrides)
return InboxMessage(**fields)
@patch("module.security.api.DEV_AUTH_BYPASS", False)
def test_messages_require_auth(unauthed_client):
assert unauthed_client.get("/api/v1/notification/messages").status_code == 401
def test_list_messages_returns_parsed_payload(authed_client, mock_db):
mock_db.inbox.list = AsyncMock(return_value=[_message()])
mock_db.inbox.count_all = AsyncMock(return_value=1)
mock_db.inbox.unread_count = AsyncMock(return_value=1)
resp = authed_client.get("/api/v1/notification/messages")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["unread_count"] == 1
message = data["messages"][0]
assert message["kind"] == "rss_failure"
assert message["payload"] == {"rss_name": "Feed", "error": "boom"}
assert message["count"] == 3
def test_list_messages_passes_query_params(authed_client, mock_db):
mock_db.inbox.list = AsyncMock(return_value=[])
mock_db.inbox.count_all = AsyncMock(return_value=0)
mock_db.inbox.unread_count = AsyncMock(return_value=0)
resp = authed_client.get(
"/api/v1/notification/messages?unread_only=true&limit=10&offset=20"
)
assert resp.status_code == 200
mock_db.inbox.list.assert_awaited_once_with(unread_only=True, limit=10, offset=20)
def test_unread_count(authed_client, mock_db):
mock_db.inbox.unread_count = AsyncMock(return_value=4)
resp = authed_client.get("/api/v1/notification/messages/unread-count")
assert resp.status_code == 200
assert resp.json()["unread_count"] == 4
def test_mark_read_bumps_revision(authed_client, mock_db):
mock_db.inbox.mark_read = AsyncMock(return_value=True)
with patch("module.api.notification.bump_inbox_revision") as bump:
resp = authed_client.post("/api/v1/notification/messages/5/read")
assert resp.status_code == 200
mock_db.inbox.mark_read.assert_awaited_once_with(5)
bump.assert_called_once()
def test_mark_read_unknown_id_returns_404(authed_client, mock_db):
mock_db.inbox.mark_read = AsyncMock(return_value=False)
resp = authed_client.post("/api/v1/notification/messages/999/read")
assert resp.status_code == 404
def test_mark_all_read(authed_client, mock_db):
mock_db.inbox.mark_all_read = AsyncMock(return_value=2)
with patch("module.api.notification.bump_inbox_revision") as bump:
resp = authed_client.post("/api/v1/notification/messages/read-all")
assert resp.status_code == 200
bump.assert_called_once()
def test_delete_message(authed_client, mock_db):
mock_db.inbox.delete = AsyncMock(return_value=True)
with patch("module.api.notification.bump_inbox_revision") as bump:
resp = authed_client.delete("/api/v1/notification/messages/5")
assert resp.status_code == 200
mock_db.inbox.delete.assert_awaited_once_with(5)
bump.assert_called_once()
def test_delete_unknown_id_returns_404(authed_client, mock_db):
mock_db.inbox.delete = AsyncMock(return_value=False)
resp = authed_client.delete("/api/v1/notification/messages/999")
assert resp.status_code == 404
def test_clear_all(authed_client, mock_db):
mock_db.inbox.clear = AsyncMock(return_value=7)
with patch("module.api.notification.bump_inbox_revision") as bump:
resp = authed_client.delete("/api/v1/notification/messages")
assert resp.status_code == 200
bump.assert_called_once()

View File

@@ -0,0 +1,32 @@
"""Checker.check_downloader_detail —— 构造错误也要被捕获,不得击穿启动循环。"""
from unittest.mock import MagicMock, patch
from module.checker import Checker
async def test_construction_error_returns_false_not_raises():
"""下载器类型无法识别时 DownloadClient() 构造即抛,必须捕获为
(False, 'unreachable'),否则会击穿 _wait_for_downloader 的等待循环。"""
settings = MagicMock()
settings.downloader.type = "bogus-type"
with (
patch("module.checker.checker.settings", settings),
patch(
"module.downloader.DownloadClient",
side_effect=Exception("Unsupported downloader type: bogus-type"),
),
):
ok, reason = await Checker.check_downloader_detail()
assert ok is False
assert reason == "unreachable"
async def test_mock_downloader_short_circuits_ok():
settings = MagicMock()
settings.downloader.type = "mock"
with patch("module.checker.checker.settings", settings):
ok, reason = await Checker.check_downloader_detail()
assert ok is True
assert reason is None

View File

@@ -8,6 +8,7 @@ import pytest
from module.conf import settings as real_settings
from module.core.context import AppContext
from module.models import ResponseModel
from module.notification import DownloaderUnavailableEvent
@pytest.fixture
@@ -43,10 +44,10 @@ def ctx():
class TestBuild:
def test_build_registers_four_named_tasks(self):
def test_build_registers_five_named_tasks(self):
built = AppContext.build(real_settings)
names = [t.name for t in built.scheduler.tasks]
assert names == ["rss", "rename", "offset_scan", "calendar"]
assert names == ["rss", "rename", "offset_scan", "calendar", "update_check"]
def test_build_does_no_io(self):
"""build() is pure wiring: not running, startup not done."""
@@ -133,8 +134,8 @@ class TestStartTasks:
patch("module.core.context.reset_shared_client", new=AsyncMock()),
patch("module.core.context.Checker.check_first_run", return_value=False),
patch(
"module.core.context.Checker.check_downloader",
new=AsyncMock(return_value=True),
"module.core.context.Checker.check_downloader_detail",
new=AsyncMock(return_value=(True, None)),
),
):
resp = await ctx.start_tasks()
@@ -155,8 +156,8 @@ class TestStartTasks:
patch("module.core.context.reset_shared_client", new=AsyncMock()),
patch("module.core.context.Checker.check_first_run", return_value=False),
patch(
"module.core.context.Checker.check_downloader",
new=AsyncMock(return_value=True),
"module.core.context.Checker.check_downloader_detail",
new=AsyncMock(return_value=(True, None)),
),
):
await ctx.start_tasks()
@@ -169,14 +170,14 @@ class TestStartTasks:
async def check_downloader():
await start_gate.wait()
return True
return (True, None)
with (
patch("module.core.context.settings"),
patch("module.core.context.reset_shared_client", new=AsyncMock()),
patch("module.core.context.Checker.check_first_run", return_value=False),
patch(
"module.core.context.Checker.check_downloader",
"module.core.context.Checker.check_downloader_detail",
new=AsyncMock(side_effect=check_downloader),
),
):
@@ -187,6 +188,52 @@ class TestStartTasks:
ctx.scheduler.start_all.assert_called_once()
# ---------------------------------------------------------------------------
# _wait_for_downloader()
# ---------------------------------------------------------------------------
class TestWaitForDownloader:
async def test_gives_up_after_max_retries_and_emits_unreachable(self, ctx):
"""连不上时按原有节奏重试到上限,然后写入一条 unreachable 通知。"""
ctx.notifier.send_event = AsyncMock()
with (
patch(
"module.core.context.Checker.check_downloader_detail",
new=AsyncMock(return_value=(False, "unreachable")),
),
patch("module.core.context.asyncio.sleep", new_callable=AsyncMock),
):
await ctx._wait_for_downloader()
ctx.notifier.send_event.assert_awaited_once()
event = ctx.notifier.send_event.await_args.args[0]
assert isinstance(event, DownloaderUnavailableEvent)
assert event.reason == "unreachable"
async def test_aborts_immediately_on_credentials_error(self, ctx):
"""密码错误重试没有意义且会触发 qB 封禁——立即放弃并通知。"""
ctx.notifier.send_event = AsyncMock()
with (
patch(
"module.core.context.Checker.check_downloader_detail",
new=AsyncMock(return_value=(False, "credentials")),
) as mock_check,
patch(
"module.core.context.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep,
):
await ctx._wait_for_downloader()
assert mock_check.await_count == 1
mock_sleep.assert_not_awaited()
event = ctx.notifier.send_event.await_args.args[0]
assert isinstance(event, DownloaderUnavailableEvent)
assert event.reason == "credentials"
# ---------------------------------------------------------------------------
# stop()
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,170 @@
"""InboxDatabase — 通知中心消息表的增删查改与合并语义。"""
import json
from sqlmodel import select
from module.database.inbox import InboxDatabase
from module.models import InboxMessage
async def _rows(db_session) -> list[InboxMessage]:
result = await db_session.execute(select(InboxMessage))
return list(result.scalars().all())
async def test_upsert_inserts_new_message(db_session):
db = InboxDatabase(db_session)
msg = await db.upsert(
kind="rss_failure",
severity="error",
title="RSS 失效",
body="feed x",
payload=json.dumps({"rss_url": "http://a/rss"}),
dedup_key="rss_failure:http://a/rss",
)
assert msg is not None
assert msg.id is not None
assert msg.count == 1
assert msg.read is False
assert len(await _rows(db_session)) == 1
async def test_upsert_unread_same_dedup_coalesces(db_session):
db = InboxDatabase(db_session)
first = await db.upsert(
kind="downloader_unavailable",
severity="error",
payload=json.dumps({"reason": "unreachable"}),
dedup_key="downloader:http://qb:8080",
)
# 人为回拨时间戳,验证合并时会刷新 updated_at
first.updated_at = "2020-01-01T00:00:00+00:00"
db_session.add(first)
await db_session.commit()
second = await db.upsert(
kind="downloader_unavailable",
severity="error",
payload=json.dumps({"reason": "credentials"}),
dedup_key="downloader:http://qb:8080",
)
rows = await _rows(db_session)
assert len(rows) == 1
assert second.id == first.id
assert second.count == 2
assert json.loads(second.payload)["reason"] == "credentials" # 最新覆盖
assert second.updated_at > "2020-01-01T00:00:00+00:00"
async def test_upsert_read_same_dedup_inserts_new_row(db_session):
db = InboxDatabase(db_session)
first = await db.upsert(kind="rss_failure", dedup_key="rss:1")
await db.mark_read(first.id)
second = await db.upsert(kind="rss_failure", dedup_key="rss:1")
rows = await _rows(db_session)
assert len(rows) == 2
assert second.id != first.id
assert second.read is False
async def test_upsert_once_skips_when_any_row_exists(db_session):
db = InboxDatabase(db_session)
first = await db.upsert(
kind="update_available", dedup_key="update_available:3.3.1", once=True
)
assert first is not None
await db.mark_read(first.id)
second = await db.upsert(
kind="update_available", dedup_key="update_available:3.3.1", once=True
)
assert second is None
assert len(await _rows(db_session)) == 1
async def test_upsert_without_dedup_always_inserts(db_session):
db = InboxDatabase(db_session)
await db.upsert(kind="update_applied")
await db.upsert(kind="update_applied")
assert len(await _rows(db_session)) == 2
async def test_prune_keeps_newest(db_session):
db = InboxDatabase(db_session)
for i in range(5):
msg = await db.upsert(kind="download_failure")
msg.updated_at = f"2026-01-0{i + 1}T00:00:00+00:00"
db_session.add(msg)
await db_session.commit()
removed = await db.prune(keep=2)
assert removed == 3
rows = await _rows(db_session)
assert len(rows) == 2
assert {r.updated_at[:10] for r in rows} == {"2026-01-04", "2026-01-05"}
async def test_upsert_prunes_beyond_keep(db_session):
db = InboxDatabase(db_session)
for _ in range(4):
await db.upsert(kind="download_failure", keep=3)
assert len(await _rows(db_session)) == 3
async def test_unread_count_and_mark_read(db_session):
db = InboxDatabase(db_session)
first = await db.upsert(kind="rss_failure")
await db.upsert(kind="download_failure")
assert await db.unread_count() == 2
assert await db.mark_read(first.id) is True
assert await db.unread_count() == 1
assert await db.mark_read(9999) is False
async def test_mark_all_read(db_session):
db = InboxDatabase(db_session)
await db.upsert(kind="rss_failure")
await db.upsert(kind="download_failure")
changed = await db.mark_all_read()
assert changed == 2
assert await db.unread_count() == 0
async def test_delete_and_clear(db_session):
db = InboxDatabase(db_session)
first = await db.upsert(kind="rss_failure")
await db.upsert(kind="download_failure")
assert await db.delete(first.id) is True
assert await db.delete(first.id) is False
assert await db.clear() == 1
assert len(await _rows(db_session)) == 0
async def test_list_pagination_and_unread_filter(db_session):
db = InboxDatabase(db_session)
for i in range(3):
msg = await db.upsert(kind=f"kind_{i}")
msg.updated_at = f"2026-01-0{i + 1}T00:00:00+00:00"
db_session.add(msg)
await db_session.commit()
newest = (await db.list(unread_only=False, limit=1, offset=0))[0]
assert newest.kind == "kind_2" # updated_at 倒序
second_page = await db.list(unread_only=False, limit=1, offset=1)
assert second_page[0].kind == "kind_1"
await db.mark_read(newest.id)
unread = await db.list(unread_only=True, limit=10, offset=0)
assert {m.kind for m in unread} == {"kind_0", "kind_1"}
assert await db.count_all(unread_only=False) == 3
assert await db.count_all(unread_only=True) == 2

View File

@@ -0,0 +1,41 @@
"""LLMCredentialDatabase — 订阅提供商凭据表(token 只存服务端)。"""
from module.database.llm_credential import LLMCredentialDatabase
from module.parser.analyser.providers.base import TokenSet
async def test_upsert_inserts_then_updates(db_session):
db = LLMCredentialDatabase(db_session)
tokens = TokenSet(
access_token="at-1",
refresh_token="rt-1",
expires_at=1000.0,
account_label="user@example.com",
extra={"account_id": "acc-1"},
)
await db.upsert("github-copilot", tokens)
loaded = await db.get("github-copilot")
assert loaded is not None
assert loaded.access_token == "at-1"
assert loaded.account_label == "user@example.com"
assert loaded.extra == {"account_id": "acc-1"}
await db.upsert("github-copilot", TokenSet(access_token="at-2"))
updated = await db.get("github-copilot")
assert updated.access_token == "at-2"
assert await db.count() == 1 # 同 provider 只有一行
async def test_get_unknown_provider_returns_none(db_session):
db = LLMCredentialDatabase(db_session)
assert await db.get("nonexistent") is None
async def test_delete_removes_row(db_session):
db = LLMCredentialDatabase(db_session)
await db.upsert("codex-chatgpt", TokenSet(access_token="at"))
assert await db.delete("codex-chatgpt") is True
assert await db.get("codex-chatgpt") is None
assert await db.delete("codex-chatgpt") is False

View File

@@ -0,0 +1,110 @@
"""CredentialStore + auth generation 计数 + AuthExpiredError 熔断/通知。"""
import time
from unittest.mock import AsyncMock, MagicMock, patch
from module.notification.events import LLMAuthFailureEvent, LLMPluginInstallFailedEvent
from module.parser.analyser.providers.base import TokenSet
from module.parser.analyser.providers.credentials import (
CredentialStore,
auth_generation,
bump_auth_generation,
)
def _async_cm(entered_value):
cm = AsyncMock()
cm.__aenter__ = AsyncMock(return_value=entered_value)
cm.__aexit__ = AsyncMock(return_value=False)
return cm
class TestAuthGeneration:
def test_bump_increments_per_provider(self):
before = auth_generation("prov-a")
bump_auth_generation("prov-a")
assert auth_generation("prov-a") == before + 1
# 互不影响
assert auth_generation("prov-b") == 0 or isinstance(
auth_generation("prov-b"), int
)
class TestCredentialStore:
async def test_load_save_clear_scoped_to_provider(self):
db = MagicMock()
db.llm_credential.get = AsyncMock(return_value=TokenSet(access_token="at"))
db.llm_credential.upsert = AsyncMock()
db.llm_credential.delete = AsyncMock(return_value=True)
store = CredentialStore("github-copilot")
# Database 在方法内延迟导入(打破循环),因此 patch 源模块
with patch("module.database.Database", return_value=_async_cm(db)):
loaded = await store.load()
await store.save(TokenSet(access_token="at-2"))
await store.clear()
assert loaded.access_token == "at"
db.llm_credential.get.assert_awaited_once_with("github-copilot")
db.llm_credential.upsert.assert_awaited_once()
assert db.llm_credential.upsert.call_args.args[0] == "github-copilot"
db.llm_credential.delete.assert_awaited_once_with("github-copilot")
class TestEventContracts:
def test_llm_auth_failure_event(self):
event = LLMAuthFailureEvent(
provider_id="codex-chatgpt", account_label="u@x.com", message="expired"
)
assert event.kind == "llm_auth_failure"
assert event.severity == "error"
assert event.dedup_key() == "llm_auth:codex-chatgpt"
assert event.payload() == {
"provider_id": "codex-chatgpt",
"account_label": "u@x.com",
"message": "expired",
}
title, body = event.describe()
assert "LLM" in title
def test_llm_plugin_install_failed_event(self):
event = LLMPluginInstallFailedEvent(
plugin_id="github-copilot", version="1.0.0", message="bad signature"
)
assert event.kind == "llm_plugin_install_failed"
assert event.severity == "error"
assert event.dedup_key() == "llm_plugin_install:github-copilot"
assert "bad signature" in event.describe()[1]
class TestAuthExpiredBreaker:
async def test_auth_expired_opens_breaker_and_notifies(self):
"""AuthExpiredError:跳过失败计数阈值直接熔断,并发通知事件。"""
from module.parser import title_parser as tp
from module.parser.analyser.providers.base import AuthExpiredError
tp.reset_cache()
mock_parser = MagicMock()
mock_parser.parse = AsyncMock(side_effect=AuthExpiredError("refresh failed"))
with (
patch.object(tp, "_get_llm_parser", return_value=mock_parser),
patch.object(tp, "_llm_config") as mock_conf,
patch.object(tp, "_notify_auth_failure") as mock_notify,
):
mock_conf.return_value = MagicMock(
provider="codex-chatgpt",
timeout=5.0,
cache_ttl=0,
max_concurrency=1,
failure_threshold=3,
failure_backoff=300,
effective=lambda provider_id=None: ("", "m", ""),
)
result = await tp._llm_parse("raw title")
assert result is None
assert tp._llm_breaker_until > time.monotonic() # 立即熔断
mock_notify.assert_called_once()
assert mock_notify.call_args.args[0] == "codex-chatgpt"
tp.reset_cache()

View File

@@ -0,0 +1,231 @@
"""LLM 插件安装管线:签名下载 → 校验 → 解包 → 加载 → 解析(克隆 updater 模式)。
用测试 ed25519 密钥对 + 内存构造的假插件 zip + httpx.MockTransport + tmp_path,
覆盖安全路径:sha 不符、无签名、坏签名、zip-slip、版本不兼容、id 不匹配全部拒绝。
"""
import base64
import hashlib
import io
import json
import zipfile
import httpx
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
)
from module.llm_plugins.installer import PluginInstaller
PLUGIN_ID = "fake-copilot"
VERSION = "1.0.0"
ADAPTER_SOURCE = """
from module.parser.analyser.providers.base import (
LLMProviderAdapter, ProviderInfo,
)
class FakeCopilotAdapter(LLMProviderAdapter):
info = ProviderInfo(
id="fake-copilot",
display_name="Fake Copilot",
auth_kind="device_code",
builtin=False,
)
async def parse(self, raw):
return {"title_en": "ok", "title_zh": None, "title_jp": None,
"season": 1, "season_raw": "", "episode": 1,
"sub": "", "group": "", "resolution": "", "source": ""}
async def list_models(self):
return ["fake-model"]
"""
@pytest.fixture
def keypair(tmp_path):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
pubkey_path = tmp_path / "pub.pem"
pubkey_path.write_bytes(
pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
)
return priv, pubkey_path
def _make_plugin_zip(
manifest_extra: dict | None = None, member_name: str | None = None
) -> bytes:
manifest = {
"schema": 1,
"id": PLUGIN_ID,
"name": "Fake Copilot",
"version": VERSION,
"min_ab_version": "3.3.0",
"entry": "ab_fake_copilot.adapter:FakeCopilotAdapter",
"auth_kind": "device_code",
}
manifest.update(manifest_extra or {})
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("plugin.json", json.dumps(manifest))
adapter_path = member_name or "ab_fake_copilot/adapter.py"
zf.writestr(adapter_path, ADAPTER_SOURCE)
if member_name is None:
zf.writestr("ab_fake_copilot/__init__.py", "")
return buf.getvalue()
def _catalog(zip_bytes: bytes, priv, extra: dict | None = None) -> dict:
entry = {
"schema": 1,
"id": PLUGIN_ID,
"name": "Fake Copilot",
"version": VERSION,
"min_ab_version": "3.3.0",
"auth_kind": "device_code",
"asset": f"{PLUGIN_ID}-{VERSION}.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}
entry.update(extra or {})
return {"schema": 1, "plugins": [entry]}
def _sign(priv, data: bytes) -> str:
return base64.b64encode(priv.sign(data)).decode()
def _installer(tmp_path, keypair, zip_bytes, *, catalog=None, tamper_sig=False):
priv, pubkey_path = keypair
catalog = catalog if catalog is not None else _catalog(zip_bytes, priv)
catalog_bytes = json.dumps(catalog).encode()
sig = _sign(priv, zip_bytes)
if tamper_sig:
sig = base64.b64encode(b"\x00" * 64).decode()
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("catalog.json"):
return httpx.Response(200, content=catalog_bytes)
if url.endswith("catalog.json.sig"):
return httpx.Response(200, text=_sign(priv, catalog_bytes))
if url.endswith(".zip"):
return httpx.Response(200, content=zip_bytes)
if url.endswith(".zip.sig"):
return httpx.Response(200, text=sig)
if url.endswith(".zip.sha256"):
return httpx.Response(200, text=hashlib.sha256(zip_bytes).hexdigest())
return httpx.Response(404)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return PluginInstaller(
root=tmp_path / "plugins",
pubkey_path=pubkey_path,
client=client,
app_version="3.3.0",
)
class TestInstallHappyPath:
async def test_install_then_resolve_and_parse(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
zip_bytes = _make_plugin_zip()
installer = _installer(tmp_path, keypair, zip_bytes)
result = await installer.install(PLUGIN_ID)
assert result.success is True
assert result.version == VERSION
adapter_cls = registry.resolve(PLUGIN_ID)
assert adapter_cls.info.id == PLUGIN_ID
assert adapter_cls.info.auth_kind == "device_code"
# 装好的插件出现在列举里
assert PLUGIN_ID in {i.id for i in registry.list_infos()}
await installer.uninstall(PLUGIN_ID)
registry.invalidate()
class TestInstallRejections:
async def test_sha_mismatch_refused(self, tmp_path, keypair):
priv, _ = keypair
zip_bytes = _make_plugin_zip()
bad_catalog = _catalog(zip_bytes, priv, extra={"sha256": "deadbeef"})
installer = _installer(tmp_path, keypair, zip_bytes, catalog=bad_catalog)
result = await installer.install(PLUGIN_ID)
assert result.success is False
assert (
"sha256" in result.message.lower() or "checksum" in result.message.lower()
)
async def test_bad_signature_refused(self, tmp_path, keypair):
zip_bytes = _make_plugin_zip()
installer = _installer(tmp_path, keypair, zip_bytes, tamper_sig=True)
result = await installer.install(PLUGIN_ID)
assert result.success is False
assert "signature" in result.message.lower()
async def test_zip_slip_refused(self, tmp_path, keypair):
zip_bytes = _make_plugin_zip(member_name="../evil.py")
installer = _installer(tmp_path, keypair, zip_bytes)
result = await installer.install(PLUGIN_ID)
assert result.success is False
async def test_incompatible_min_version_refused(self, tmp_path, keypair):
priv, _ = keypair
zip_bytes = _make_plugin_zip(manifest_extra={"min_ab_version": "9.9.0"})
catalog = _catalog(zip_bytes, priv, extra={"min_ab_version": "9.9.0"})
installer = _installer(tmp_path, keypair, zip_bytes, catalog=catalog)
result = await installer.install(PLUGIN_ID)
assert result.success is False
assert "9.9.0" in result.message
async def test_id_mismatch_refused(self, tmp_path, keypair):
zip_bytes = _make_plugin_zip(manifest_extra={"id": "someone-else"})
installer = _installer(tmp_path, keypair, zip_bytes)
result = await installer.install(PLUGIN_ID)
assert result.success is False
async def test_unknown_plugin_id_refused(self, tmp_path, keypair):
zip_bytes = _make_plugin_zip()
installer = _installer(tmp_path, keypair, zip_bytes)
result = await installer.install("nonexistent-in-catalog")
assert result.success is False
async def test_uninstall_builtin_refused(self, tmp_path, keypair):
zip_bytes = _make_plugin_zip()
installer = _installer(tmp_path, keypair, zip_bytes)
result = await installer.uninstall("openai")
assert result.success is False
class TestUninstallClearsCredential:
async def test_uninstall_deletes_credential_row(self, tmp_path, keypair):
from unittest.mock import AsyncMock, patch
from module.parser.analyser.providers.registry import registry
zip_bytes = _make_plugin_zip()
installer = _installer(tmp_path, keypair, zip_bytes)
await installer.install(PLUGIN_ID)
db = AsyncMock()
def _cm(v):
cm = AsyncMock()
cm.__aenter__ = AsyncMock(return_value=v)
cm.__aexit__ = AsyncMock(return_value=False)
return cm
with patch("module.llm_plugins.installer.Database", return_value=_cm(db)):
await installer.uninstall(PLUGIN_ID)
db.llm_credential.delete.assert_awaited_once_with(PLUGIN_ID)
registry.invalidate()

View File

@@ -0,0 +1,386 @@
"""M6 灰色插件(github-copilot / codex-chatgpt):装载 + device flow + parse。
从 ``plugins/<id>/`` 真实源码打包 zip → 用测试密钥签名 → 走安装管线装载真实
适配器 → 用 MockTransport 模拟各家 HTTP 端点,验证 begin/complete/parse。
"""
import base64
import hashlib
import io
import json
import zipfile
from pathlib import Path
from unittest.mock import AsyncMock
import httpx
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from module.llm_plugins.installer import PluginInstaller
from module.parser.analyser.providers.base import AdapterContext, AuthExpiredError
PLUGINS_SRC = Path(__file__).resolve().parents[3] / "plugins"
@pytest.fixture
def keypair(tmp_path):
priv = Ed25519PrivateKey.generate()
pubkey_path = tmp_path / "pub.pem"
pubkey_path.write_bytes(
priv.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
)
return priv, pubkey_path
def _zip_plugin(plugin_id: str) -> bytes:
src = PLUGINS_SRC / plugin_id
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for path in sorted(src.rglob("*")):
if path.is_file() and "__pycache__" not in path.parts:
zf.write(path, path.relative_to(src).as_posix())
return buf.getvalue()
async def _install(tmp_path, keypair, plugin_id):
priv, pubkey_path = keypair
zip_bytes = _zip_plugin(plugin_id)
manifest = json.loads((PLUGINS_SRC / plugin_id / "plugin.json").read_text())
catalog = {
"schema": 1,
"plugins": [
{
"id": plugin_id,
"name": manifest["name"],
"version": manifest["version"],
"min_ab_version": manifest["min_ab_version"],
"auth_kind": manifest["auth_kind"],
"asset": f"{plugin_id}-{manifest['version']}.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}
],
}
catalog_bytes = json.dumps(catalog).encode()
def handler(request):
url = str(request.url)
if url.endswith("catalog.json"):
return httpx.Response(200, content=catalog_bytes)
if url.endswith("catalog.json.sig"):
return httpx.Response(
200, text=base64.b64encode(priv.sign(catalog_bytes)).decode()
)
if url.endswith(".zip"):
return httpx.Response(200, content=zip_bytes)
if url.endswith(".zip.sig"):
return httpx.Response(
200, text=base64.b64encode(priv.sign(zip_bytes)).decode()
)
return httpx.Response(404)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
installer = PluginInstaller(
root=tmp_path / "plugins",
pubkey_path=pubkey_path,
client=client,
app_version="3.3.0",
)
result = await installer.install(plugin_id)
assert result.success, result.message
return installer
def _ctx(handler, credentials=None, model=""):
"""构造注入 MockTransport-http-client 的 AdapterContext。"""
def build_client(_timeout):
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
return AdapterContext(
model=model,
build_http_client=build_client,
credentials=credentials or AsyncMock(),
)
# ---------------------------------------------------------------------------
# github-copilot
# ---------------------------------------------------------------------------
class TestGithubCopilot:
async def test_install_and_resolve(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "github-copilot")
cls = registry.resolve("github-copilot")
assert cls.info.id == "github-copilot"
assert cls.info.auth_kind == "device_code"
registry.invalidate()
async def test_begin_auth_returns_user_code(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "github-copilot")
cls = registry.resolve("github-copilot")
def handler(request):
assert "login/device/code" in str(request.url)
return httpx.Response(
200,
json={
"device_code": "DC-secret",
"user_code": "WXYZ-1234",
"verification_uri": "https://github.com/login/device",
"interval": 5,
"expires_in": 900,
},
)
adapter = cls(_ctx(handler))
challenge = await adapter.begin_auth()
assert challenge.user_code == "WXYZ-1234"
assert challenge.method == "device_code"
# device_code 藏在 state 里(不作为 user_code 下发)
assert "DC-secret" in challenge.state
assert "DC-secret" != challenge.user_code
await adapter.aclose()
registry.invalidate()
async def test_complete_auth_pending_raises(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "github-copilot")
cls = registry.resolve("github-copilot")
def handler(request):
return httpx.Response(200, json={"error": "authorization_pending"})
adapter = cls(_ctx(handler))
with pytest.raises(AuthExpiredError):
await adapter.complete_auth(json.dumps({"device_code": "DC"}))
await adapter.aclose()
registry.invalidate()
async def test_parse_uses_tool_call_and_copilot_token(self, tmp_path, keypair):
from module.parser.analyser.providers.base import TokenSet
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "github-copilot")
cls = registry.resolve("github-copilot")
episode = {
"title_en": "Test",
"title_zh": None,
"title_jp": None,
"season": 1,
"season_raw": "",
"episode": 5,
"sub": "CHS",
"group": "G",
"resolution": "1080p",
"source": "Web",
}
seen = {"copilot_token_fetched": False, "editor_header": False}
def handler(request):
url = str(request.url)
if "copilot_internal/v2/token" in url:
seen["copilot_token_fetched"] = True
assert request.headers["authorization"].startswith("token ghu_")
return httpx.Response(
200, json={"token": "cop_tok", "expires_at": 9e18}
)
if "chat/completions" in url:
seen["editor_header"] = "editor-version" in request.headers
assert request.headers["authorization"] == "Bearer cop_tok"
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"tool_calls": [
{"function": {"arguments": json.dumps(episode)}}
]
}
}
]
},
)
return httpx.Response(404)
creds = AsyncMock()
creds.load = AsyncMock(return_value=TokenSet(access_token="ghu_abc"))
adapter = cls(_ctx(handler, credentials=creds))
result = await adapter.parse("[G] Test - 05 [1080p]")
assert result == episode
assert seen["copilot_token_fetched"] is True
assert seen["editor_header"] is True
await adapter.aclose()
registry.invalidate()
async def test_parse_without_credentials_raises(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "github-copilot")
cls = registry.resolve("github-copilot")
creds = AsyncMock()
creds.load = AsyncMock(return_value=None)
adapter = cls(_ctx(lambda r: httpx.Response(404), credentials=creds))
with pytest.raises(AuthExpiredError):
await adapter.parse("raw")
await adapter.aclose()
registry.invalidate()
# ---------------------------------------------------------------------------
# codex-chatgpt
# ---------------------------------------------------------------------------
class TestCodexChatgpt:
async def test_install_and_resolve(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "codex-chatgpt")
cls = registry.resolve("codex-chatgpt")
assert cls.info.id == "codex-chatgpt"
registry.invalidate()
async def test_complete_auth_decodes_account_id(self, tmp_path, keypair):
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "codex-chatgpt")
cls = registry.resolve("codex-chatgpt")
# 构造带 chatgpt_account_id claim 的假 JWT
header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode().rstrip("=")
claims = (
base64.urlsafe_b64encode(
json.dumps({"chatgpt_account_id": "acc-42"}).encode()
)
.decode()
.rstrip("=")
)
fake_jwt = f"{header}.{claims}.sig"
def handler(request):
return httpx.Response(
200,
json={
"access_token": fake_jwt,
"refresh_token": "rt-1",
"expires_in": 3600,
},
)
adapter = cls(_ctx(handler))
tokens = await adapter.complete_auth(json.dumps({"device_code": "DC"}))
assert tokens.access_token == fake_jwt
assert tokens.extra["account_id"] == "acc-42"
assert tokens.refresh_token == "rt-1"
await adapter.aclose()
registry.invalidate()
async def test_parse_sends_originator_and_account_headers(self, tmp_path, keypair):
from module.parser.analyser.providers.base import TokenSet
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "codex-chatgpt")
cls = registry.resolve("codex-chatgpt")
episode = {
"title_en": "X",
"title_zh": None,
"title_jp": None,
"season": 1,
"season_raw": "",
"episode": 1,
"sub": "",
"group": "",
"resolution": "",
"source": "",
}
seen = {}
def handler(request):
seen["originator"] = request.headers.get("originator")
seen["account"] = request.headers.get("chatgpt-account-id")
return httpx.Response(
200,
json={"output": [{"content": [{"text": json.dumps(episode)}]}]},
)
creds = AsyncMock()
creds.load = AsyncMock(
return_value=TokenSet(
access_token="at", expires_at=9e18, extra={"account_id": "acc-42"}
)
)
adapter = cls(_ctx(handler, credentials=creds))
result = await adapter.parse("raw title")
assert result == episode
assert seen["originator"] == "codex_cli_rs"
assert seen["account"] == "acc-42"
await adapter.aclose()
registry.invalidate()
async def test_expired_token_triggers_refresh(self, tmp_path, keypair):
from module.parser.analyser.providers.base import TokenSet
from module.parser.analyser.providers.registry import registry
await _install(tmp_path, keypair, "codex-chatgpt")
cls = registry.resolve("codex-chatgpt")
refreshed_saved = {}
def handler(request):
url = str(request.url)
if url.endswith("oauth/token"):
return httpx.Response(
200, json={"access_token": "new-at", "expires_in": 3600}
)
return httpx.Response(
200,
json={
"output": [
{
"content": [
{
"text": json.dumps(
{
"title_en": None,
"title_zh": None,
"title_jp": None,
"season": 1,
"season_raw": "",
"episode": 1,
"sub": "",
"group": "",
"resolution": "",
"source": "",
}
)
}
]
}
]
},
)
creds = AsyncMock()
creds.load = AsyncMock(
return_value=TokenSet(
access_token="old", refresh_token="rt", expires_at=0.0
)
)
creds.save = AsyncMock(
side_effect=lambda t: refreshed_saved.update(at=t.access_token)
)
adapter = cls(_ctx(handler, credentials=creds))
await adapter.parse("raw")
# 过期 → 刷新 → 保存新 token
assert refreshed_saved.get("at") == "new-at"
await adapter.aclose()
registry.invalidate()

View File

@@ -0,0 +1,167 @@
"""国产提供商 base_url 预设 + OpenAI 兼容降级路径 + 按提供商配置覆盖。"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from module.api.config import _restore_masked, _sanitize_dict
from module.models.config import LLM, LLMProviderOverride
from module.parser.analyser.llm import LLMParser
from module.parser.analyser.providers import registry
from module.parser.analyser.providers.builtin import OpenAIAdapter
EPISODE_DICT = {
"title_en": "Test",
"title_zh": None,
"title_jp": None,
"season": 1,
"season_raw": "",
"episode": 5,
"sub": "CHS",
"group": "G",
"resolution": "1080p",
"source": "Web",
}
class TestPresetRegistry:
def test_presets_listed_with_endpoints(self):
infos = {i.id: i for i in registry.list_infos()}
assert infos["deepseek"].preset_base_url == "https://api.deepseek.com"
assert infos["zai"].preset_base_url == "https://api.z.ai/api/paas/v4"
assert (
infos["zai-cn"].preset_base_url
== "https://open.bigmodel.cn/api/coding/paas/v4"
)
assert infos["kimi"].preset_base_url == "https://api.moonshot.ai/v1"
assert infos["kimi-cn"].preset_base_url == "https://api.moonshot.cn/v1"
assert infos["minimax"].preset_base_url == "https://api.minimax.io/v1"
for preset_id in ("zai", "zai-cn", "minimax", "minimax-cn", "deepseek", "kimi"):
assert infos[preset_id].auth_kind == "api_key"
assert infos[preset_id].builtin is False
assert infos[preset_id].needs_base_url is True
def test_preset_resolves_to_openai_dialect_adapter(self):
adapter_cls = registry.resolve("kimi")
assert issubclass(adapter_cls, OpenAIAdapter)
assert adapter_cls.info.id == "kimi"
def test_preset_base_url_fills_when_config_blank(self):
parser = LLMParser(api_key="sk-test", provider="deepseek", model="m")
assert str(parser._openai_client.base_url).startswith(
"https://api.deepseek.com"
)
def test_explicit_base_url_overrides_preset(self):
parser = LLMParser(
api_key="sk-test",
provider="deepseek",
model="m",
base_url="http://localhost:11434/v1",
)
assert str(parser._openai_client.base_url).startswith(
"http://localhost:11434/v1"
)
class TestJsonObjectFallback:
"""json_schema 不受支持的预设(DeepSeek/MiniMax)降级为
json_object + schema 写入提示词。"""
def _completion(self, content: str):
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=content))]
)
async def test_deepseek_uses_json_object_mode(self):
parser = LLMParser(api_key="sk-test", provider="deepseek", model="m")
mock_create = AsyncMock(return_value=self._completion(json.dumps(EPISODE_DICT)))
parser._openai_client.chat.completions.create = mock_create
result = await parser.parse("[G] Test - 05 [1080p]")
assert result == EPISODE_DICT
kwargs = mock_create.call_args.kwargs
assert kwargs["response_format"] == {"type": "json_object"}
# schema 字段清单进入系统提示词
assert "title_en" in kwargs["messages"][0]["content"]
async def test_json_object_mode_invalid_json_returns_none(self):
parser = LLMParser(api_key="sk-test", provider="minimax", model="m")
parser._openai_client.chat.completions.create = AsyncMock(
return_value=self._completion("not json")
)
assert await parser.parse("raw") is None
async def test_openai_still_uses_beta_parse(self):
"""内置 openai 提供商不受降级影响,仍走 beta 结构化输出。"""
parser = LLMParser(api_key="sk-test", provider="openai", model="m")
mock_parse = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
parsed=SimpleNamespace(model_dump=lambda: EPISODE_DICT)
)
)
]
)
)
parser._openai_client.beta.chat.completions.parse = mock_parse
assert await parser.parse("raw") == EPISODE_DICT
mock_parse.assert_awaited_once()
class TestLLMEffective:
def test_effective_prefers_provider_override(self):
conf = LLM(
provider="kimi",
api_key="flat-key",
model="flat-model",
base_url="flat-url",
providers={
"kimi": LLMProviderOverride(
api_key="kimi-key", model="kimi-k2.5", base_url="https://k/v1"
)
},
)
assert conf.effective() == ("kimi-key", "kimi-k2.5", "https://k/v1")
def test_effective_falls_back_to_flat_fields(self):
conf = LLM(provider="openai", api_key="flat-key", model="m0", base_url="b0")
assert conf.effective() == ("flat-key", "m0", "b0")
def test_effective_blank_override_fields_stay_blank(self):
"""有覆盖项时不跨提供商回退到扁平 openai 字段:空模型保持空,
由适配器回退到本提供商默认(避免把 gpt-5-mini 发给 Kimi)。"""
conf = LLM(
provider="kimi",
api_key="flat-key",
model="flat-model",
base_url="flat-url",
providers={"kimi": LLMProviderOverride(api_key="kimi-key")},
)
api_key, model, base_url = conf.effective()
assert api_key == "kimi-key"
assert model == "" # 不再串到 flat-model
assert base_url == "" # 不再串到 flat-url
def test_blank_model_falls_back_to_provider_default(self):
"""空模型的预设适配器使用自己的 default_model,而非 openai 的默认。"""
parser = LLMParser(api_key="sk", provider="kimi", model="")
assert parser._adapter.model == "kimi-k2.5"
class TestNestedProviderMasking:
def test_sanitize_masks_nested_provider_api_key(self):
data = {"llm": {"providers": {"kimi": {"api_key": "sk-secret", "model": "m"}}}}
masked = _sanitize_dict(data)
assert masked["llm"]["providers"]["kimi"]["api_key"] == "********"
assert masked["llm"]["providers"]["kimi"]["model"] == "m"
def test_restore_swaps_mask_back_to_saved_value(self):
saved = {"llm": {"providers": {"kimi": {"api_key": "sk-secret"}}}}
incoming = {"llm": {"providers": {"kimi": {"api_key": "********"}}}}
_restore_masked(incoming, saved)
assert incoming["llm"]["providers"]["kimi"]["api_key"] == "sk-secret"

View File

@@ -0,0 +1,32 @@
"""ProviderRegistry — LLM 提供商注册表(内置适配器解析与列举)。"""
import pytest
from module.parser.analyser.providers import registry
from module.parser.analyser.providers.base import LLMProviderAdapter
class TestResolve:
def test_resolves_builtin_providers(self):
for provider_id in ("openai", "anthropic", "gemini"):
adapter_cls = registry.resolve(provider_id)
assert issubclass(adapter_cls, LLMProviderAdapter)
assert adapter_cls.info.id == provider_id
assert adapter_cls.info.builtin is True
def test_unknown_provider_raises_value_error(self):
with pytest.raises(ValueError, match="Unsupported LLM provider"):
registry.resolve("nonexistent")
class TestListInfos:
def test_lists_builtins_with_api_key_auth(self):
infos = {info.id: info for info in registry.list_infos()}
assert {"openai", "anthropic", "gemini"} <= set(infos)
for provider_id in ("openai", "anthropic", "gemini"):
assert infos[provider_id].auth_kind == "api_key"
def test_openai_needs_base_url(self):
infos = {info.id: info for info in registry.list_infos()}
assert infos["openai"].needs_base_url is True
assert infos["anthropic"].needs_base_url is False

View File

@@ -10,11 +10,20 @@ returned events into ``notifier.send_event`` calls, gated by
import asyncio
import time
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from module.core.loops import offset_scan_tick, rename_tick, rss_tick
from module.core.loops import (
offset_scan_tick,
rename_tick,
rss_tick,
update_check_tick,
)
from module.models.bangumi import Notification
from module.notification.events import OffsetReviewEvent, RssFailureEvent
from module.notification.events import (
OffsetReviewEvent,
RssFailureEvent,
UpdateAvailableEvent,
)
# ---------------------------------------------------------------------------
# offset_scan_tick
@@ -35,8 +44,9 @@ class TestOffsetScanTick:
notifier.send_event.assert_awaited_once_with(event)
async def test_no_events_sent_when_notifications_disabled(self):
"""settings.notification.enable=False suppresses all sends."""
async def test_events_forwarded_even_when_external_push_disabled(self):
"""事件总是送达 notifier:站内落库不受 enable 开关影响(外发 gating
在 NotificationManager.send_event 内部)。"""
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
notifier = AsyncMock()
@@ -46,7 +56,7 @@ class TestOffsetScanTick:
mock_settings.notification.enable = False
await offset_scan_tick(notifier)
notifier.send_event.assert_not_awaited()
notifier.send_event.assert_awaited_once_with(event)
async def test_no_events_flagged_sends_nothing(self):
"""An empty scan result sends no notifications."""
@@ -101,8 +111,8 @@ class TestRssTick:
notifier.send_event.assert_awaited_once_with(events[0])
async def test_no_events_sent_when_notifications_disabled(self):
"""settings.notification.enable=False suppresses all sends."""
async def test_events_forwarded_even_when_external_push_disabled(self):
"""事件总是送达 notifier(站内落库),外发 gating 在 manager 内部。"""
events = [RssFailureEvent(rss_name="Feed", rss_url="u", error="e")]
notifier = AsyncMock()
analyser = AsyncMock()
@@ -125,7 +135,7 @@ class TestRssTick:
mock_settings.bangumi_manage.eps_complete = False
await rss_tick(analyser, notifier)
notifier.send_event.assert_not_awaited()
notifier.send_event.assert_awaited_once_with(events[0])
# ---------------------------------------------------------------------------
@@ -211,3 +221,71 @@ class TestRenameTick:
# Serial would take ~0.25s (5 x 0.05s); concurrent should stay well
# under 2x a single call's latency.
assert elapsed < 0.15
# ---------------------------------------------------------------------------
# update_check_tick
# ---------------------------------------------------------------------------
class TestUpdateCheckTick:
def _result(self, **overrides):
result = MagicMock()
result.has_update = overrides.get("has_update", True)
result.error = overrides.get("error", None)
result.current = overrides.get("current", "3.3.0")
result.latest = overrides.get("latest", "3.3.1")
result.channel = overrides.get("channel", "beta")
result.notes = overrides.get("notes", "release notes")
return result
async def test_emits_event_when_update_available(self):
notifier = AsyncMock()
with (
patch(
"module.core.loops.updater.check_update",
new=AsyncMock(return_value=self._result()),
),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.update.channel = "beta"
await update_check_tick(notifier)
notifier.send_event.assert_awaited_once()
event = notifier.send_event.await_args.args[0]
assert isinstance(event, UpdateAvailableEvent)
assert event.latest == "3.3.1"
assert event.current == "3.3.0"
assert event.channel == "beta"
async def test_silent_when_no_update(self):
notifier = AsyncMock()
with (
patch(
"module.core.loops.updater.check_update",
new=AsyncMock(return_value=self._result(has_update=False)),
),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.update.channel = "stable"
await update_check_tick(notifier)
notifier.send_event.assert_not_awaited()
async def test_silent_on_check_error(self):
"""网络失败等 error 情况不产生通知(避免每天误报一条)。"""
notifier = AsyncMock()
with (
patch(
"module.core.loops.updater.check_update",
new=AsyncMock(return_value=self._result(error="rate limited")),
),
patch("module.core.loops.settings") as mock_settings,
):
mock_settings.update.channel = "stable"
await update_check_tick(notifier)
notifier.send_event.assert_not_awaited()

View File

@@ -23,7 +23,7 @@ class TestCreateApp:
app = main.create_app()
assert isinstance(app.state.ctx, AppContext)
names = [t.name for t in app.state.ctx.scheduler.tasks]
assert names == ["rss", "rename", "offset_scan", "calendar"]
assert names == ["rss", "rename", "offset_scan", "calendar", "update_check"]
class TestLifespan:

View File

@@ -207,6 +207,30 @@ class TestRunMigrations:
aria2_cols = {c["name"] for c in inspector.get_columns("aria2_gid")}
assert "renamed_paths" in aria2_cols
def test_creates_inboxmessage_table(self):
"""v16 creates the in-app notification center table with its indexes."""
engine = _make_v0_engine()
run_migrations(engine)
inspector = inspect(engine)
assert "inboxmessage" in inspector.get_table_names()
inbox_indexes = {ix["name"] for ix in inspector.get_indexes("inboxmessage")}
assert {
"ix_inboxmessage_kind",
"ix_inboxmessage_dedup_key",
"ix_inboxmessage_read",
} <= inbox_indexes
def test_creates_llmcredential_table(self):
"""v17 creates the subscription-LLM credential table (unique provider_id)."""
engine = _make_v0_engine()
run_migrations(engine)
inspector = inspect(engine)
assert "llmcredential" in inspector.get_table_names()
indexes = {ix["name"]: ix for ix in inspector.get_indexes("llmcredential")}
assert indexes["ix_llmcredential_provider_id"]["unique"]
def test_is_idempotent(self):
engine = _make_v0_engine()
run_migrations(engine)

View File

@@ -8,7 +8,14 @@ from module.models import Notification
from module.models.config import NotificationProvider as ProviderConfig
from module.network import RequestContent
from module.notification import PROVIDER_REGISTRY, NotificationManager
from module.notification.events import OffsetReviewEvent, RssFailureEvent
from module.notification.events import (
DownloaderUnavailableEvent,
DownloadFailureEvent,
OffsetReviewEvent,
RssFailureEvent,
UpdateAppliedEvent,
UpdateAvailableEvent,
)
from module.notification.providers import (
BarkProvider,
DiscordProvider,
@@ -448,7 +455,12 @@ class TestSendEvent:
provider.__aexit__ = AsyncMock(return_value=None)
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
await manager.send_event(event)
with (
patch("module.notification.manager.record_event", new_callable=AsyncMock),
patch("module.notification.manager.settings") as mock_settings,
):
mock_settings.notification.enable = True
await manager.send_event(event)
for provider in manager.providers:
provider.send_event.assert_called_once_with( # type: ignore[attr-defined]
@@ -456,6 +468,80 @@ class TestSendEvent:
)
class TestEventInboxContract:
"""每个 SystemEvent 提供 kind/severity/once/dedup_key()/payload(),供通知中心持久化。"""
def test_rss_failure_event_contract(self):
event = RssFailureEvent(rss_name="Feed", rss_url="http://x/rss", error="boom")
assert event.kind == "rss_failure"
assert event.severity == "error"
assert event.once is False
assert event.dedup_key() == "rss_failure:http://x/rss"
assert event.payload() == {
"rss_name": "Feed",
"rss_url": "http://x/rss",
"error": "boom",
}
def test_download_failure_event_contract(self):
event = DownloadFailureEvent(official_title="Anime", torrent_name="t.torrent")
assert event.kind == "download_failure"
assert event.severity == "error"
assert event.dedup_key() == "download_failure:Anime"
assert event.payload() == {
"official_title": "Anime",
"torrent_name": "t.torrent",
}
def test_offset_review_event_contract(self):
event = OffsetReviewEvent(official_title="Anime", reason="mismatch")
assert event.kind == "offset_review"
assert event.severity == "warning"
assert event.dedup_key() == "offset_review:Anime"
assert event.payload() == {"official_title": "Anime", "reason": "mismatch"}
def test_downloader_unavailable_event_contract(self):
event = DownloaderUnavailableEvent(host="http://qb:8080", reason="credentials")
assert event.kind == "downloader_unavailable"
assert event.severity == "error"
assert event.once is False
# reason 不进 dedup_key:unreachable→credentials 的翻转合并为一条
assert event.dedup_key() == "downloader:http://qb:8080"
assert event.payload() == {"host": "http://qb:8080", "reason": "credentials"}
title, body = event.describe()
assert "下载器" in title
assert "密码" in body
def test_update_available_event_contract(self):
event = UpdateAvailableEvent(current="3.3.0", latest="3.3.1", channel="beta")
assert event.kind == "update_available"
assert event.severity == "info"
assert event.once is True # 每个版本只提醒一次,已读后不再重建
assert event.dedup_key() == "update_available:3.3.1"
assert event.payload() == {
"current": "3.3.0",
"latest": "3.3.1",
"channel": "beta",
"notes": "",
}
title, body = event.describe()
assert "3.3.1" in body
def test_update_applied_event_kind_follows_success(self):
ok = UpdateAppliedEvent(version="3.3.1", success=True)
assert ok.kind == "update_applied"
assert ok.severity == "info"
assert ok.dedup_key() is None
assert ok.payload() == {"version": "3.3.1", "message": ""}
failed = UpdateAppliedEvent(version="3.3.1", success=False, message="sig error")
assert failed.kind == "update_failed"
assert failed.severity == "error"
title, body = failed.describe()
assert "失败" in title
assert "sig error" in body
class TestDeliverText:
"""Each provider's own ``_deliver_text`` hook, exercised directly."""

View File

@@ -0,0 +1,121 @@
"""通知中心持久化 sink:record_event / send_event 的 persist-first 语义。"""
from unittest.mock import AsyncMock, MagicMock, patch
from module.notification.events import RssFailureEvent, UpdateAvailableEvent
from module.notification.inbox import (
bump_inbox_revision,
inbox_revision,
record_event,
)
from module.notification.manager import NotificationManager
def _async_cm(entered_value):
cm = AsyncMock()
cm.__aenter__ = AsyncMock(return_value=entered_value)
cm.__aexit__ = AsyncMock(return_value=False)
return cm
def _mock_db(upsert_return):
db = MagicMock()
db.inbox.upsert = AsyncMock(return_value=upsert_return)
return db
EVENT = RssFailureEvent(rss_name="Feed", rss_url="http://x/rss", error="boom")
class TestRecordEvent:
async def test_record_event_upserts_and_bumps_revision(self):
row = MagicMock(id=7)
db = _mock_db(row)
before = inbox_revision()
with patch("module.notification.inbox.Database", return_value=_async_cm(db)):
result = await record_event(EVENT)
assert result == 7
assert inbox_revision() == before + 1
kwargs = db.inbox.upsert.call_args.kwargs
assert kwargs["kind"] == "rss_failure"
assert kwargs["severity"] == "error"
assert kwargs["dedup_key"] == "rss_failure:http://x/rss"
assert kwargs["once"] is False
assert "boom" in kwargs["payload"]
assert kwargs["title"] == "RSS 订阅连接异常"
async def test_record_event_once_skip_returns_zero_without_bump(self):
db = _mock_db(None) # upsert 返回 None = once 去重跳过
before = inbox_revision()
event = UpdateAvailableEvent(current="3.3.0", latest="3.3.1", channel="beta")
with patch("module.notification.inbox.Database", return_value=_async_cm(db)):
result = await record_event(event)
assert result == 0
assert inbox_revision() == before
def test_bump_inbox_revision_increments(self):
before = inbox_revision()
bump_inbox_revision()
assert inbox_revision() == before + 1
class TestSendEventPersistFirst:
def _manager_with_provider(self):
with patch("module.notification.manager.settings") as mock_settings:
mock_settings.notification.providers = []
manager = NotificationManager()
provider = AsyncMock()
provider.send_event = AsyncMock(return_value=True)
provider.__aenter__ = AsyncMock(return_value=provider)
provider.__aexit__ = AsyncMock(return_value=None)
manager.providers = [provider]
return manager, provider
async def test_persists_but_does_not_broadcast_when_disabled(self):
manager, provider = self._manager_with_provider()
with (
patch(
"module.notification.manager.record_event", new_callable=AsyncMock
) as mock_record,
patch("module.notification.manager.settings") as mock_settings,
):
mock_settings.notification.enable = False
await manager.send_event(EVENT)
mock_record.assert_awaited_once_with(EVENT)
provider.send_event.assert_not_awaited()
async def test_persists_and_broadcasts_when_enabled(self):
manager, provider = self._manager_with_provider()
with (
patch(
"module.notification.manager.record_event", new_callable=AsyncMock
) as mock_record,
patch("module.notification.manager.settings") as mock_settings,
):
mock_settings.notification.enable = True
await manager.send_event(EVENT)
mock_record.assert_awaited_once_with(EVENT)
provider.send_event.assert_awaited_once_with(EVENT)
async def test_persist_failure_does_not_block_broadcast(self):
manager, provider = self._manager_with_provider()
with (
patch(
"module.notification.manager.record_event", new_callable=AsyncMock
) as mock_record,
patch("module.notification.manager.settings") as mock_settings,
):
mock_record.side_effect = RuntimeError("db locked")
mock_settings.notification.enable = True
await manager.send_event(EVENT)
provider.send_event.assert_awaited_once_with(EVENT)

View File

@@ -586,6 +586,134 @@ class TestAuthQb52Compat:
assert result is False
async def test_auth_sets_last_auth_error_credentials_on_fails_body(self):
"""密码错误后 last_auth_error 标记为 credentials,供上层区分故障类型。"""
qb = QbDownloader(
host="localhost:8080", username="admin", password="wrong", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "Fails."
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
await qb.auth(retry=3)
assert qb.last_auth_error == "credentials"
async def test_auth_sets_last_auth_error_banned_on_403(self):
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 403
mock_resp.text = "Forbidden"
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
await qb.auth(retry=3)
assert qb.last_auth_error == "banned"
async def test_auth_sets_last_auth_error_unreachable_on_connect_error(self):
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
with (
patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
),
patch(
"module.downloader.client.qb_downloader.asyncio.sleep",
new_callable=AsyncMock,
),
):
await qb.auth(retry=1)
assert qb.last_auth_error == "unreachable"
async def test_auth_clears_last_auth_error_on_success(self):
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
qb.last_auth_error = "credentials"
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "Ok."
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
result = await qb.auth()
assert result is True
assert qb.last_auth_error is None
async def test_auth_stops_immediately_on_200_fails_body(self, caplog):
"""Wrong credentials (200 + 'Fails.') must not be retried — repeated
failed logins trigger qBittorrent's WebUI IP ban — and the log must
say the credentials are wrong."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="wrong", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "Fails."
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
result = await qb.auth(retry=3)
assert result is False
assert mock_client.post.call_count == 1
assert "username or password" in caplog.text
async def test_auth_stops_immediately_on_401(self, caplog):
"""401 also means bad credentials; same no-retry behaviour."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="wrong", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.text = ""
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
result = await qb.auth(retry=3)
assert result is False
assert mock_client.post.call_count == 1
assert "username or password" in caplog.text
async def test_auth_returns_false_on_200_html_body(self):
"""A 200 + HTML page (proxy, wrong service) is not a successful login."""
qb = QbDownloader(

View File

@@ -13,7 +13,7 @@ import json
import sqlite3
import zipfile
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
@@ -803,6 +803,10 @@ class TestBootOverlay:
def app():
app = FastAPI()
app.include_router(v1, prefix="/api")
# apply/rollback 通过 ctx.notifier 发更新结果通知
ctx = MagicMock()
ctx.notifier.send_event = AsyncMock()
app.state.ctx = ctx
return app
@@ -879,6 +883,43 @@ class TestUpdateApiContract:
assert resp.status_code == 200
sched.assert_called_once()
def test_apply_emits_update_applied_event(self, authed_client, app):
from module.notification import UpdateAppliedEvent
from module.update.updater import ApplyResult
async def fake_apply(channel="stable"):
return ApplyResult(
success=True, version="3.3.0", restart_required=True, message="ok"
)
with (
patch("module.api.update.updater.apply_update", side_effect=fake_apply),
patch("module.api.update.schedule_restart"),
):
authed_client.post("/api/v1/update/apply")
app.state.ctx.notifier.send_event.assert_awaited_once()
event = app.state.ctx.notifier.send_event.await_args.args[0]
assert isinstance(event, UpdateAppliedEvent)
assert event.kind == "update_applied"
assert event.version == "3.3.0"
def test_apply_failure_emits_update_failed_event(self, authed_client, app):
from module.update.updater import ApplyResult
async def fake_apply(channel="stable"):
return ApplyResult(success=False, message="signature mismatch")
with (
patch("module.api.update.updater.apply_update", side_effect=fake_apply),
patch("module.api.update.schedule_restart"),
):
authed_client.post("/api/v1/update/apply")
event = app.state.ctx.notifier.send_event.await_args.args[0]
assert event.kind == "update_failed"
assert "signature mismatch" in event.message
def test_apply_failure_returns_400(self, authed_client):
from module.update.updater import ApplyResult

View File

@@ -0,0 +1,198 @@
"""ChatGPT (Codex) 订阅适配器(device flow → codex responses 端点)。
⚠️ 自担风险:ChatGPT 订阅凭据仅供官方 Codex 客户端使用,token 被限定到
Codex client。第三方使用未获官方书面许可(灰色默许),originator 白名单
是其执法手段,可能收紧。
⚠️ 待核实(研究标注中/低置信度):device code 端点 URL、responses 端点的
精确请求头大小写。发货前须对 openai/codex 源码复核;下方以结构化占位实现,
便于单元测试与后续替换。
流程:
1. device flow:POST auth.openai.com/oauth/device/code → user_code;
服务端轮询 oauth/token 换 access_token(JWT,含 chatgpt_account_id)。
2. 刷新:refresh_token 换新 access_token(临期主动刷新)。
3. 推理:POST chatgpt.com/backend-api/codex/responses(Responses API),
头带 Bearer + ChatGPT-Account-Id + originator=codex_cli_rs。
"""
import base64
import json
import time
from module.parser.analyser.providers.base import (
AdapterContext,
AuthChallenge,
AuthExpiredError,
LLMProviderAdapter,
ProviderInfo,
TokenSet,
)
from module.parser.analyser.providers.schema import DEFAULT_PROMPT, EPISODE_JSON_SCHEMA
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # Codex CLI 公开应用
# TODO(ship): 对 openai/codex 源码复核以下端点/头
DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code"
TOKEN_URL = "https://auth.openai.com/oauth/token"
RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_MODEL = "gpt-5.1-codex"
_ORIGINATOR = "codex_cli_rs"
def _decode_account_id(access_token: str) -> str:
"""从 JWT 的 chatgpt_account_id claim 取账号 id(best-effort,失败返回空)。"""
try:
payload_b64 = access_token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
return claims.get("chatgpt_account_id", "") or claims.get(
"https://api.openai.com/auth", {}
).get("chatgpt_account_id", "")
except Exception:
return ""
class CodexChatgptAdapter(LLMProviderAdapter):
info = ProviderInfo(
id="codex-chatgpt",
display_name="ChatGPT (Codex)",
auth_kind="device_code",
builtin=False,
default_model=DEFAULT_MODEL,
)
def __init__(self, ctx: AdapterContext) -> None:
super().__init__(ctx)
self.model = ctx.model or DEFAULT_MODEL
self._http_client = ctx.build_http_client(ctx.timeout)
# ------------------------------------------------------------ device flow
async def begin_auth(self) -> AuthChallenge:
resp = await self._http_client.post(
DEVICE_CODE_URL,
data={"client_id": CLIENT_ID, "scope": "openid profile email"},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
state = json.dumps(
{"device_code": data["device_code"], "interval": data.get("interval", 5)}
)
return AuthChallenge(
method="device_code",
user_code=data["user_code"],
verification_uri=data.get("verification_uri_complete")
or data.get("verification_uri"),
expires_in=data.get("expires_in", 900),
state=state,
)
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
blob = json.loads(state)
resp = await self._http_client.post(
TOKEN_URL,
data={
"client_id": CLIENT_ID,
"device_code": blob["device_code"],
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
if "access_token" not in data:
raise AuthExpiredError(data.get("error", "authorization_pending"))
return self._tokens_from_response(data)
def _tokens_from_response(self, data: dict) -> TokenSet:
access = data["access_token"]
account_id = _decode_account_id(access)
expires_at = time.time() + float(data.get("expires_in", 3600))
return TokenSet(
access_token=access,
refresh_token=data.get("refresh_token", ""),
expires_at=expires_at,
account_label="ChatGPT",
extra={"account_id": account_id},
)
async def refresh(self, tokens: TokenSet) -> TokenSet:
if not tokens.refresh_token:
raise AuthExpiredError("no refresh token")
resp = await self._http_client.post(
TOKEN_URL,
data={
"client_id": CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": tokens.refresh_token,
},
headers={"Accept": "application/json"},
)
if resp.status_code >= 400:
raise AuthExpiredError("refresh failed")
data = resp.json()
refreshed = self._tokens_from_response(data)
# 有些实现刷新响应不回 refresh_token,沿用旧的
if not refreshed.refresh_token:
refreshed = refreshed.model_copy(
update={"refresh_token": tokens.refresh_token}
)
return refreshed
# ------------------------------------------------------------ inference
async def _valid_tokens(self) -> TokenSet:
tokens = await self.ctx.credentials.load()
if tokens is None or not tokens.access_token:
raise AuthExpiredError("ChatGPT is not connected")
if tokens.expires_at is not None and tokens.expires_at - 60 < time.time():
tokens = await self.refresh(tokens)
await self.ctx.credentials.save(tokens)
return tokens
async def parse(self, raw: str) -> dict | None:
tokens = await self._valid_tokens()
resp = await self._http_client.post(
RESPONSES_URL,
headers={
"Authorization": f"Bearer {tokens.access_token}",
"ChatGPT-Account-Id": tokens.extra.get("account_id", ""),
"originator": _ORIGINATOR,
"User-Agent": _ORIGINATOR,
"Content-Type": "application/json",
},
json={
"model": self.model,
"instructions": DEFAULT_PROMPT,
"input": raw,
"text": {
"format": {
"type": "json_schema",
"name": "episode",
"schema": EPISODE_JSON_SCHEMA,
}
},
},
)
if resp.status_code == 401:
raise AuthExpiredError("ChatGPT token rejected")
resp.raise_for_status()
return self._extract_json(resp.json())
def _extract_json(self, data: dict) -> dict | None:
# Responses API:output[].content[].text 承载 JSON 字符串
try:
for item in data.get("output", []):
for block in item.get("content", []):
text = block.get("text")
if text:
return json.loads(text)
except (json.JSONDecodeError, AttributeError):
return None
return None
async def list_models(self) -> list[str]:
# Codex 后端不暴露稳定的 /models;返回已知默认,交由用户自定义
return [DEFAULT_MODEL, "gpt-5.1"]

View File

@@ -0,0 +1,15 @@
{
"schema": 1,
"id": "codex-chatgpt",
"name": "ChatGPT (Codex)",
"version": "1.0.0",
"min_ab_version": "3.3.0",
"entry": "ab_llm_codex.adapter:CodexChatgptAdapter",
"auth_kind": "device_code",
"needs_base_url": false,
"default_model": "gpt-5.1-codex",
"description_en": "Use a ChatGPT Plus/Pro subscription for title parsing via the Codex device-flow login.",
"description_zh": "通过 Codex device flow 登录,用 ChatGPT Plus/Pro 订阅解析标题。",
"risk_notice_en": "ChatGPT subscription credentials are scoped to the official Codex client; third-party use is unsanctioned and may be blocked. Use at your own risk.",
"risk_notice_zh": "ChatGPT 订阅凭据被限定到官方 Codex 客户端,第三方使用未获许可、可能被封锁。请自担风险。"
}

View File

@@ -0,0 +1,185 @@
"""GitHub Copilot 订阅适配器(device flow → OpenAI 兼容端点)。
⚠️ 自担风险:Copilot 内部端点仅供官方客户端使用,第三方调用可能违反
GitHub 条款并触发滥用检测。低请求量降低但不消除风险。
流程(2026-07 核实):
1. device flow:POST github.com/login/device/code → 展示 user_code;
服务端轮询 login/oauth/access_token 换取长期 ``ghu_`` token。
2. 每次调用前用 ``ghu_`` 换短命 Copilot token(~30min,主动刷新):
GET api.github.com/copilot_internal/v2/token。
3. 推理走 api.githubcopilot.com/chat/completions(OpenAI 兼容),
仿编辑器请求头。结构化输出用 tool-calling 兜底。
"""
import json
import time
from module.parser.analyser.providers.base import (
AdapterContext,
AuthChallenge,
AuthExpiredError,
LLMProviderAdapter,
ProviderInfo,
TokenSet,
)
from module.parser.analyser.providers.schema import DEFAULT_PROMPT, EPISODE_JSON_SCHEMA
CLIENT_ID = "Iv1.b507a08c87ecfe98" # VS Code Copilot 公开应用
DEVICE_CODE_URL = "https://github.com/login/device/code"
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
API_BASE = "https://api.githubcopilot.com"
DEFAULT_MODEL = "gpt-4o"
# 仿 VS Code Copilot 客户端的请求头(值会随版本漂移,保持可配置)。
_EDITOR_HEADERS = {
"Editor-Version": "vscode/1.95.0",
"Editor-Plugin-Version": "copilot-chat/0.22.0",
"Copilot-Integration-Id": "vscode-chat",
"User-Agent": "GithubCopilot/1.155.0",
}
_EPISODE_TOOL = {
"type": "function",
"function": {
"name": "emit_episode",
"description": "Return the structured anime episode fields.",
"parameters": EPISODE_JSON_SCHEMA,
},
}
class GithubCopilotAdapter(LLMProviderAdapter):
info = ProviderInfo(
id="github-copilot",
display_name="GitHub Copilot",
auth_kind="device_code",
builtin=False,
default_model=DEFAULT_MODEL,
)
def __init__(self, ctx: AdapterContext) -> None:
super().__init__(ctx)
self.model = ctx.model or DEFAULT_MODEL
self._http_client = ctx.build_http_client(ctx.timeout)
# (copilot_token, expires_at) 短命缓存
self._copilot_token: tuple[str, float] | None = None
# ------------------------------------------------------------ device flow
async def begin_auth(self) -> AuthChallenge:
resp = await self._http_client.post(
DEVICE_CODE_URL,
data={"client_id": CLIENT_ID, "scope": "read:user"},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
# device_code 是敏感态,藏进 state(服务端 pending 存储,不下发前端)
state = json.dumps(
{"device_code": data["device_code"], "interval": data.get("interval", 5)}
)
return AuthChallenge(
method="device_code",
user_code=data["user_code"],
verification_uri=data["verification_uri"],
expires_in=data.get("expires_in", 900),
state=state,
)
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
"""轮询一次 access_token 端点(服务端轮询循环每次调用一次本方法)。"""
blob = json.loads(state)
resp = await self._http_client.post(
ACCESS_TOKEN_URL,
data={
"client_id": CLIENT_ID,
"device_code": blob["device_code"],
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
if "access_token" not in data:
# authorization_pending / slow_down:告知调用方继续轮询
raise AuthExpiredError(data.get("error", "authorization_pending"))
return TokenSet(
access_token=data["access_token"], # ghu_ 长期 token
account_label="GitHub Copilot",
)
async def refresh(self, tokens: TokenSet) -> TokenSet:
# ghu_ token 本身长期有效;短命 Copilot token 在 _ensure_copilot_token 刷新
return tokens
# ------------------------------------------------------------ inference
async def _ensure_copilot_token(self, ghu_token: str) -> str:
now = time.time()
if self._copilot_token is not None and self._copilot_token[1] - 60 > now:
return self._copilot_token[0]
resp = await self._http_client.get(
COPILOT_TOKEN_URL,
headers={
"Authorization": f"token {ghu_token}",
"Accept": "application/json",
},
)
resp.raise_for_status()
data = resp.json()
token = data["token"]
expires_at = float(data.get("expires_at", now + 1500))
self._copilot_token = (token, expires_at)
return token
async def parse(self, raw: str) -> dict | None:
tokens = await self.ctx.credentials.load()
if tokens is None or not tokens.access_token:
raise AuthExpiredError("GitHub Copilot is not connected")
copilot_token = await self._ensure_copilot_token(tokens.access_token)
resp = await self._http_client.post(
f"{API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {copilot_token}",
"Content-Type": "application/json",
**_EDITOR_HEADERS,
},
json={
"model": self.model,
"messages": [
{"role": "system", "content": DEFAULT_PROMPT},
{"role": "user", "content": raw},
],
"tools": [_EPISODE_TOOL],
"tool_choice": {
"type": "function",
"function": {"name": "emit_episode"},
},
"temperature": 0,
},
)
if resp.status_code == 401:
self._copilot_token = None
raise AuthExpiredError("Copilot token rejected")
resp.raise_for_status()
data = resp.json()
try:
call = data["choices"][0]["message"]["tool_calls"][0]
return json.loads(call["function"]["arguments"])
except (KeyError, IndexError, json.JSONDecodeError):
return None
async def list_models(self) -> list[str]:
tokens = await self.ctx.credentials.load()
if tokens is None or not tokens.access_token:
raise AuthExpiredError("GitHub Copilot is not connected")
copilot_token = await self._ensure_copilot_token(tokens.access_token)
resp = await self._http_client.get(
f"{API_BASE}/models",
headers={"Authorization": f"Bearer {copilot_token}", **_EDITOR_HEADERS},
)
resp.raise_for_status()
data = resp.json()
return sorted(m["id"] for m in data.get("data", []))

View File

@@ -0,0 +1,15 @@
{
"schema": 1,
"id": "github-copilot",
"name": "GitHub Copilot",
"version": "1.0.0",
"min_ab_version": "3.3.0",
"entry": "ab_llm_copilot.adapter:GithubCopilotAdapter",
"auth_kind": "device_code",
"needs_base_url": false,
"default_model": "gpt-4o",
"description_en": "Use a GitHub Copilot subscription for title parsing via the device-flow login.",
"description_zh": "通过 device flow 登录,用 GitHub Copilot 订阅解析标题。",
"risk_notice_en": "Copilot's internal endpoint is for official clients only; third-party use may violate GitHub's terms and trigger abuse detection. Use at your own risk.",
"risk_notice_zh": "Copilot 内部端点仅供官方客户端使用,第三方调用可能违反 GitHub 条款并触发滥用检测。请自担风险。"
}

View File

@@ -1,5 +1,4 @@
import type { Config, LLMProvider } from '#/config';
import type { TupleToUnion } from '#/utils';
import type { Config, LLMProviderId } from '#/config';
import type { ApiSuccess } from '#/api';
export const apiConfig = {
@@ -28,7 +27,7 @@ export const apiConfig = {
* (api_key 传掩码时后端回退到已保存的密钥)
*/
async getLLMModels(payload: {
provider: TupleToUnion<LLMProvider>;
provider: LLMProviderId;
api_key: string;
base_url: string;
}) {

78
webui/src/api/llm.ts Normal file
View File

@@ -0,0 +1,78 @@
export type LLMAuthKind = 'api_key' | 'oauth' | 'device_code';
export interface LLMProviderView {
id: string;
display_name: string;
auth_kind: LLMAuthKind;
builtin: boolean;
needs_base_url: boolean;
preset_base_url: string;
default_model: string;
plugin_version: string | null;
connected: boolean;
account_label: string;
expires_at: number | null;
}
export interface LLMAuthChallenge {
method: 'redirect_paste' | 'device_code';
authorize_url: string | null;
user_code: string | null;
verification_uri: string | null;
expires_in: number;
state: string;
}
export interface LLMAuthStatus {
connected: boolean;
account_label: string;
expires_at: number | null;
}
export const apiLLM = {
async getProviders() {
const { data } = await axios.get<{ providers: LLMProviderView[] }>(
'api/v1/config/llm/providers',
{ silent: true }
);
return data!.providers;
},
async installProvider(id: string) {
const { data } = await axios.post<{ success: boolean; version: string }>(
`api/v1/config/llm/providers/${id}/install`
);
return data!;
},
async uninstallProvider(id: string) {
await axios.delete(`api/v1/config/llm/providers/${id}`);
},
async beginAuth(id: string) {
const { data } = await axios.post<LLMAuthChallenge>(
`api/v1/config/llm/providers/${id}/auth/begin`
);
return data!;
},
async completeAuth(id: string, payload: { state: string; code?: string }) {
const { data } = await axios.post<{
connected: boolean;
account_label: string;
}>(`api/v1/config/llm/providers/${id}/auth/complete`, payload);
return data!;
},
async getAuthStatus(id: string) {
const { data } = await axios.get<LLMAuthStatus>(
`api/v1/config/llm/providers/${id}/auth/status`,
{ silent: true }
);
return data!;
},
async disconnect(id: string) {
await axios.delete(`api/v1/config/llm/providers/${id}/auth`);
},
};

View File

@@ -26,6 +26,33 @@ export interface TestResponse {
message_en: string;
}
export type InboxSeverity = 'info' | 'warning' | 'error';
export interface InboxMessage {
id: number;
kind: string;
severity: InboxSeverity;
title: string;
body: string;
payload: Record<string, string | number> | null;
read: boolean;
count: number;
created_at: string;
updated_at: string;
}
export interface InboxListResponse {
messages: InboxMessage[];
total: number;
unread_count: number;
}
export interface InboxListParams {
unread_only?: boolean;
limit?: number;
offset?: number;
}
export const apiNotification = {
/**
* Test a configured provider by index
@@ -48,4 +75,38 @@ export const apiNotification = {
);
return { data };
},
/** 收件箱:分页列表(后台刷新,静默失败) */
async getMessages(params: InboxListParams = {}) {
const { data } = await axios.get<InboxListResponse>(
'api/v1/notification/messages',
{ params, silent: true }
);
return data!;
},
/** 收件箱:未读数(SSE 断开时的轮询回退) */
async getUnreadCount() {
const { data } = await axios.get<{ unread_count: number }>(
'api/v1/notification/messages/unread-count',
{ silent: true }
);
return data!.unread_count;
},
async markRead(id: number) {
await axios.post(`api/v1/notification/messages/${id}/read`);
},
async markAllRead() {
await axios.post('api/v1/notification/messages/read-all');
},
async removeMessage(id: number) {
await axios.delete(`api/v1/notification/messages/${id}`);
},
async clearAll() {
await axios.delete('api/v1/notification/messages');
},
};

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { AddOne, International, System } from '@icon-park/vue-next';
import { AddOne, System, Translate } from '@icon-park/vue-next';
withDefaults(
defineProps<{
@@ -40,7 +40,7 @@ function abLabel(label: string | (() => string)) {
aria-label="Switch language"
@click="() => $emit('changeLang')"
>
<International theme="outline" size="1em" />
<Translate theme="outline" size="1em" />
</button>
<button
@@ -86,11 +86,6 @@ function abLabel(label: string | (() => string)) {
align-items: center;
gap: 2px;
font-size: 18px;
@include forTablet {
gap: 6px;
font-size: 20px;
}
}
.status-bar-btn {
@@ -110,17 +105,12 @@ function abLabel(label: string | (() => string)) {
border-radius: var(--radius-sm);
// Transparent background — a full 44px hit area costs nothing visually.
// 仅触屏放大命中区域;桌面指针设备保持 36px 紧凑排布。
@include forTouch {
min-width: var(--touch-target);
min-height: var(--touch-target);
}
@include forTablet {
min-width: var(--touch-target);
min-height: var(--touch-target);
padding: 8px;
}
&:hover {
color: var(--color-primary);
transform: scale(1.1);

View File

@@ -0,0 +1,440 @@
<script lang="ts" setup>
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue';
import { Attention, CloseOne, Delete, Info, Remind } from '@icon-park/vue-next';
import type { InboxMessage } from '@/api/notification';
const { t } = useMyI18n();
const router = useRouter();
const store = useNotificationStore();
const { messages, unreadCount, panelOpen } = storeToRefs(store);
// 点条目跳转的目标页面;未列出的 kind 不跳转
const KIND_ROUTES: Record<string, string> = {
update_available: '/config',
update_applied: '/config',
update_failed: '/config',
downloader_unavailable: '/config',
rss_failure: '/rss',
offset_review: '/bangumi',
download_failure: '/bangumi',
};
const SEVERITY_ICONS = {
error: CloseOne,
warning: Attention,
info: Info,
} as const;
const badgeText = computed(() =>
unreadCount.value > 99 ? '99+' : String(unreadCount.value)
);
const confirmClear = ref(false);
// PopoverPanel 挂载/卸载即打开/关闭:打开时拉取列表,关闭时复位确认态。
function onPanelRef(el: unknown) {
const isOpen = !!el;
if (isOpen && !panelOpen.value) {
store.fetchMessages();
}
if (!isOpen) {
confirmClear.value = false;
}
panelOpen.value = isOpen;
}
function severityIcon(msg: InboxMessage) {
return SEVERITY_ICONS[msg.severity] ?? Info;
}
function onItemClick(msg: InboxMessage, close: () => void) {
store.markRead(msg.id);
const route = KIND_ROUTES[msg.kind];
if (route) {
close();
router.push(route);
}
}
function onClearClick() {
if (!confirmClear.value) {
confirmClear.value = true;
return;
}
confirmClear.value = false;
store.clearAll();
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const minutes = Math.floor((Date.now() - then) / 60000);
if (minutes < 1) return t('notifications.time.just_now');
if (minutes < 60) return t('notifications.time.minutes_ago', { n: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t('notifications.time.hours_ago', { n: hours });
const days = Math.floor(hours / 24);
if (days < 7) return t('notifications.time.days_ago', { n: days });
return new Date(iso).toLocaleDateString();
}
</script>
<template>
<Popover class="notification-center">
<PopoverButton
class="notification-bell"
:aria-label="$t('notifications.title')"
>
<Remind theme="outline" size="1em" />
<span v-if="unreadCount > 0" class="notification-badge">{{
badgeText
}}</span>
</PopoverButton>
<PopoverPanel v-slot="{ close }" class="notification-panel">
<div :ref="onPanelRef" class="notification-panel-inner">
<div class="notification-head">
<span class="notification-head-title">
{{ $t('notifications.title') }}
<template v-if="unreadCount > 0">
· {{ $t('notifications.unread', { n: unreadCount }) }}
</template>
</span>
<div class="notification-head-actions">
<button
v-if="unreadCount > 0"
class="notification-action"
@click="store.markAllRead()"
>
{{ $t('notifications.mark_all_read') }}
</button>
<button
v-if="messages.length > 0"
class="notification-action notification-action--danger"
@click="onClearClick"
>
{{
confirmClear
? $t('notifications.clear_confirm')
: $t('notifications.clear_all')
}}
</button>
</div>
</div>
<div v-if="messages.length === 0" class="notification-empty">
{{ $t('notifications.empty') }}
</div>
<div v-else class="notification-list">
<div
v-for="msg in messages"
:key="msg.id"
class="notification-item"
:class="{ 'notification-item--unread': !msg.read }"
role="button"
tabindex="0"
@click="onItemClick(msg, close)"
@keydown.enter="onItemClick(msg, close)"
>
<div class="notification-sev" :class="`is-${msg.severity}`">
<Component :is="severityIcon(msg)" theme="outline" size="14" />
</div>
<div class="notification-content">
<div class="notification-title">
{{ store.titleOf(msg) }}
<span v-if="msg.count > 1" class="notification-count"
>×{{ msg.count }}</span
>
</div>
<div class="notification-body">{{ store.bodyOf(msg) }}</div>
<div class="notification-meta">
{{ relativeTime(msg.updated_at) }}
</div>
</div>
<button
class="notification-delete"
:aria-label="$t('notifications.delete')"
@click.stop="store.remove(msg.id)"
>
<Delete theme="outline" size="13" />
</button>
</div>
</div>
</div>
</PopoverPanel>
</Popover>
</template>
<!-- 不加 scoped:@headlessui/vue 的 Popover/PopoverButton/PopoverPanel 渲染的
根元素不继承父组件的 scope 属性,scoped 选择器无法命中(position 等样式
会静默失效)。所有类名以 notification- 前缀隔离,避免全局冲突。 -->
<style lang="scss">
.notification-center {
position: relative;
display: flex;
align-items: center;
}
// 与 ab-status-bar 的 .status-bar-btn 保持一致
.notification-bell {
position: relative;
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast), transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
min-width: 36px;
min-height: 36px;
padding: 6px;
border-radius: var(--radius-sm);
font-size: 18px;
// 与 ab-status-bar 保持一致:仅触屏放大命中区域,桌面保持紧凑。
@include forTouch {
min-width: var(--touch-target);
min-height: var(--touch-target);
}
&:hover {
color: var(--color-primary);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.notification-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 15px;
text-align: center;
pointer-events: none;
}
.notification-panel {
position: absolute;
top: 44px;
right: 0;
width: min(340px, calc(100vw - 24px));
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
animation: notification-dropdown-in 150ms ease-out;
transform-origin: top right;
}
@keyframes notification-dropdown-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.notification-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border);
}
.notification-head-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.notification-head-actions {
display: flex;
gap: 4px;
}
.notification-action {
background: transparent;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--color-primary);
padding: 4px 6px;
border-radius: var(--radius-sm);
&:hover {
background: var(--color-primary-light);
}
&--danger {
color: var(--color-danger, #ef4444);
&:hover {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
}
}
.notification-empty {
padding: 32px 14px;
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
}
.notification-list {
max-height: min(420px, 60vh);
overflow-y: auto;
}
.notification-item {
display: flex;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background-color var(--transition-fast);
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--color-surface-hover);
.notification-delete {
opacity: 1;
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
&--unread {
box-shadow: inset 3px 0 0 var(--color-primary);
}
&:not(.notification-item--unread) {
opacity: 0.6;
}
}
.notification-sev {
flex-shrink: 0;
width: 26px;
height: 26px;
margin-top: 2px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.is-error {
color: var(--color-danger, #ef4444);
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
}
&.is-warning {
color: var(--color-warning, #f59e0b);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
}
&.is-info {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.notification-count {
background: var(--color-primary-light);
color: var(--color-primary);
font-size: 10.5px;
font-weight: 700;
border-radius: 8px;
padding: 0 6px;
}
.notification-body {
font-size: 12.5px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.notification-meta {
margin-top: 2px;
font-size: 11px;
color: var(--color-text-muted);
}
.notification-delete {
flex-shrink: 0;
align-self: flex-start;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
border-radius: var(--radius-sm);
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
&:hover {
color: var(--color-danger, #ef4444);
}
&:focus-visible {
opacity: 1;
outline: 2px solid var(--color-primary);
}
@include forTouch {
opacity: 1;
}
}
</style>

View File

@@ -128,6 +128,7 @@ onUnmounted(() => {
<!-- Right side actions -->
<div class="topbar-right">
<ab-notification-center />
<ab-status-bar
:items="items"
:running="running"
@@ -244,9 +245,9 @@ onUnmounted(() => {
.topbar-right {
flex-shrink: 0;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
</style>

View File

@@ -1,16 +1,115 @@
<script lang="ts" setup>
import { Info } from '@icon-park/vue-next';
import { NSelect } from 'naive-ui';
import type { SelectOption } from 'naive-ui';
import { NButton, NSelect } from 'naive-ui';
import type { SelectGroupOption, SelectOption } from 'naive-ui';
import type { LLMProviderView } from '@/api/llm';
import type { SelectItem } from '#/components';
import type { LLMProvider } from '#/config';
const { t } = useMyI18n();
const message = useMessage();
const { getSettingGroup } = useConfigStore();
const llm = getSettingGroup('llm');
const providers: LLMProvider = ['openai', 'anthropic', 'gemini'];
// --- 提供商列表(内置 / 已安装 / 可下载)-----------------------------------
const providerList = ref<LLMProviderView[]>([]);
// 已在 /config/llm/providers 中出现的都算"可用";不在列表里的 = 可下载插件
const KNOWN_DOWNLOADABLE: { id: string; display_name: string }[] = [
{ id: 'github-copilot', display_name: 'GitHub Copilot' },
{ id: 'codex-chatgpt', display_name: 'ChatGPT (Codex)' },
];
const busyProvider = ref('');
const providersLoaded = ref(false);
async function loadProviders() {
try {
providerList.value = await apiLLM.getProviders();
} catch {
providerList.value = [];
} finally {
providersLoaded.value = true;
}
}
onMounted(loadProviders);
const selected = computed(() =>
providerList.value.find((p) => p.id === llm.value.provider)
);
const isSubscription = computed(
() =>
selected.value?.auth_kind === 'oauth' ||
selected.value?.auth_kind === 'device_code'
);
// 可下载 = 列表已加载、未在列表中、且是已知可下载插件。
const isDownloadable = computed(
() =>
providersLoaded.value &&
!selected.value &&
KNOWN_DOWNLOADABLE.some((p) => p.id === llm.value.provider)
);
// API-Key 表单:选中的是 api_key 类,或列表未加载/加载失败时的安全兜底
// (避免给内置 openai 误显示"未安装/安装")。
const isBuiltinLike = computed(
() =>
selected.value?.auth_kind === 'api_key' ||
(!selected.value && !isDownloadable.value && !isSubscription.value)
);
const providerOptions = computed<SelectGroupOption[]>(() => {
const builtin = providerList.value.filter((p) => p.builtin);
const installed = providerList.value.filter(
(p) => !p.builtin && p.auth_kind === 'api_key'
);
const subscription = providerList.value.filter((p) => !p.builtin && p.auth_kind !== 'api_key');
const installedIds = new Set(providerList.value.map((p) => p.id));
const downloadable = KNOWN_DOWNLOADABLE.filter((p) => !installedIds.has(p.id));
const groups: SelectGroupOption[] = [];
const toOpt = (p: { id: string; display_name: string }) => ({
label: p.display_name,
value: p.id,
});
if (builtin.length)
groups.push({ type: 'group', label: t('config.llm_set.group_builtin'), key: 'builtin', children: builtin.map(toOpt) });
if (installed.length)
groups.push({ type: 'group', label: t('config.llm_set.group_installed'), key: 'installed', children: installed.map(toOpt) });
if (subscription.length)
groups.push({ type: 'group', label: t('config.llm_set.group_subscription'), key: 'subscription', children: subscription.map(toOpt) });
if (downloadable.length)
groups.push({ type: 'group', label: t('config.llm_set.group_available'), key: 'available', children: downloadable.map(toOpt) });
return groups;
});
// --- 按提供商读写凭据/模型/端点:内置写扁平字段,其它写 providers[id] -------
function fieldGet(field: 'api_key' | 'model' | 'base_url'): string {
const pid = llm.value.provider;
const builtin = selected.value?.builtin ?? pid === 'openai';
if (builtin) return llm.value[field];
return llm.value.providers?.[pid]?.[field] ?? '';
}
function fieldSet(field: 'api_key' | 'model' | 'base_url', value: string) {
const pid = llm.value.provider;
const builtin = selected.value?.builtin ?? pid === 'openai';
if (builtin) {
llm.value[field] = value;
return;
}
if (!llm.value.providers) llm.value.providers = {};
if (!llm.value.providers[pid])
llm.value.providers[pid] = { api_key: '', model: '', base_url: '' };
llm.value.providers[pid][field] = value;
}
const apiKey = computed({
get: () => fieldGet('api_key'),
set: (v: string) => fieldSet('api_key', v),
});
const model = computed({
get: () => fieldGet('model'),
set: (v: string) => fieldSet('model', v),
});
const baseUrl = computed({
get: () => fieldGet('base_url'),
set: (v: string) => fieldSet('base_url', v),
});
// 解析模式选项需要本地化标签,用 computed 保证语言切换时同步更新
const modeItems = computed<SelectItem[]>(() => [
@@ -18,24 +117,71 @@ const modeItems = computed<SelectItem[]>(() => [
{ id: 2, label: t('config.llm_set.mode_primary'), value: 'primary' },
]);
// 模型占位符跟随所选服务商,给出各家经济型号作示例
const modelPlaceholder = computed(() => {
const M: Record<string, string> = {
openai: 'gpt-5-mini',
anthropic: 'claude-haiku-4-5',
gemini: 'gemini-2.5-flash',
};
return M[llm.value.provider] ?? 'gpt-5-mini';
});
const modelPlaceholder = computed(
() => selected.value?.default_model || 'gpt-5-mini'
);
const baseUrlPlaceholder = computed(
() => selected.value?.preset_base_url || 'https://api.openai.com/v1'
);
// 模型列表按需从提供商 API 拉取;下拉支持搜索与自由输入(tag)
// --- 安装 / 卸载 / 认证 -----------------------------------------------------
const showAuthDialog = ref(false);
const confirmRisk = ref(false);
const pendingInstallId = ref('');
// Copilot/Codex 属于 ToS 灰色区,安装前弹风险确认
function needsRiskNotice(id: string): boolean {
return id === 'github-copilot' || id === 'codex-chatgpt';
}
async function onInstall(id: string) {
if (needsRiskNotice(id) && !confirmRisk.value) {
pendingInstallId.value = id;
confirmRisk.value = true;
return;
}
confirmRisk.value = false;
busyProvider.value = id;
try {
await apiLLM.installProvider(id);
await loadProviders();
message.success(t('config.llm_set.install_success'));
} catch {
// 失败详情由通知中心呈现
message.error(t('config.llm_set.install_failed'));
} finally {
busyProvider.value = '';
}
}
async function onUninstall(id: string) {
busyProvider.value = id;
try {
await apiLLM.uninstallProvider(id);
await loadProviders();
} catch {
message.error(t('config.llm_set.install_failed'));
} finally {
busyProvider.value = '';
}
}
async function onDisconnect(id: string) {
await apiLLM.disconnect(id);
await loadProviders();
}
function onAuthConnected() {
loadProviders();
}
// --- 模型列表按需拉取(api_key 类)-----------------------------------------
const modelOptions = ref<SelectOption[]>([]);
const modelsLoading = ref(false);
// 记录上次成功拉取时的表单指纹,凭据变化后自动重新拉取
let fetchedFor = '';
function credentialsKey(): string {
return [llm.value.provider, llm.value.api_key, llm.value.base_url].join('|');
return [llm.value.provider, apiKey.value, baseUrl.value].join('|');
}
async function fetchModels() {
@@ -44,8 +190,8 @@ async function fetchModels() {
try {
const models = await apiConfig.getLLMModels({
provider: llm.value.provider,
api_key: llm.value.api_key,
base_url: llm.value.base_url,
api_key: apiKey.value,
base_url: baseUrl.value,
});
modelOptions.value = models.map((m) => ({ label: m, value: m }));
fetchedFor = credentialsKey();
@@ -105,12 +251,14 @@ const tuningItems = computed(() => [
<transition name="slide-fade">
<div v-if="llm.enable" class="llm-config">
<ab-setting
v-model:data="llm.provider"
:label="() => t('config.llm_set.provider')"
type="select"
:prop="{ items: providers }"
/>
<ab-label :label="() => t('config.llm_set.provider')">
<NSelect
v-model:value="llm.provider"
class="model-select"
:options="providerOptions"
:aria-label="t('config.llm_set.provider')"
/>
</ab-label>
<ab-setting
v-model:data="llm.mode"
@@ -119,35 +267,104 @@ const tuningItems = computed(() => [
:prop="{ items: modeItems }"
/>
<ab-setting
v-model:data="llm.api_key"
:label="() => t('config.llm_set.api_key')"
type="input"
:prop="{ type: 'password', placeholder: 'sk-...' }"
/>
<!-- 订阅类提供商:连接状态 chip 或 Connect 按钮 -->
<div v-if="isSubscription" class="llm-subscription">
<div v-if="selected?.connected" class="llm-connected">
<span class="llm-connected-dot" />
<span class="llm-connected-label">{{
t('config.llm_set.connected_as', {
account: selected.account_label || selected.display_name,
})
}}</span>
<NButton size="tiny" quaternary @click="onDisconnect(selected.id)">{{
t('config.llm_set.disconnect')
}}</NButton>
</div>
<NButton
v-else
type="primary"
size="small"
@click="showAuthDialog = true"
>{{ t('config.llm_set.connect') }}</NButton
>
</div>
<ab-label :label="() => t('config.llm_set.model')">
<NSelect
v-model:value="llm.model"
class="model-select"
filterable
tag
:options="modelOptions"
:loading="modelsLoading"
:placeholder="modelPlaceholder"
:aria-label="t('config.llm_set.model')"
@update:show="onModelDropdownShow"
<!-- api_key 类(内置 + 预设):密钥 + 模型 + base_url -->
<template v-if="isBuiltinLike">
<ab-setting
v-model:data="apiKey"
:label="() => t('config.llm_set.api_key')"
type="input"
:prop="{ type: 'password', placeholder: 'sk-...' }"
/>
</ab-label>
<!-- base_url 仅对 openai 提供商生效(自定义 OpenAI 兼容端点) -->
<ab-setting
v-if="llm.provider === 'openai'"
v-model:data="llm.base_url"
:label="() => t('config.llm_set.base_url')"
type="input"
:prop="{ type: 'url', placeholder: 'https://api.openai.com/v1' }"
/>
<ab-label :label="() => t('config.llm_set.model')">
<NSelect
v-model:value="model"
class="model-select"
filterable
tag
:options="modelOptions"
:loading="modelsLoading"
:placeholder="modelPlaceholder"
:aria-label="t('config.llm_set.model')"
@update:show="onModelDropdownShow"
/>
</ab-label>
<ab-setting
v-if="selected?.needs_base_url"
v-model:data="baseUrl"
:label="() => t('config.llm_set.base_url')"
type="input"
:prop="{ type: 'url', placeholder: baseUrlPlaceholder }"
/>
</template>
<!-- 可下载插件:安装按钮(灰色插件先弹风险确认) -->
<div v-if="isDownloadable" class="llm-install">
<p class="llm-install-hint">
{{ t('config.llm_set.not_installed') }}
</p>
<NButton
v-if="!confirmRisk"
type="primary"
size="small"
:loading="busyProvider === llm.provider"
@click="onInstall(llm.provider)"
>{{ t('config.llm_set.install') }}</NButton
>
<div v-else class="llm-risk">
<p class="llm-risk-text">{{ t('config.llm_set.risk_notice') }}</p>
<div class="llm-risk-actions">
<NButton size="small" @click="confirmRisk = false">{{
t('config.llm_set.risk_cancel')
}}</NButton>
<NButton
type="warning"
size="small"
:loading="busyProvider === llm.provider"
@click="onInstall(llm.provider)"
>{{ t('config.llm_set.risk_confirm') }}</NButton
>
</div>
</div>
</div>
<!-- 已安装的非内置提供商:卸载入口 -->
<div
v-if="selected && !selected.builtin && selected.plugin_version"
class="llm-uninstall"
>
<NButton
size="tiny"
quaternary
type="error"
:loading="busyProvider === selected.id"
@click="onUninstall(selected.id)"
>{{ t('config.llm_set.uninstall') }}</NButton
>
</div>
<!-- 超时/缓存/并发/熔断调优项 -->
<advanced-section v-model:open="showTuning">
@@ -164,6 +381,14 @@ const tuningItems = computed(() => [
</div>
</transition>
</div>
<llm-auth-dialog
v-if="selected"
v-model:show="showAuthDialog"
:provider-id="selected.id"
:display-name="selected.display_name"
@connected="onAuthConnected"
/>
</ab-fold-panel>
</template>
@@ -201,6 +426,57 @@ const tuningItems = computed(() => [
}
}
.llm-subscription,
.llm-install,
.llm-uninstall {
display: flex;
flex-direction: column;
gap: 8px;
}
.llm-connected {
display: flex;
align-items: center;
gap: 8px;
}
.llm-connected-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success, #22c55e);
}
.llm-connected-label {
font-size: 13px;
color: var(--color-text);
}
.llm-install-hint {
font-size: 12px;
color: var(--color-text-secondary);
}
.llm-risk {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--color-warning-bg, #fef9ed);
border: 1px solid var(--color-warning-border, #fde68a);
}
.llm-risk-text {
font-size: 12.5px;
color: var(--color-warning-text, #92400e);
}
.llm-risk-actions {
display: flex;
gap: 8px;
}
.slide-fade-enter-active {
transition: all 0.2s ease-out;
}

View File

@@ -0,0 +1,191 @@
<script lang="ts" setup>
import { NButton, NInput, NModal } from 'naive-ui';
import type { LLMAuthChallenge } from '@/api/llm';
const props = defineProps<{
show: boolean;
providerId: string;
displayName: string;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'connected'): void;
}>();
const { t } = useMyI18n();
const message = useMessage();
const challenge = ref<LLMAuthChallenge | null>(null);
const loading = ref(false);
const pastedCode = ref('');
let pollTimer: ReturnType<typeof setInterval> | undefined;
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = undefined;
}
}
function close() {
stopPolling();
challenge.value = null;
pastedCode.value = '';
emit('update:show', false);
}
async function begin() {
loading.value = true;
try {
challenge.value = await apiLLM.beginAuth(props.providerId);
if (challenge.value.method === 'device_code') startPolling();
} catch {
message.error(t('config.llm_set.auth_failed'));
close();
} finally {
loading.value = false;
}
}
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
try {
const status = await apiLLM.getAuthStatus(props.providerId);
if (status.connected) {
stopPolling();
message.success(t('config.llm_set.connected_as', { account: status.account_label }));
emit('connected');
close();
}
} catch {
// 轮询失败静默重试
}
}, 3000);
}
// redirect_paste:用户在自己设备打开授权链接,把回调 code/URL 粘回来
async function submitCode() {
if (!challenge.value) return;
loading.value = true;
try {
const res = await apiLLM.completeAuth(props.providerId, {
state: challenge.value.state,
code: pastedCode.value.trim(),
});
message.success(t('config.llm_set.connected_as', { account: res.account_label }));
emit('connected');
close();
} catch {
message.error(t('config.llm_set.auth_failed'));
} finally {
loading.value = false;
}
}
watch(
() => props.show,
(show) => {
if (show) begin();
else stopPolling();
}
);
onUnmounted(stopPolling);
</script>
<template>
<NModal
:show="show"
preset="card"
:title="`${t('config.llm_set.connect')} · ${displayName}`"
style="max-width: 460px"
@update:show="(v: boolean) => !v && close()"
>
<div v-if="challenge" class="auth-body">
<template v-if="challenge.method === 'device_code'">
<p class="auth-step">{{ t('config.llm_set.auth_device_hint') }}</p>
<a
v-if="challenge.verification_uri"
:href="challenge.verification_uri"
target="_blank"
rel="noopener"
class="auth-link"
>{{ challenge.verification_uri }}</a
>
<div class="auth-code">{{ challenge.user_code }}</div>
<p class="auth-pending">{{ t('config.llm_set.auth_pending') }}</p>
</template>
<template v-else>
<p class="auth-step">{{ t('config.llm_set.auth_open_link') }}</p>
<a
v-if="challenge.authorize_url"
:href="challenge.authorize_url"
target="_blank"
rel="noopener"
class="auth-link"
>{{ challenge.authorize_url }}</a
>
<NInput
v-model:value="pastedCode"
type="textarea"
:rows="2"
:placeholder="t('config.llm_set.auth_paste_code')"
/>
<NButton
type="primary"
:loading="loading"
:disabled="!pastedCode.trim()"
@click="submitCode"
>{{ t('config.llm_set.connect') }}</NButton
>
</template>
</div>
<div v-else class="auth-body auth-loading">
{{ t('config.llm_set.auth_pending') }}
</div>
</NModal>
</template>
<style lang="scss" scoped>
.auth-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-step {
font-size: 13px;
color: var(--color-text-secondary);
}
.auth-link {
font-size: 13px;
color: var(--color-primary);
word-break: break-all;
}
.auth-code {
font-family: var(--font-mono, monospace);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.15em;
text-align: center;
padding: 12px;
border-radius: var(--radius-md);
background: var(--color-primary-light);
color: var(--color-primary);
}
.auth-pending {
font-size: 12px;
color: var(--color-text-muted);
}
.auth-loading {
color: var(--color-text-muted);
font-size: 13px;
}
</style>

View File

@@ -8,6 +8,12 @@ export interface StatusPayload {
first_run: boolean;
}
export interface NotificationStreamPayload {
unread_count: number;
latest_id: number;
revision: number;
}
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 15000;
@@ -27,6 +33,7 @@ export const useEventStream = createSharedComposable(() => {
const downloaderData = ref<QbTorrentInfo[] | null>(null);
const logData = ref<string | null>(null);
const updateData = ref<UpdateProgress | null>(null);
const notificationData = ref<NotificationStreamPayload | null>(null);
let source: EventSource | null = null;
let retryCount = 0;
@@ -92,6 +99,14 @@ export const useEventStream = createSharedComposable(() => {
}
});
es.addEventListener('notification', (e) => {
try {
notificationData.value = JSON.parse((e as MessageEvent).data);
} catch {
// Ignore malformed frames; the next tick will retry.
}
});
es.onerror = () => {
teardown();
scheduleReconnect();
@@ -125,5 +140,6 @@ export const useEventStream = createSharedComposable(() => {
downloaderData,
logData,
updateData,
notificationData,
};
});

View File

@@ -37,7 +37,27 @@
"model": "Model",
"provider": "Provider",
"timeout": "Request timeout (seconds)",
"title": "LLM Parser"
"title": "LLM Parser",
"group_builtin": "Built-in",
"group_installed": "Installed presets",
"group_subscription": "Subscription",
"group_available": "Available to download",
"not_installed": "This provider is not installed yet.",
"install": "Install",
"install_success": "Provider installed",
"install_failed": "Install failed",
"uninstall": "Uninstall",
"connect": "Connect",
"disconnect": "Disconnect",
"connected_as": "Connected as {account}",
"risk_notice": "This provider uses an unofficial subscription flow. Third-party use may violate the vendor's terms and could get your account restricted. Continue at your own risk.",
"risk_cancel": "Cancel",
"risk_confirm": "Install anyway",
"auth_open_link": "Open this link on any device, authorize, then paste the code below:",
"auth_paste_code": "Paste the code or redirect URL here",
"auth_device_hint": "Open this link and enter the code to authorize:",
"auth_pending": "Waiting for authorization…",
"auth_failed": "Authorization failed"
},
"manage_set": {
"delete_bad_torrent": "Delete Bad Torrent",
@@ -558,5 +578,62 @@
},
"shutdown": "Shutdown",
"start": "Start"
},
"notifications": {
"title": "Notifications",
"unread": "{n} unread",
"empty": "No notifications",
"mark_all_read": "Mark all read",
"clear_all": "Clear all",
"clear_confirm": "Confirm?",
"delete": "Delete",
"time": {
"just_now": "just now",
"minutes_ago": "{n} min ago",
"hours_ago": "{n} h ago",
"days_ago": "{n} d ago"
},
"reason": {
"unreachable": "Cannot connect to the downloader. Check the host, port and network.",
"credentials": "Username or password rejected. Check the downloader credentials in settings.",
"banned": "IP banned by the downloader. Unban it in the downloader WebUI or restart the downloader."
},
"kind": {
"rss_failure": {
"title": "RSS feed error",
"body": "{rss_name}: {error}"
},
"download_failure": {
"title": "Torrent add failed",
"body": "{official_title} — {torrent_name}"
},
"offset_review": {
"title": "Episode offset needs review",
"body": "{official_title}: {reason}"
},
"downloader_unavailable": {
"title": "Downloader unavailable"
},
"update_available": {
"title": "New version available",
"body": "{current} → {latest} ({channel} channel)"
},
"update_applied": {
"title": "Update applied",
"body": "Updated to {version}. It takes effect after restart."
},
"update_failed": {
"title": "Update failed",
"body": "{message}"
},
"llm_auth_failure": {
"title": "LLM credentials expired",
"body": "{provider_id}: {message}"
},
"llm_plugin_install_failed": {
"title": "LLM plugin install failed",
"body": "{plugin_id}: {message}"
}
}
}
}

View File

@@ -37,7 +37,27 @@
"model": "模型",
"provider": "服务商",
"timeout": "请求超时(秒)",
"title": "LLM 解析器"
"title": "LLM 解析器",
"group_builtin": "内置",
"group_installed": "已安装预设",
"group_subscription": "订阅账号",
"group_available": "可下载",
"not_installed": "该提供商尚未安装。",
"install": "安装",
"install_success": "提供商已安装",
"install_failed": "安装失败",
"uninstall": "卸载",
"connect": "连接",
"disconnect": "断开",
"connected_as": "已连接:{account}",
"risk_notice": "该提供商使用非官方订阅流程,第三方使用可能违反厂商条款、导致账号被限制。请自担风险后继续。",
"risk_cancel": "取消",
"risk_confirm": "仍然安装",
"auth_open_link": "在任意设备打开此链接授权,然后把回调 code 粘贴到下方:",
"auth_paste_code": "粘贴授权 code 或回调 URL",
"auth_device_hint": "打开链接并输入下方代码完成授权:",
"auth_pending": "等待授权中…",
"auth_failed": "授权失败"
},
"manage_set": {
"delete_bad_torrent": "删除坏种",
@@ -558,5 +578,62 @@
},
"shutdown": "关闭",
"start": "启动"
},
"notifications": {
"title": "通知",
"unread": "{n} 条未读",
"empty": "暂无通知",
"mark_all_read": "全部已读",
"clear_all": "清空",
"clear_confirm": "确认清空?",
"delete": "删除",
"time": {
"just_now": "刚刚",
"minutes_ago": "{n} 分钟前",
"hours_ago": "{n} 小时前",
"days_ago": "{n} 天前"
},
"reason": {
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
"banned": "IP 已被下载器封禁,请在下载器 WebUI 解封或重启下载器。"
},
"kind": {
"rss_failure": {
"title": "RSS 订阅连接异常",
"body": "{rss_name}:{error}"
},
"download_failure": {
"title": "种子添加失败",
"body": "{official_title} — {torrent_name}"
},
"offset_review": {
"title": "集数偏移待确认",
"body": "{official_title}:{reason}"
},
"downloader_unavailable": {
"title": "下载器连接异常"
},
"update_available": {
"title": "发现新版本",
"body": "{current} → {latest}({channel} 频道)"
},
"update_applied": {
"title": "程序更新完成",
"body": "已更新到 {version},重启后生效。"
},
"update_failed": {
"title": "程序更新失败",
"body": "{message}"
},
"llm_auth_failure": {
"title": "LLM 提供商凭据失效",
"body": "{provider_id}:{message}"
},
"llm_plugin_install_failed": {
"title": "LLM 插件安装失败",
"body": "{plugin_id}:{message}"
}
}
}
}

View File

@@ -0,0 +1,180 @@
import { useIntervalFn } from '@vueuse/core';
import type { InboxMessage } from '@/api/notification';
/** 有 i18n 文案的消息类型;未知 kind 回退到后端存储的 title/body。 */
const KNOWN_KINDS = [
'rss_failure',
'download_failure',
'offset_review',
'downloader_unavailable',
'update_available',
'update_applied',
'update_failed',
'llm_auth_failure',
'llm_plugin_install_failed',
] as const;
export const useNotificationStore = defineStore('notification', () => {
const message = useMessage();
const { t } = useMyI18n();
const { isLoggedIn } = useAuth();
const { connected: sseConnected, notificationData } = useEventStream();
const messages = ref<InboxMessage[]>([]);
const total = ref(0);
const unreadCount = ref(0);
const isLoading = ref(false);
const panelOpen = ref(false);
// null = 尚未收到第一帧;第一帧只同步未读数,不为历史积压弹 toast
let lastLatestId: number | null = null;
function titleOf(msg: InboxMessage): string {
if (!(KNOWN_KINDS as readonly string[]).includes(msg.kind)) {
return msg.title;
}
return t(`notifications.kind.${msg.kind}.title`);
}
function bodyOf(msg: InboxMessage): string {
if (!(KNOWN_KINDS as readonly string[]).includes(msg.kind)) {
return msg.body;
}
const payload = msg.payload ?? {};
if (msg.kind === 'downloader_unavailable') {
const reason = String(payload.reason || 'unreachable');
return `${payload.host ?? ''}: ${t(`notifications.reason.${reason}`)}`;
}
return t(`notifications.kind.${msg.kind}.body`, payload);
}
async function fetchMessages() {
if (!isLoggedIn.value) return;
isLoading.value = true;
try {
const res = await apiNotification.getMessages({ limit: 50 });
messages.value = res.messages;
total.value = res.total;
unreadCount.value = res.unread_count;
} catch {
// 后台刷新失败保留旧数据
} finally {
isLoading.value = false;
}
}
// SSE 帧:同步未读数;latest_id 增大 = 有新消息 → 刷新列表并对
// error 级新消息弹 toast(跳过登录后的第一帧,避免为历史积压刷屏)。
watch(notificationData, (frame) => {
if (!frame) return;
unreadCount.value = frame.unread_count;
const isFirstFrame = lastLatestId === null;
const hasNew = !isFirstFrame && frame.latest_id > (lastLatestId as number);
lastLatestId = frame.latest_id;
if (hasNew) {
fetchMessages().then(() => {
const head = messages.value[0];
if (head && !head.read && head.severity === 'error') {
message.error(titleOf(head));
}
});
} else if (panelOpen.value && !isFirstFrame) {
// 其它端的已读/删除操作也让打开中的面板保持同步
fetchMessages();
}
});
// SSE 断开时的回退:30s 轮询一次未读数(静默)。
async function pollUnread() {
if (sseConnected.value || document.hidden || !isLoggedIn.value) return;
try {
unreadCount.value = await apiNotification.getUnreadCount();
} catch {
// Silent poll — keep the last count on a transient failure.
}
}
const { pause: offPoll, resume: onPoll } = useIntervalFn(pollUnread, 30000, {
immediate: false,
immediateCallback: true,
});
watch(
isLoggedIn,
(loggedIn) => {
if (loggedIn) {
onPoll();
} else {
offPoll();
messages.value = [];
total.value = 0;
unreadCount.value = 0;
lastLatestId = null;
}
},
{ immediate: true }
);
async function markRead(id: number) {
const target = messages.value.find((m) => m.id === id);
if (!target || target.read) return;
try {
await apiNotification.markRead(id);
target.read = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
} catch {
// 失败保持原状,下一次 SSE 帧会校正
}
}
async function markAllRead() {
try {
await apiNotification.markAllRead();
messages.value.forEach((m) => {
m.read = true;
});
unreadCount.value = 0;
} catch {
// 同上
}
}
async function remove(id: number) {
try {
await apiNotification.removeMessage(id);
const target = messages.value.find((m) => m.id === id);
messages.value = messages.value.filter((m) => m.id !== id);
total.value = Math.max(0, total.value - 1);
if (target && !target.read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
} catch {
// 同上
}
}
async function clearAll() {
try {
await apiNotification.clearAll();
messages.value = [];
total.value = 0;
unreadCount.value = 0;
} catch {
// 同上
}
}
return {
messages,
total,
unreadCount,
isLoading,
panelOpen,
fetchMessages,
markRead,
markAllRead,
remove,
clearAll,
titleOf,
bodyOf,
};
});

View File

@@ -19,8 +19,14 @@ export type NotificationType = [
'pushover',
'webhook',
];
/** LLM 提供商(openai 表示任意 OpenAI 兼容端点) */
export type LLMProvider = ['openai', 'anthropic', 'gemini'];
/** LLM 提供商 id(内置三家 + 预设/插件,开放集合) */
export type LLMProviderId = string;
/** 单个提供商的凭据/模型/端点覆盖 */
export interface LLMProviderOverride {
api_key: string;
model: string;
base_url: string;
}
/** LLM 解析模式(fallback:正则优先;primary:LLM 优先) */
export type LLMParseMode = ['fallback', 'primary'];
/** OpenAI Model List */
@@ -92,7 +98,7 @@ export interface Notification {
}
export interface LLM {
enable: boolean;
provider: TupleToUnion<LLMProvider>;
provider: LLMProviderId;
api_key: string;
model: string;
/** 自定义端点,仅 openai 提供商使用;空串表示官方 API */
@@ -108,6 +114,8 @@ export interface LLM {
failure_threshold: number;
/** 熔断后暂停调用的时长(秒) */
failure_backoff: number;
/** 按提供商 id 存放的凭据/模型/端点覆盖 */
providers: Record<string, LLMProviderOverride>;
}
/** @deprecated 旧版 OpenAI 解析配置,已被 LLM 段取代(保留向后兼容) */
@@ -210,6 +218,7 @@ export const initConfig: Config = {
max_concurrency: 2,
failure_threshold: 3,
failure_backoff: 300,
providers: {},
},
experimental_openai: {
enable: false,

View File

@@ -11,6 +11,7 @@ declare global {
const apiConfig: typeof import('../../src/api/config')['apiConfig']
const apiDownload: typeof import('../../src/api/download')['apiDownload']
const apiDownloader: typeof import('../../src/api/downloader')['apiDownloader']
const apiLLM: typeof import('../../src/api/llm')['apiLLM']
const apiLog: typeof import('../../src/api/log')['apiLog']
const apiNotification: typeof import('../../src/api/notification')['apiNotification']
const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']
@@ -115,6 +116,7 @@ declare global {
const useLogStore: typeof import('../../src/store/log')['useLogStore']
const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']
const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']
const useNotificationStore: typeof import('../../src/store/notification')['useNotificationStore']
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']

View File

@@ -23,6 +23,7 @@ declare module '@vue/runtime-core' {
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
AbNotificationCenter: typeof import('./../../src/components/layout/ab-notification-center.vue')['default']
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
@@ -63,6 +64,7 @@ declare module '@vue/runtime-core' {
ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']
ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']
ConfigSecurity: typeof import('./../../src/components/setting/config-security.vue')['default']
LlmAuthDialog: typeof import('./../../src/components/setting/llm-auth-dialog.vue')['default']
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']