diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index cc8e60819..218c65c25 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -4,12 +4,15 @@ from fastapi import APIRouter, Depends from app import schemas from app.chain.douban import DoubanChain +from app.core.config import settings from app.core.context import MediaInfo from app.core.security import verify_token from app.db.models.user import User +from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser_async from app.modules.douban.douban_cache import DoubanCache from app.schemas import MediaType +from app.schemas.types import SystemConfigKey router = APIRouter() @@ -29,6 +32,10 @@ async def douban_recognition_cache( "count": len(cache_items), "recognized": recognized_count, "unrecognized": len(cache_items) - recognized_count, + "shared_recognized": SystemConfigOper().get( + SystemConfigKey.MediaRecognizeShareCount + ) or 0, + "shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE, "data": cache_items, }, ) diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index 90e85127f..2093d3386 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -4,11 +4,13 @@ from fastapi import APIRouter, Depends from app import schemas from app.chain.tmdb import TmdbChain +from app.core.config import settings from app.core.security import verify_token from app.db.models.user import User +from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser_async from app.modules.themoviedb.tmdb_cache import TmdbCache -from app.schemas.types import MediaType +from app.schemas.types import MediaType, SystemConfigKey router = APIRouter() @@ -28,6 +30,10 @@ async def tmdb_recognition_cache( "count": len(cache_items), "recognized": recognized_count, "unrecognized": len(cache_items) - recognized_count, + "shared_recognized": SystemConfigOper().get( + SystemConfigKey.MediaRecognizeShareCount + ) or 0, + "shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE, "data": cache_items, }, ) diff --git a/app/chain/__init__.py b/app/chain/__init__.py index d1d93f692..062b04d3f 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -20,6 +20,7 @@ from app.core.meta import MetaBase from app.core.module import ModuleManager from app.core.plugin import PluginManager from app.db.message_oper import MessageOper +from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import UserOper from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper from app.helper.server import MoviePilotServerHelper @@ -49,6 +50,7 @@ from app.schemas.types import ( MediaImageType, EventType, MessageChannel, + SystemConfigKey, ) from app.utils.object import ObjectUtils @@ -572,6 +574,14 @@ class ChainBase(metaclass=ABCMeta): mediainfo=mediainfo, ) + @staticmethod + def _record_media_recognize_share_hit() -> None: + """记录一次共享媒体识别成功命中,统计失败不影响识别结果。""" + try: + SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount) + except Exception as err: + logger.error(f"记录共享媒体识别命中次数失败:{str(err)}") + @staticmethod def _resolve_media_source_params( source: Optional[str] = None, @@ -730,6 +740,7 @@ class ChainBase(metaclass=ABCMeta): ) if mediainfo: self._update_local_recognize_cache(shared_cache_meta, mediainfo) + self._record_media_recognize_share_hit() return mediainfo return None @@ -839,6 +850,7 @@ class ChainBase(metaclass=ABCMeta): ) if mediainfo: await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo) + await run_in_threadpool(self._record_media_recognize_share_hit) return mediainfo return None diff --git a/app/db/systemconfig_oper.py b/app/db/systemconfig_oper.py index 059381c09..a6c89c2d5 100644 --- a/app/db/systemconfig_oper.py +++ b/app/db/systemconfig_oper.py @@ -101,6 +101,19 @@ class SystemConfigOper(DbOper, metaclass=Singleton): # 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动 return copy.deepcopy(self.__SYSTEMCONF.get(key)) + def increment(self, key: SystemConfigKey, step: int = 1) -> int: + """ + 原子递增整数系统设置 + + :param key: 配置键 + :param step: 递增步长 + :return: 递增后的整数值 + """ + with self._rlock: + value = int(self.get(key) or 0) + step + self.set(key, value) + return value + def all(self): """ 获取所有系统设置 diff --git a/app/schemas/types.py b/app/schemas/types.py index d660d3931..79fa9c210 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -281,6 +281,8 @@ class SystemConfigKey(Enum): SetupWizardState = "SetupWizardState" # 绿联影视登录会话缓存 UgreenSessionCache = "UgreenSessionCache" + # 共享媒体识别成功次数 + MediaRecognizeShareCount = "MediaRecognizeShareCount" # 处理进度Key字典 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 09ade66bd..2d1fb4f6e 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -197,9 +197,15 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/tmdb/cache` | 查询 TheMovieDb 识别缓存及识别成功、失败条目统计 | +| GET | `/api/v1/tmdb/cache` | 查询 TheMovieDb 识别缓存统计、共享识别累计成功命中次数及开关状态 | | DELETE | `/api/v1/tmdb/cache/{cache_key}` | 按缓存键删除单条 TheMovieDb 识别缓存,缓存键需要进行 URL 编码 | | DELETE | `/api/v1/tmdb/cache` | 清空全部 TheMovieDb 识别缓存 | +| GET | `/api/v1/douban/cache` | 查询豆瓣识别缓存统计、共享识别累计成功命中次数及开关状态 | +| DELETE | `/api/v1/douban/cache/{cache_key}` | 按缓存键删除单条豆瓣识别缓存,缓存键需要进行 URL 编码 | +| DELETE | `/api/v1/douban/cache` | 清空全部豆瓣识别缓存 | + +缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`、`data`,以及共享识别统计字段 +`shared_recognized` 和开关字段 `shared_recognize_enabled`。共享命中次数仅在共享结果驱动的二次媒体识别成功后累计。 ### 插件补充接口 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 9c8e20302..bf191a07a 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -441,6 +441,20 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache | | POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` | +### Recognition Cache (6 endpoints) + +The two list endpoints return local cache totals plus `shared_recognized` and +`shared_recognize_enabled` for the persisted successful shared-recognition count. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/tmdb/cache` | Get TheMovieDb recognition cache statistics | +| DELETE | `/api/v1/tmdb/cache/{cache_key}` | Delete one URL-encoded TheMovieDb recognition cache key | +| DELETE | `/api/v1/tmdb/cache` | Clear TheMovieDb recognition cache | +| GET | `/api/v1/douban/cache` | Get Douban recognition cache statistics | +| DELETE | `/api/v1/douban/cache/{cache_key}` | Delete one URL-encoded Douban recognition cache key | +| DELETE | `/api/v1/douban/cache` | Clear Douban recognition cache | + ### Message (8 endpoints) | Method | Path | Description | diff --git a/tests/test_douban_cache_management.py b/tests/test_douban_cache_management.py index 01347f41f..85f3e226f 100644 --- a/tests/test_douban_cache_management.py +++ b/tests/test_douban_cache_management.py @@ -1,10 +1,11 @@ import asyncio import inspect +from unittest.mock import Mock from app.api.endpoints import douban as douban_endpoint from app.db.user_oper import get_current_active_superuser_async from app.modules.douban.douban_cache import DoubanCache -from app.schemas.types import MediaType +from app.schemas.types import MediaType, SystemConfigKey class _MemoryCacheStub: @@ -116,7 +117,14 @@ def test_douban_cache_endpoint_returns_management_statistics(monkeypatch): "recognized": {"id": "1", "title": "Alpha", "type": MediaType.MOVIE}, "unrecognized": {"id": 0}, }) + get_system_config = Mock(return_value=None) monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache) + monkeypatch.setattr( + douban_endpoint, + "SystemConfigOper", + lambda: type("SystemConfigStub", (), {"get": get_system_config})(), + ) + monkeypatch.setattr(douban_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", False) response = asyncio.run(douban_endpoint.douban_recognition_cache(None)) @@ -124,6 +132,11 @@ def test_douban_cache_endpoint_returns_management_statistics(monkeypatch): assert response.data["count"] == 2 assert response.data["recognized"] == 1 assert response.data["unrecognized"] == 1 + assert response.data["shared_recognized"] == 0 + assert response.data["shared_recognize_enabled"] is False + get_system_config.assert_called_once_with( + SystemConfigKey.MediaRecognizeShareCount + ) def test_douban_cache_delete_endpoint_reports_missing_item(monkeypatch): diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py new file mode 100644 index 000000000..50b380287 --- /dev/null +++ b/tests/test_media_recognize_share_statistics.py @@ -0,0 +1,134 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from app.chain import ChainBase +from app.core.context import MediaInfo +from app.core.meta import MetaBase +from app.helper.server import MoviePilotServerHelper +from app.schemas.types import MediaType, SystemConfigKey + + +def _build_meta(name: str) -> MetaBase: + """构造共享识别统计测试所需的媒体元数据。""" + meta = MetaBase(name) + meta.name = name + meta.type = MediaType.UNKNOWN + return meta + + +def _shared_params(tmdb_id: int) -> dict: + """构造共享识别结果转换后的模块参数。""" + return { + "mtype": MediaType.MOVIE, + "source": "themoviedb", + "mediaid": str(tmdb_id), + "tmdbid": tmdb_id, + "doubanid": None, + "bangumiid": None, + "anilistid": None, + } + + +def _mock_counter(monkeypatch) -> Mock: + """替换系统配置持久化入口并返回递增调用桩。""" + increment = Mock() + monkeypatch.setattr( + "app.chain.SystemConfigOper", + lambda: SimpleNamespace(increment=increment), + ) + return increment + + +def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): + """同步共享识别二次识别成功后应累计一次命中。""" + chain = object.__new__(ChainBase) + meta = _build_meta("共享识别电影") + media = MediaInfo( + title="共享识别电影", + year="2026", + tmdb_id=101, + type=MediaType.MOVIE, + ) + increment = _mock_counter(monkeypatch) + monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, media])) + monkeypatch.setattr(chain, "_update_local_recognize_cache", Mock()) + monkeypatch.setattr( + MoviePilotServerHelper, + "query_recognize_share", + Mock(return_value={"type": "movie", "tmdbid": 101}), + ) + monkeypatch.setattr( + MoviePilotServerHelper, + "to_recognize_params", + Mock(return_value=_shared_params(101)), + ) + + result = chain.recognize_media(meta=meta, cache=False) + + assert result is media + increment.assert_called_once_with(SystemConfigKey.MediaRecognizeShareCount) + + +def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): + """共享接口返回数据但二次识别失败时不应累计命中。""" + chain = object.__new__(ChainBase) + meta = _build_meta("共享识别失败电影") + increment = _mock_counter(monkeypatch) + monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, None])) + monkeypatch.setattr( + MoviePilotServerHelper, + "query_recognize_share", + Mock(return_value={"type": "movie", "tmdbid": 102}), + ) + monkeypatch.setattr( + MoviePilotServerHelper, + "to_recognize_params", + Mock(return_value=_shared_params(102)), + ) + + result = chain.recognize_media(meta=meta, cache=False) + + assert result is None + increment.assert_not_called() + + +def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): + """异步共享识别二次识别成功后应累计一次命中。""" + chain = object.__new__(ChainBase) + meta = _build_meta("异步共享识别电影") + media = MediaInfo( + title="异步共享识别电影", + year="2026", + tmdb_id=103, + type=MediaType.MOVIE, + ) + increment = _mock_counter(monkeypatch) + monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr( + chain, + "async_run_module", + AsyncMock(side_effect=[None, media]), + ) + monkeypatch.setattr( + chain, + "_async_update_local_recognize_cache", + AsyncMock(), + ) + monkeypatch.setattr( + MoviePilotServerHelper, + "async_query_recognize_share", + AsyncMock(return_value={"type": "movie", "tmdbid": 103}), + ) + monkeypatch.setattr( + MoviePilotServerHelper, + "to_recognize_params", + Mock(return_value=_shared_params(103)), + ) + + result = asyncio.run(chain.async_recognize_media(meta=meta, cache=False)) + + assert result is media + increment.assert_called_once_with(SystemConfigKey.MediaRecognizeShareCount) diff --git a/tests/test_systemconfig_oper.py b/tests/test_systemconfig_oper.py new file mode 100644 index 000000000..e28e7f0f8 --- /dev/null +++ b/tests/test_systemconfig_oper.py @@ -0,0 +1,51 @@ +import threading +from concurrent.futures import ThreadPoolExecutor + +from app.db.systemconfig_oper import SystemConfigOper +from app.schemas.types import SystemConfigKey + + +def test_increment_serializes_concurrent_counter_updates(monkeypatch): + """并发递增系统计数时不应丢失更新。""" + oper = object.__new__(SystemConfigOper) + oper._rlock = threading.RLock() + stored_value = {"value": 0} + + monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"]) + monkeypatch.setattr( + oper, + "set", + lambda _key, value: stored_value.update(value=value), + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list( + executor.map( + lambda _index: oper.increment( + SystemConfigKey.MediaRecognizeShareCount + ), + range(100), + ) + ) + + assert sorted(results) == list(range(1, 101)) + assert stored_value["value"] == 100 + + +def test_increment_supports_custom_step(monkeypatch): + """整数系统计数应支持指定递增步长。""" + oper = object.__new__(SystemConfigOper) + oper._rlock = threading.RLock() + stored_value = {"value": 4} + + monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"]) + monkeypatch.setattr( + oper, + "set", + lambda _key, value: stored_value.update(value=value), + ) + + result = oper.increment(SystemConfigKey.MediaRecognizeShareCount, step=3) + + assert result == 7 + assert stored_value["value"] == 7 diff --git a/tests/test_tmdb_cache_management.py b/tests/test_tmdb_cache_management.py index 3ce1b5788..beccfc7a9 100644 --- a/tests/test_tmdb_cache_management.py +++ b/tests/test_tmdb_cache_management.py @@ -1,10 +1,11 @@ import asyncio import inspect +from unittest.mock import Mock from app.api.endpoints import tmdb as tmdb_endpoint from app.db.user_oper import get_current_active_superuser_async from app.modules.themoviedb.tmdb_cache import TmdbCache -from app.schemas.types import MediaType +from app.schemas.types import MediaType, SystemConfigKey class _MemoryCacheStub: @@ -97,7 +98,14 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch): "recognized": {"id": 1, "title": "Alpha", "type": MediaType.MOVIE}, "unrecognized": {"id": 0}, }) + get_system_config = Mock(return_value=7) monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache) + monkeypatch.setattr( + tmdb_endpoint, + "SystemConfigOper", + lambda: type("SystemConfigStub", (), {"get": get_system_config})(), + ) + monkeypatch.setattr(tmdb_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", True) response = asyncio.run(tmdb_endpoint.tmdb_recognition_cache(None)) @@ -105,6 +113,11 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch): assert response.data["count"] == 2 assert response.data["recognized"] == 1 assert response.data["unrecognized"] == 1 + assert response.data["shared_recognized"] == 7 + assert response.data["shared_recognize_enabled"] is True + get_system_config.assert_called_once_with( + SystemConfigKey.MediaRecognizeShareCount + ) def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):