feat: 统计共享媒体识别命中次数

This commit is contained in:
jxxghp
2026-07-30 17:50:16 +08:00
parent c976741574
commit a23fce1491
11 changed files with 275 additions and 4 deletions
+7
View File
@@ -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,
},
)
+7 -1
View File
@@ -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,
},
)
+12
View File
@@ -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
+13
View File
@@ -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):
"""
获取所有系统设置
+2
View File
@@ -281,6 +281,8 @@ class SystemConfigKey(Enum):
SetupWizardState = "SetupWizardState"
# 绿联影视登录会话缓存
UgreenSessionCache = "UgreenSessionCache"
# 共享媒体识别成功次数
MediaRecognizeShareCount = "MediaRecognizeShareCount"
# 处理进度Key字典
+7 -1
View File
@@ -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`。共享命中次数仅在共享结果驱动的二次媒体识别成功后累计。
### 插件补充接口
+14
View File
@@ -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 |
+14 -1
View File
@@ -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):
@@ -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)
+51
View File
@@ -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
+14 -1
View File
@@ -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):