From 4570a7b74aff9035e49f1aae43c3276df0b9acad Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 8 Aug 2026 21:40:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E7=A9=BA=E7=99=BD=E5=92=8CAPI=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20(#6253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 8 +++ app/core/plugin.py | 74 +++++++++++++---------- app/helper/plugin.py | 84 ++++++++++++++++++++------ app/modules/listenbrainz/__init__.py | 1 + app/modules/musicbrainz/__init__.py | 2 + app/schemas/subscribe.py | 17 ++++++ tests/test_listenbrainz_module.py | 64 ++++++++++++++++++++ tests/test_musicbrainz_module.py | 64 ++++++++++++++++++++ tests/test_plugin_helper.py | 78 ++++++++++++++++++++++++ tests/test_subscribe_endpoint.py | 89 ++++++++++++++++++++++++++++ 10 files changed, 431 insertions(+), 50 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 92b8cf12b..d2549dc52 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -42,6 +42,10 @@ class SystemConfModel(BaseModel): anilist: int = 0 # Fanart请求缓存数量 fanart: int = 0 + # MusicBrainz请求缓存数量 + musicbrainz: int = 0 + # ListenBrainz请求缓存数量 + listenbrainz: int = 0 # 元数据缓存过期时间(秒) meta: int = 0 # 调度器数量 @@ -990,6 +994,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): douban=512, bangumi=512, fanart=512, + musicbrainz=512, + listenbrainz=256, meta=(self.META_CACHE_EXPIRE or 72) * 3600, scheduler=100, threadpool=100, @@ -1001,6 +1007,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): douban=256, bangumi=256, fanart=128, + musicbrainz=256, + listenbrainz=128, meta=(self.META_CACHE_EXPIRE or 24) * 3600, scheduler=50, threadpool=50, diff --git a/app/core/plugin.py b/app/core/plugin.py index 8fc8954d7..1409ddb2b 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -26,7 +26,7 @@ from app.core.event import eventmanager from app.db.plugindata_oper import PluginDataOper from app.db.systemconfig_oper import SystemConfigOper from app.helper.server import MoviePilotServerHelper -from app.helper.plugin import PluginHelper +from app.helper.plugin import PluginHelper, VERSION_BACKWARD_COMPATIBLE_FLAGS from app.helper.sites import SitesHelper # noqa from app.log import logger from app.schemas.types import EventType, SystemConfigKey @@ -1336,37 +1336,43 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if not settings.PLUGIN_MARKET: return [] - # 用于存储高于 v1 版本的插件(如 v2, v3 等) - higher_version_plugins = [] - # 用于存储 v1 版本插件 - base_version_plugins = [] + # 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取 + compatible_flags = ( + [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) + if settings.VERSION_FLAG else [] + ) + markets = [m for m in settings.PLUGIN_MARKET.split(",") if m] # 使用多线程获取线上插件 with concurrent.futures.ThreadPoolExecutor() as executor: - futures_to_version = {} - for m in settings.PLUGIN_MARKET.split(","): - if not m: - continue - # 提交任务获取 v1 版本插件,存储 future 到 version 的映射 + # future -> (market_index, is_higher, flag_priority) + futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {} + for market_index, m in enumerate(markets): + # 提交任务获取 v1 版本插件 base_future = executor.submit(self.get_plugins_from_market, m, None, force) - futures_to_version[base_future] = "base_version" + futures_meta[base_future] = (market_index, False, 0) + # 提交任务获取高版本插件(如 v3)及向后兼容版本(如 v2) + for flag_priority, flag in enumerate(compatible_flags): + higher_future = executor.submit(self.get_plugins_from_market, m, flag, force) + futures_meta[higher_future] = (market_index, True, flag_priority) - # 提交任务获取高版本插件(如 v2、v3),存储 future 到 version 的映射 - if settings.VERSION_FLAG: - higher_version_future = executor.submit(self.get_plugins_from_market, m, - settings.VERSION_FLAG, force) - futures_to_version[higher_version_future] = "higher_version" - - # 按照完成顺序处理结果 - for future in concurrent.futures.as_completed(futures_to_version): + # 收集结果,按市场顺序、高版本优先、兼容版本优先级排序,保证去重时优先保留高版本来源 + collected: List[Tuple[int, bool, int, List[schemas.Plugin]]] = [] + for future in concurrent.futures.as_completed(futures_meta): plugins = future.result() - version = futures_to_version[future] + market_index, is_higher, flag_priority = futures_meta[future] + collected.append((market_index, is_higher, flag_priority, plugins or [])) - if plugins: - if version == "higher_version": - higher_version_plugins.extend(plugins) # 收集高版本插件 - else: - base_version_plugins.extend(plugins) # 收集 v1 版本插件 + collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2])) + higher_version_plugins: List[schemas.Plugin] = [] + base_version_plugins: List[schemas.Plugin] = [] + for _market_index, is_higher, _flag_priority, plugins in collected: + if not plugins: + continue + if is_higher: + higher_version_plugins.extend(plugins) + else: + base_version_plugins.extend(plugins) result = self.process_plugins_list(higher_version_plugins, base_version_plugins) logger.info(f"获取到 {len(result)} 个线上插件") @@ -1622,11 +1628,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return None plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy()) - # 如 package_version 为空,则需要判断插件是否兼容当前版本 - if not package_version: - if plugin_info.get(settings.VERSION_FLAG) is not True: - # 插件当前版本不兼容 - return None + # 如 package_version 为空(package.json 来源),则需要判断插件是否兼容当前版本或任一向后兼容版本 + if not package_version and not PluginHelper.is_plugin_info_compatible(plugin_info): + # 插件当前版本不兼容 + return None # 运行状插件 plugin_obj = self._running_plugins.get(pid) @@ -1757,6 +1762,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): base_version_plugins = [] tasks = [] + # 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取 + compatible_flags = ( + [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) + if settings.VERSION_FLAG else [] + ) for market in settings.PLUGIN_MARKET.split(","): if not market: continue @@ -1765,12 +1775,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): fetch_market(market, None, "base_version", len(tasks)) ) ) - if settings.VERSION_FLAG: + for flag in compatible_flags: tasks.append( asyncio.create_task( fetch_market( market, - settings.VERSION_FLAG, + flag, "higher_version", len(tasks), ) diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 5c1494bf1..caff7f35a 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -43,6 +43,11 @@ from version import APP_VERSION PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" LOCAL_REPO_PREFIX = "local://" PLUGIN_SYSTEM_VERSION_FIELD = "system_version" +# 主程序重大版本向后兼容声明:键为当前 VERSION_FLAG,值为该版本可向下兼容的更低版本标识列表(按优先级降序)。 +# 例如 v3 兼容 v2,则 package.json 中声明 "v2": true 的插件、package.v2.json 中的插件均视为可用。 +VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = { + "v3": ["v2"], +} class PluginHelper(metaclass=WeakSingleton): @@ -156,6 +161,37 @@ class PluginHelper(metaclass=WeakSingleton): logger.error(f"当前主程序版本号无法解析:{APP_VERSION}") return None + @classmethod + def get_compatible_version_flags(cls) -> List[str]: + """ + 返回当前主程序版本可兼容的全部版本标识,包含自身及向后兼容的低版本,按优先级降序。 + 未启用 VERSION_FLAG(v1)时返回空列表,表示仅使用 package.json 基础索引。 + """ + flags: List[str] = [] + if settings.VERSION_FLAG: + flags.append(settings.VERSION_FLAG) + flags.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])) + return flags + + @classmethod + def is_plugin_info_compatible(cls, plugin_info: Optional[dict]) -> bool: + """ + 判断 package.json 中的插件元数据是否兼容当前主程序版本。 + + 兼容条件:未启用 VERSION_FLAG(v1)时默认全部兼容;否则需声明当前 VERSION_FLAG 为 True, + 或声明任一向后兼容的低版本标识为 True。 + """ + if not isinstance(plugin_info, dict): + return False + if not settings.VERSION_FLAG: + return True + if plugin_info.get(settings.VERSION_FLAG) is True: + return True + for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []): + if plugin_info.get(flag) is True: + return True + return False + @classmethod def check_plugin_system_version(cls, plugin_info: Optional[dict]) -> Tuple[bool, str]: """ @@ -263,6 +299,9 @@ class PluginHelper(metaclass=WeakSingleton): if settings.VERSION_FLAG: package_candidates.append((settings.VERSION_FLAG, self.__get_local_package(repo_path, settings.VERSION_FLAG))) + # 向后兼容:补充扫描更低版本的 package 文件,便于本地仓库复用历史版本插件。 + for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []): + package_candidates.append((backward_flag, self.__get_local_package(repo_path, backward_flag))) package_candidates.append(("", self.__get_local_package(repo_path))) for package_version, local_plugins in package_candidates: @@ -271,12 +310,8 @@ class PluginHelper(metaclass=WeakSingleton): for pid, plugin_info in local_plugins.items(): if not isinstance(plugin_info, dict): continue - # package.json 中的旧结构需要声明兼容当前版本。 - if ( - not package_version - and settings.VERSION_FLAG - and plugin_info.get(settings.VERSION_FLAG) is not True - ): + # package.json 中的旧结构需要声明兼容当前版本或任一向后兼容版本。 + if not package_version and not self.is_plugin_info_compatible(plugin_info): continue plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version) @@ -326,6 +361,7 @@ class PluginHelper(metaclass=WeakSingleton): if package_version is None: if settings.VERSION_FLAG: package_versions.append(settings.VERSION_FLAG) + package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])) package_versions.append("") selected_candidate = None for repo_order, local_repo_path in enumerate(self.get_local_repo_paths()): @@ -338,10 +374,10 @@ class PluginHelper(metaclass=WeakSingleton): for candidate_pid, plugin_info in local_plugins.items(): if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict): continue - is_compatible = not ( - not current_package_version - and settings.VERSION_FLAG - and plugin_info.get(settings.VERSION_FLAG) is not True + # 指定版本 package 文件视为可用;package.json 需声明当前版本或任一向后兼容版本。 + is_compatible = ( + bool(current_package_version) + or self.is_plugin_info_compatible(plugin_info) ) if not is_compatible and strict_compat: continue @@ -357,7 +393,9 @@ class PluginHelper(metaclass=WeakSingleton): candidate["path"] = plugin_dir if not is_compatible: candidate["compatible"] = False - candidate["skip_reason"] = f"package.json 未声明 {settings.VERSION_FLAG} 兼容" + candidate["skip_reason"] = ( + f"package.json 未声明 {settings.VERSION_FLAG} 或向后兼容版本" + ) self.annotate_plugin_system_version(candidate) if strict_system_version and candidate.get("system_version_compatible") is False: candidate["compatible"] = False @@ -575,14 +613,15 @@ class PluginHelper(metaclass=WeakSingleton): 检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测 1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置) 2. 优先检查指定版本的插件(如 `package.v2.json`) - 3. 如果插件不存在于指定版本,检查 `package.json` 文件,查看该插件是否兼容指定版本 - 4. 如果插件不存在或不兼容指定版本,返回 `None` + 3. 向后兼容:检查更低版本的 package 文件,安装对应版本代码 + 4. 检查 `package.json` 文件,插件声明当前版本或任一向后兼容版本均视为可用 + 5. 如果插件不存在或不兼容指定版本,返回 `None` :param pid: 插件 ID,用于在插件列表中查找 :param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None """ - # 如果没有指定版本,则使用当前系统配置的版本(如 "v2") + # 如果没有指定版本,则使用当前系统配置的版本(如 "v3") if not package_version: package_version = settings.VERSION_FLAG @@ -590,10 +629,14 @@ class PluginHelper(metaclass=WeakSingleton): if pid in (self.get_plugins(repo_url, package_version) or []): return package_version - # 如果指定版本的插件不存在,检查全局 package.json 文件,查看插件是否兼容指定的版本 + # 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码 + for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): + if pid in (self.get_plugins(repo_url, backward_flag) or []): + return backward_flag + + # 检查全局 package.json 文件,插件声明当前版本或任一向后兼容版本均视为可用,安装基础代码 plugin = (self.get_plugins(repo_url) or {}).get(pid, None) - # 检查插件是否明确支持当前指定的版本(如 v2 或 v3),如果支持,返回空字符串表示使用 package.json(v1) - if plugin and plugin.get(package_version) is True: + if plugin and self.is_plugin_info_compatible(plugin): return "" # 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用 @@ -2137,8 +2180,13 @@ class PluginHelper(metaclass=WeakSingleton): if pid in (await self.async_get_plugins(repo_url, package_version) or []): return package_version + # 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码 + for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): + if pid in (await self.async_get_plugins(repo_url, backward_flag) or []): + return backward_flag + plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None) - if plugin and plugin.get(package_version) is True: + if plugin and self.is_plugin_info_compatible(plugin): return "" return None diff --git a/app/modules/listenbrainz/__init__.py b/app/modules/listenbrainz/__init__.py index 3a4f0079f..56e4bbc46 100644 --- a/app/modules/listenbrainz/__init__.py +++ b/app/modules/listenbrainz/__init__.py @@ -165,6 +165,7 @@ class ListenBrainzModule(_ModuleBase): ) @classmethod + @cached(maxsize=settings.CONF.listenbrainz, ttl=settings.CONF.meta, skip_none=True) def _request_json( cls, path: str, diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 18df44f7f..d630ce2fc 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -2,6 +2,7 @@ import threading import time from typing import Any, Optional, Tuple, Union +from app.core.cache import cached from app.core.config import settings from app.core.music import ( MUSIC_ENTITY_ALBUM, @@ -582,6 +583,7 @@ class MusicBrainzModule(_ModuleBase): cls._last_request_at = time.monotonic() @classmethod + @cached(maxsize=settings.CONF.musicbrainz, ttl=settings.CONF.meta, skip_none=True) def _request_json( cls, path: str, diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 482729dff..874e3b51d 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -133,6 +133,23 @@ class Subscribe(BaseModel): model_config = ConfigDict(from_attributes=True) + @model_validator(mode="before") + @classmethod + def _normalize_empty_strings(cls, data: Any) -> Any: + """ + 将前端清空输入框后残留的空字符串视为未提供,移除该键由字段默认值兜底。 + + 音乐等媒体类型的 tmdbid、season、total_episode、episode_priority 等数值或容器字段 + 在表单中常以空字符串提交,而 Pydantic 不会把空字符串自动转为 None,会直接抛出 + 校验异常导致接口返回 422。这里把空字符串键移除,等价于该字段未提供,从而复用字段 + 默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。 + """ + if isinstance(data, dict): + for key, value in list(data.items()): + if isinstance(value, str) and value == "": + data.pop(key) + return data + @model_validator(mode="after") def _fill_completed_episode(self) -> "Subscribe": """ diff --git a/tests/test_listenbrainz_module.py b/tests/test_listenbrainz_module.py index d2ece7cd1..4048ae9d9 100644 --- a/tests/test_listenbrainz_module.py +++ b/tests/test_listenbrainz_module.py @@ -145,3 +145,67 @@ def test_music_fresh_releases_pages_official_window(monkeypatch): assert requested == {"days": 90, "sort": "release_date", "past": True, "future": False} assert [item.media_id for item in results] == ["release-group-2"] assert results[0].music_type == "album" + + +class _FakeListenBrainzResponse: + """模拟 ListenBrainz HTTP 响应,便于缓存回归测试统计网络调用次数。""" + + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.text = "" + + def json(self): + """返回预设的 JSON 负载。""" + return self._payload + + def close(self): + """无需释放的资源。""" + + +def test_request_json_caches_repeated_calls(monkeypatch): + """相同路径与参数的 ListenBrainz 请求应命中缓存,避免重复发起网络调用。""" + import app.modules.listenbrainz as listenbrainz_module + + network_calls = {"count": 0} + + def fake_get_res(_self, url, params=None): + """记录网络调用次数并返回固定的榜单负载。""" + network_calls["count"] += 1 + return _FakeListenBrainzResponse( + {"payload": {"recordings": [{"recording_mbid": "recording-cache", "track_name": "晴天"}]}} + ) + + monkeypatch.setattr(listenbrainz_module.RequestUtils, "get_res", fake_get_res) + # 清理缓存区,排除其他用例残留 + ListenBrainzModule._request_json.cache_clear() + + first = ListenBrainzModule._request_json( + "/stats/sitewide/recordings", params={"range": "this_week", "offset": 0, "count": 30} + ) + second = ListenBrainzModule._request_json( + "/stats/sitewide/recordings", params={"range": "this_week", "offset": 0, "count": 30} + ) + + assert first == second + assert network_calls["count"] == 1 + + +def test_request_json_does_not_cache_errors(monkeypatch): + """失败请求返回的 None 不应缓存,以便下次重试。""" + import app.modules.listenbrainz as listenbrainz_module + + network_calls = {"count": 0} + + def fake_get_res(_self, url, params=None): + """始终返回 500,用于验证空结果不会被缓存。""" + network_calls["count"] += 1 + return _FakeListenBrainzResponse(None, status_code=500) + + monkeypatch.setattr(listenbrainz_module.RequestUtils, "get_res", fake_get_res) + ListenBrainzModule._request_json.cache_clear() + + ListenBrainzModule._request_json("/stats/sitewide/recordings", params={"range": "this_week"}) + ListenBrainzModule._request_json("/stats/sitewide/recordings", params={"range": "this_week"}) + + assert network_calls["count"] == 2 diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index 36dd8ade6..0e485be86 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -368,3 +368,67 @@ def test_music_artist_related_prefers_meaningful_relations(monkeypatch): assert [item.media_id for item in related] == ["artist-member", "artist-tribute"] assert related[0].relation == "member of band" assert related[0].music_type == "artist" + + +class _FakeMusicBrainzResponse: + """模拟 MusicBrainz HTTP 响应,便于缓存回归测试统计网络调用次数。""" + + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.text = "" + + def json(self): + """返回预设的 JSON 负载。""" + return self._payload + + def close(self): + """无需释放的资源。""" + + +def test_request_json_caches_repeated_calls(monkeypatch): + """相同路径与参数的 MusicBrainz 请求应命中缓存,避免重复发起网络调用。""" + import app.modules.musicbrainz as musicbrainz_module + + monkeypatch.setattr( + MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None) + ) + network_calls = {"count": 0} + + def fake_get_res(_self, url, params=None): + """记录网络调用次数并返回固定的录音详情。""" + network_calls["count"] += 1 + return _FakeMusicBrainzResponse({"id": "recording-cache", "title": "晴天"}) + + monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res) + # 清理缓存区,排除其他用例残留 + MusicBrainzModule._request_json.cache_clear() + + first = MusicBrainzModule._request_json("/recording/recording-cache", params={"fmt": "json"}) + second = MusicBrainzModule._request_json("/recording/recording-cache", params={"fmt": "json"}) + + assert first == second + assert network_calls["count"] == 1 + + +def test_request_json_does_not_cache_not_found(monkeypatch): + """404 等空结果不应缓存,以便后续重新探测单曲与专辑入口。""" + import app.modules.musicbrainz as musicbrainz_module + + monkeypatch.setattr( + MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None) + ) + network_calls = {"count": 0} + + def fake_get_res(_self, url, params=None): + """始终返回 404,用于验证空结果不会被缓存。""" + network_calls["count"] += 1 + return _FakeMusicBrainzResponse(None, status_code=404) + + monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res) + MusicBrainzModule._request_json.cache_clear() + + MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"}) + MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"}) + + assert network_calls["count"] == 2 diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 421a00c05..95bef343a 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -666,6 +666,84 @@ class TestPluginHelper: assert plugins[0].plugin_label == "站点 通知" assert plugins[0].model_dump()["plugin_label"] == "站点 通知" + def test_get_online_plugins_includes_backward_compatible_v2_plugins(self, monkeypatch) -> None: + """ + V3 升级后插件市场不应空白:需同时展示 package.json 中声明 v2 兼容的插件、 + package.v2.json 中的 v2 原生插件,并过滤掉未声明任何版本兼容的 v1 插件。 + """ + try: + from app.core.plugin import PluginManager + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + base_plugins = { + "V2FlagPlugin": {"name": "V2Flag", "version": "1.0.0", "v2": True, "level": 1}, + "LegacyPlugin": {"name": "Legacy", "version": "1.0.0", "level": 1}, + } + v2_native_plugins = { + "V2NativePlugin": {"name": "V2Native", "version": "1.0.0", "level": 1}, + } + + def fake_get_plugins(_self, _repo_url, package_version=None): + # package.v3.json 不存在(404 → 空字典),package.v2.json 返回 v2 原生插件 + if package_version == "v3": + return {} + if package_version == "v2": + return v2_native_plugins + return base_plugins + + plugin_manager = PluginManager() + monkeypatch.setattr(plugin_manager, "_plugins", {}) + monkeypatch.setattr(plugin_manager, "_running_plugins", {}) + monkeypatch.setattr( + "app.core.plugin.settings", + SimpleNamespace(VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL), + ) + monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(VERSION_FLAG="v3")) + monkeypatch.setattr("app.core.plugin.SystemConfigOper", lambda: SimpleNamespace(get=lambda _key: [])) + monkeypatch.setattr("app.core.plugin.SitesHelper", lambda: SimpleNamespace(auth_level=1)) + monkeypatch.setattr(PluginHelper, "get_plugins", fake_get_plugins) + + plugins = plugin_manager.get_online_plugins(force=False) + plugin_ids = {p.id for p in plugins} + + assert "V2FlagPlugin" in plugin_ids + assert "V2NativePlugin" in plugin_ids + assert "LegacyPlugin" not in plugin_ids + + def test_get_plugin_package_version_resolves_backward_compatible_v2_sources(self, monkeypatch) -> None: + """ + V3 安装链路应能解析 v2 兼容插件:package.v2.json 命中返回 v2,package.json 声明 v2 返回基础版本。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + base_plugins = { + "V2FlagPlugin": {"name": "V2Flag", "version": "1.0.0", "v2": True}, + "LegacyPlugin": {"name": "Legacy", "version": "1.0.0"}, + } + v2_native_plugins = { + "V2NativePlugin": {"name": "V2Native", "version": "1.0.0"}, + } + + def fake_get_plugins(_self, _repo_url, package_version=None): + if package_version == "v3": + return {} + if package_version == "v2": + return v2_native_plugins + return base_plugins + + monkeypatch.setattr("app.helper.plugin.settings", SimpleNamespace(VERSION_FLAG="v3")) + helper = PluginHelper.__new__(PluginHelper) + monkeypatch.setattr(PluginHelper, "get_plugins", fake_get_plugins) + + assert helper.get_plugin_package_version("V2NativePlugin", REPO_URL) == "v2" + assert helper.get_plugin_package_version("V2FlagPlugin", REPO_URL) == "" + assert helper.get_plugin_package_version("LegacyPlugin", REPO_URL) is None + def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch): """ 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 746da0bf1..9b8c86b83 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -1066,3 +1066,92 @@ class _EndpointSubscribe: async def async_update(self, _db, payload): self.__dict__.update(payload) + + +def test_subscribe_accepts_empty_strings_for_numeric_fields(): + """前端提交音乐订阅时常以空字符串填充数值字段,不应触发 422。""" + subscribe = Subscribe( + name="Random Access Memories", + type=MediaType.MUSIC.value, + tmdbid="", + bangumiid="", + anilistid="", + season="", + total_episode="", + start_episode="", + best_version="", + best_version_full="", + current_priority="", + search_imdbid="", + vote="", + episode_priority="", + sites="", + filter_groups="", + ) + + assert subscribe.tmdbid is None + assert subscribe.season is None + assert subscribe.best_version is None + assert subscribe.episode_priority is None + # 空字符串视为未提供,应回退到字段默认值而非 None + assert subscribe.total_episode == 0 + assert subscribe.start_episode == 0 + assert subscribe.search_imdbid == 0 + assert subscribe.vote == 0.0 + assert subscribe.sites == [] + assert subscribe.filter_groups == [] + assert subscribe.type == MediaType.MUSIC.value + + +def test_subscribe_preserves_explicit_zero_and_numeric_string_values(): + """显式 0 和数字字符串应保持原有行为,不被空字符串归一化影响。""" + subscribe = Subscribe( + name="测试剧集", + type=MediaType.TV.value, + season="2", + tmdbid="123", + total_episode=0, + start_episode=0, + search_imdbid=0, + vote=0.0, + ) + + assert subscribe.season == 2 + assert subscribe.tmdbid == 123 + assert subscribe.total_episode == 0 + assert subscribe.start_episode == 0 + assert subscribe.search_imdbid == 0 + assert subscribe.vote == 0.0 + + +def test_create_subscribe_accepts_music_payload_with_empty_strings(): + """带空字符串的音乐订阅应能通过新增订阅接口,不返回 422。""" + subscribe_in = Subscribe( + name="Random Access Memories", + type=MediaType.MUSIC.value, + tmdbid="", + season="", + total_episode="", + episode_priority="", + sites="", + ) + + with patch( + "app.api.endpoints.subscribe.SubscribeChain.async_add", + new=AsyncMock(return_value=(1, "新增订阅成功")), + ) as async_add: + response = asyncio.run( + create_subscribe( + subscribe_in=subscribe_in, + current_user=_EndpointUser(name="moviepilot-user", is_superuser=False), + ) + ) + + assert response.success is True + payload = async_add.await_args.kwargs + # 空字符串回退默认值后应正确传入持久化链路 + assert payload["tmdbid"] is None + assert payload["total_episode"] == 0 + assert payload["sites"] == [] + assert payload["type"] == MediaType.MUSIC.value +