diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index 3dc3c9cb..aac70938 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -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) diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index 63fac85b..e8c46517 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -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: diff --git a/backend/src/module/api/events.py b/backend/src/module/api/events.py index 8684cf56..da7744b7 100644 --- a/backend/src/module/api/events.py +++ b/backend/src/module/api/events.py @@ -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 diff --git a/backend/src/module/api/llm.py b/backend/src/module/api/llm.py new file mode 100644 index 00000000..ca4fb0f6 --- /dev/null +++ b/backend/src/module/api/llm.py @@ -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} diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py index 21af55c9..13fc5116 100644 --- a/backend/src/module/api/notification.py +++ b/backend/src/module/api/notification.py @@ -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} 条消息。", + ) diff --git a/backend/src/module/api/update.py b/backend/src/module/api/update.py index 4fc195ce..b5d12bf3 100644 --- a/backend/src/module/api/update.py +++ b/backend/src/module/api/update.py @@ -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( diff --git a/backend/src/module/checker/checker.py b/backend/src/module/checker/checker.py index 3c98328c..a9a6e088 100644 --- a/backend/src/module/checker/checker.py +++ b/backend/src/module/checker/checker.py @@ -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: diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index f2155dbc..221ff7aa 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -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() diff --git a/backend/src/module/core/loops.py b/backend/src/module/core/loops.py index 1c59a376..c641c367 100644 --- a/backend/src/module/core/loops.py +++ b/backend/src/module/core/loops.py @@ -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: diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py index 4d32ec35..8640288f 100644 --- a/backend/src/module/database/combine.py +++ b/backend/src/module/database/combine.py @@ -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 diff --git a/backend/src/module/database/inbox.py b/backend/src/module/database/inbox.py new file mode 100644 index 00000000..bfab8094 --- /dev/null +++ b/backend/src/module/database/inbox.py @@ -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) diff --git a/backend/src/module/database/llm_credential.py b/backend/src/module/database/llm_credential.py new file mode 100644 index 00000000..b5ba6ab7 --- /dev/null +++ b/backend/src/module/database/llm_credential.py @@ -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()) diff --git a/backend/src/module/database/migrations.py b/backend/src/module/database/migrations.py index 5839a155..2e425493 100644 --- a/backend/src/module/database/migrations.py +++ b/backend/src/module/database/migrations.py @@ -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"), + ), ) # 由迁移列表派生,新增迁移时无需手动同步 diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index 938e2a7a..3db65aa0 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -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. " diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 9d83ecd9..5c5d9c92 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -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) diff --git a/backend/src/module/llm_plugins/__init__.py b/backend/src/module/llm_plugins/__init__.py new file mode 100644 index 00000000..47a73496 --- /dev/null +++ b/backend/src/module/llm_plugins/__init__.py @@ -0,0 +1,3 @@ +from .installer import DEFAULT_PLUGINS_ROOT, InstallResult, PluginInstaller + +__all__ = ["DEFAULT_PLUGINS_ROOT", "InstallResult", "PluginInstaller"] diff --git a/backend/src/module/llm_plugins/installer.py b/backend/src/module/llm_plugins/installer.py new file mode 100644 index 00000000..838e660e --- /dev/null +++ b/backend/src/module/llm_plugins/installer.py @@ -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/// + 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) diff --git a/backend/src/module/llm_plugins/loader.py b/backend/src/module/llm_plugins/loader.py new file mode 100644 index 00000000..6ddfb072 --- /dev/null +++ b/backend/src/module/llm_plugins/loader.py @@ -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 diff --git a/backend/src/module/models/__init__.py b/backend/src/module/models/__init__.py index d610684d..441b3e0d 100644 --- a/backend/src/module/models/__init__.py +++ b/backend/src/module/models/__init__.py @@ -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 diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index 5a8ccb4a..fe4ff0db 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -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( diff --git a/backend/src/module/models/inbox.py b/backend/src/module/models/inbox.py new file mode 100644 index 00000000..3d76ca19 --- /dev/null +++ b/backend/src/module/models/inbox.py @@ -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) diff --git a/backend/src/module/models/llm_credential.py b/backend/src/module/models/llm_credential.py new file mode 100644 index 00000000..69805682 --- /dev/null +++ b/backend/src/module/models/llm_credential.py @@ -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) diff --git a/backend/src/module/notification/__init__.py b/backend/src/module/notification/__init__.py index 049897e0..b49a7832 100644 --- a/backend/src/module/notification/__init__.py +++ b/backend/src/module/notification/__init__.py @@ -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", ] diff --git a/backend/src/module/notification/events.py b/backend/src/module/notification/events.py index efd883e1..22e84680 100644 --- a/backend/src/module/notification/events.py +++ b/backend/src/module/notification/events.py @@ -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 +) diff --git a/backend/src/module/notification/inbox.py b/backend/src/module/notification/inbox.py new file mode 100644 index 00000000..31137765 --- /dev/null +++ b/backend/src/module/notification/inbox.py @@ -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 diff --git a/backend/src/module/notification/manager.py b/backend/src/module/notification/manager.py index fac23105..7b591f10 100644 --- a/backend/src/module/notification/manager.py +++ b/backend/src/module/notification/manager.py @@ -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) diff --git a/backend/src/module/parser/analyser/llm.py b/backend/src/module/parser/analyser/llm.py index 7aad606f..26e498b4 100644 --- a/backend/src/module/parser/analyser/llm.py +++ b/backend/src/module/parser/analyser/llm.py @@ -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( diff --git a/backend/src/module/parser/analyser/providers/__init__.py b/backend/src/module/parser/analyser/providers/__init__.py new file mode 100644 index 00000000..3dbe6bf2 --- /dev/null +++ b/backend/src/module/parser/analyser/providers/__init__.py @@ -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", +] diff --git a/backend/src/module/parser/analyser/providers/base.py b/backend/src/module/parser/analyser/providers/base.py new file mode 100644 index 00000000..da7a562b --- /dev/null +++ b/backend/src/module/parser/analyser/providers/base.py @@ -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: + """注销远端凭据,尽力而为;默认无操作。""" diff --git a/backend/src/module/parser/analyser/providers/builtin.py b/backend/src/module/parser/analyser/providers/builtin.py new file mode 100644 index 00000000..9c75d633 --- /dev/null +++ b/backend/src/module/parser/analyser/providers/builtin.py @@ -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, +} diff --git a/backend/src/module/parser/analyser/providers/credentials.py b/backend/src/module/parser/analyser/providers/credentials.py new file mode 100644 index 00000000..7b3746de --- /dev/null +++ b/backend/src/module/parser/analyser/providers/credentials.py @@ -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) diff --git a/backend/src/module/parser/analyser/providers/presets.py b/backend/src/module/parser/analyser/providers/presets.py new file mode 100644 index 00000000..48b6c3a4 --- /dev/null +++ b/backend/src/module/parser/analyser/providers/presets.py @@ -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 +} diff --git a/backend/src/module/parser/analyser/providers/registry.py b/backend/src/module/parser/analyser/providers/registry.py new file mode 100644 index 00000000..389a245d --- /dev/null +++ b/backend/src/module/parser/analyser/providers/registry.py @@ -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() diff --git a/backend/src/module/parser/analyser/providers/schema.py b/backend/src/module/parser/analyser/providers/schema.py new file mode 100644 index 00000000..ce5be0db --- /dev/null +++ b/backend/src/module/parser/analyser/providers/schema.py @@ -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)." +) diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index 02d409b9..33a3aa0e 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -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) diff --git a/backend/src/test/test_api_config.py b/backend/src/test/test_api_config.py index 50cb6b99..cd32dc0e 100644 --- a/backend/src/test/test_api_config.py +++ b/backend/src/test/test_api_config.py @@ -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", diff --git a/backend/src/test/test_api_events.py b/backend/src/test/test_api_events.py index 261175d8..9a14c7e7 100644 --- a/backend/src/test/test_api_events.py +++ b/backend/src/test/test_api_events.py @@ -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() diff --git a/backend/src/test/test_api_llm.py b/backend/src/test/test_api_llm.py new file mode 100644 index 00000000..d79dfa09 --- /dev/null +++ b/backend/src/test/test_api_llm.py @@ -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") diff --git a/backend/src/test/test_api_notification_inbox.py b/backend/src/test/test_api_notification_inbox.py new file mode 100644 index 00000000..4c1a716a --- /dev/null +++ b/backend/src/test/test_api_notification_inbox.py @@ -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() diff --git a/backend/src/test/test_checker.py b/backend/src/test/test_checker.py new file mode 100644 index 00000000..b1008936 --- /dev/null +++ b/backend/src/test/test_checker.py @@ -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 diff --git a/backend/src/test/test_context.py b/backend/src/test/test_context.py index 6bfd16b2..63d1529c 100644 --- a/backend/src/test/test_context.py +++ b/backend/src/test/test_context.py @@ -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() # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_database_inbox.py b/backend/src/test/test_database_inbox.py new file mode 100644 index 00000000..4c6f4575 --- /dev/null +++ b/backend/src/test/test_database_inbox.py @@ -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 diff --git a/backend/src/test/test_database_llm_credential.py b/backend/src/test/test_database_llm_credential.py new file mode 100644 index 00000000..fda54892 --- /dev/null +++ b/backend/src/test/test_database_llm_credential.py @@ -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 diff --git a/backend/src/test/test_llm_credentials_store.py b/backend/src/test/test_llm_credentials_store.py new file mode 100644 index 00000000..8ee4d1dc --- /dev/null +++ b/backend/src/test/test_llm_credentials_store.py @@ -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() diff --git a/backend/src/test/test_llm_plugin_installer.py b/backend/src/test/test_llm_plugin_installer.py new file mode 100644 index 00000000..898cbfef --- /dev/null +++ b/backend/src/test/test_llm_plugin_installer.py @@ -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() diff --git a/backend/src/test/test_llm_plugins_grey.py b/backend/src/test/test_llm_plugins_grey.py new file mode 100644 index 00000000..91da9a64 --- /dev/null +++ b/backend/src/test/test_llm_plugins_grey.py @@ -0,0 +1,386 @@ +"""M6 灰色插件(github-copilot / codex-chatgpt):装载 + device flow + parse。 + +从 ``plugins//`` 真实源码打包 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() diff --git a/backend/src/test/test_llm_providers_presets.py b/backend/src/test/test_llm_providers_presets.py new file mode 100644 index 00000000..3396af19 --- /dev/null +++ b/backend/src/test/test_llm_providers_presets.py @@ -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" diff --git a/backend/src/test/test_llm_providers_registry.py b/backend/src/test/test_llm_providers_registry.py new file mode 100644 index 00000000..12afbc14 --- /dev/null +++ b/backend/src/test/test_llm_providers_registry.py @@ -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 diff --git a/backend/src/test/test_loops.py b/backend/src/test/test_loops.py index 5c89e01d..60f36b86 100644 --- a/backend/src/test/test_loops.py +++ b/backend/src/test/test_loops.py @@ -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() diff --git a/backend/src/test/test_main_lifespan.py b/backend/src/test/test_main_lifespan.py index b2688df4..404ef944 100644 --- a/backend/src/test/test_main_lifespan.py +++ b/backend/src/test/test_main_lifespan.py @@ -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: diff --git a/backend/src/test/test_migrations_module.py b/backend/src/test/test_migrations_module.py index 1b66b9bf..85335a09 100644 --- a/backend/src/test/test_migrations_module.py +++ b/backend/src/test/test_migrations_module.py @@ -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) diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index d386f361..80d31784 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -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.""" diff --git a/backend/src/test/test_notification_inbox.py b/backend/src/test/test_notification_inbox.py new file mode 100644 index 00000000..d2f7cb8e --- /dev/null +++ b/backend/src/test/test_notification_inbox.py @@ -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) diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index f19e981e..01759dd1 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -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( diff --git a/backend/src/test/test_update.py b/backend/src/test/test_update.py index 4bec809b..8773117b 100644 --- a/backend/src/test/test_update.py +++ b/backend/src/test/test_update.py @@ -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 diff --git a/plugins/codex-chatgpt/ab_llm_codex/__init__.py b/plugins/codex-chatgpt/ab_llm_codex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/codex-chatgpt/ab_llm_codex/adapter.py b/plugins/codex-chatgpt/ab_llm_codex/adapter.py new file mode 100644 index 00000000..da9b67d7 --- /dev/null +++ b/plugins/codex-chatgpt/ab_llm_codex/adapter.py @@ -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"] diff --git a/plugins/codex-chatgpt/plugin.json b/plugins/codex-chatgpt/plugin.json new file mode 100644 index 00000000..54630856 --- /dev/null +++ b/plugins/codex-chatgpt/plugin.json @@ -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 客户端,第三方使用未获许可、可能被封锁。请自担风险。" +} diff --git a/plugins/github-copilot/ab_llm_copilot/__init__.py b/plugins/github-copilot/ab_llm_copilot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/github-copilot/ab_llm_copilot/adapter.py b/plugins/github-copilot/ab_llm_copilot/adapter.py new file mode 100644 index 00000000..7f2ba72d --- /dev/null +++ b/plugins/github-copilot/ab_llm_copilot/adapter.py @@ -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", [])) diff --git a/plugins/github-copilot/plugin.json b/plugins/github-copilot/plugin.json new file mode 100644 index 00000000..6f5be655 --- /dev/null +++ b/plugins/github-copilot/plugin.json @@ -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 条款并触发滥用检测。请自担风险。" +} diff --git a/webui/src/api/config.ts b/webui/src/api/config.ts index e009b617..1942ffa0 100644 --- a/webui/src/api/config.ts +++ b/webui/src/api/config.ts @@ -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; + provider: LLMProviderId; api_key: string; base_url: string; }) { diff --git a/webui/src/api/llm.ts b/webui/src/api/llm.ts new file mode 100644 index 00000000..3824deac --- /dev/null +++ b/webui/src/api/llm.ts @@ -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( + `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( + `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`); + }, +}; diff --git a/webui/src/api/notification.ts b/webui/src/api/notification.ts index f5dc794b..8ddf1a70 100644 --- a/webui/src/api/notification.ts +++ b/webui/src/api/notification.ts @@ -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 | 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( + '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'); + }, }; diff --git a/webui/src/components/ab-status-bar.vue b/webui/src/components/ab-status-bar.vue index 368400c6..2a1815cd 100644 --- a/webui/src/components/ab-status-bar.vue +++ b/webui/src/components/ab-status-bar.vue @@ -1,6 +1,6 @@ + + + + + diff --git a/webui/src/components/layout/ab-topbar.vue b/webui/src/components/layout/ab-topbar.vue index c9e6ad4c..a5bffa2b 100644 --- a/webui/src/components/layout/ab-topbar.vue +++ b/webui/src/components/layout/ab-topbar.vue @@ -128,6 +128,7 @@ onUnmounted(() => {
+ { .topbar-right { flex-shrink: 0; -} - -.topbar-right { margin-left: auto; + display: flex; + align-items: center; + gap: 2px; } diff --git a/webui/src/components/setting/config-llm.vue b/webui/src/components/setting/config-llm.vue index 47dd6b73..2a0ff05f 100644 --- a/webui/src/components/setting/config-llm.vue +++ b/webui/src/components/setting/config-llm.vue @@ -1,16 +1,115 @@ + + + + diff --git a/webui/src/hooks/useEventStream.ts b/webui/src/hooks/useEventStream.ts index d8f8f7fe..711fdcaa 100644 --- a/webui/src/hooks/useEventStream.ts +++ b/webui/src/hooks/useEventStream.ts @@ -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(null); const logData = ref(null); const updateData = ref(null); + const notificationData = ref(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, }; }); diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 66838bcd..caeb51e9 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -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}" + } + } } } diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 873f2ddd..07ec2588 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -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}" + } + } } } diff --git a/webui/src/store/notification.ts b/webui/src/store/notification.ts new file mode 100644 index 00000000..0fdd679b --- /dev/null +++ b/webui/src/store/notification.ts @@ -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([]); + 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, + }; +}); diff --git a/webui/types/config.ts b/webui/types/config.ts index 4b7b9c9a..7dc517ba 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -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; + 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; } /** @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, diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts index 88b4c70b..ebad9a54 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -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'] diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index 0cfbe9ce..15b46209 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -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']