feat: 修复插件市场空白和API缓存 (#6253)

This commit is contained in:
jxxghp
2026-08-08 21:40:06 +08:00
committed by GitHub
parent 173b8608e9
commit 4570a7b74a
10 changed files with 431 additions and 50 deletions
+8
View File
@@ -42,6 +42,10 @@ class SystemConfModel(BaseModel):
anilist: int = 0 anilist: int = 0
# Fanart请求缓存数量 # Fanart请求缓存数量
fanart: int = 0 fanart: int = 0
# MusicBrainz请求缓存数量
musicbrainz: int = 0
# ListenBrainz请求缓存数量
listenbrainz: int = 0
# 元数据缓存过期时间(秒) # 元数据缓存过期时间(秒)
meta: int = 0 meta: int = 0
# 调度器数量 # 调度器数量
@@ -990,6 +994,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
douban=512, douban=512,
bangumi=512, bangumi=512,
fanart=512, fanart=512,
musicbrainz=512,
listenbrainz=256,
meta=(self.META_CACHE_EXPIRE or 72) * 3600, meta=(self.META_CACHE_EXPIRE or 72) * 3600,
scheduler=100, scheduler=100,
threadpool=100, threadpool=100,
@@ -1001,6 +1007,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
douban=256, douban=256,
bangumi=256, bangumi=256,
fanart=128, fanart=128,
musicbrainz=256,
listenbrainz=128,
meta=(self.META_CACHE_EXPIRE or 24) * 3600, meta=(self.META_CACHE_EXPIRE or 24) * 3600,
scheduler=50, scheduler=50,
threadpool=50, threadpool=50,
+42 -32
View File
@@ -26,7 +26,7 @@ from app.core.event import eventmanager
from app.db.plugindata_oper import PluginDataOper from app.db.plugindata_oper import PluginDataOper
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.helper.server import MoviePilotServerHelper 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.helper.sites import SitesHelper # noqa
from app.log import logger from app.log import logger
from app.schemas.types import EventType, SystemConfigKey from app.schemas.types import EventType, SystemConfigKey
@@ -1336,37 +1336,43 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
if not settings.PLUGIN_MARKET: if not settings.PLUGIN_MARKET:
return [] return []
# 用于存储高于 v1 版本的插件(如 v2, v3 等) # 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取
higher_version_plugins = [] compatible_flags = (
# 用于存储 v1 版本插件 [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])
base_version_plugins = [] if settings.VERSION_FLAG else []
)
markets = [m for m in settings.PLUGIN_MARKET.split(",") if m]
# 使用多线程获取线上插件 # 使用多线程获取线上插件
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
futures_to_version = {} # future -> (market_index, is_higher, flag_priority)
for m in settings.PLUGIN_MARKET.split(","): futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {}
if not m: for market_index, m in enumerate(markets):
continue # 提交任务获取 v1 版本插件
# 提交任务获取 v1 版本插件,存储 future 到 version 的映射
base_future = executor.submit(self.get_plugins_from_market, m, None, force) 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: collected: List[Tuple[int, bool, int, List[schemas.Plugin]]] = []
higher_version_future = executor.submit(self.get_plugins_from_market, m, for future in concurrent.futures.as_completed(futures_meta):
settings.VERSION_FLAG, force)
futures_to_version[higher_version_future] = "higher_version"
# 按照完成顺序处理结果
for future in concurrent.futures.as_completed(futures_to_version):
plugins = future.result() 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: collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2]))
if version == "higher_version": higher_version_plugins: List[schemas.Plugin] = []
higher_version_plugins.extend(plugins) # 收集高版本插件 base_version_plugins: List[schemas.Plugin] = []
else: for _market_index, is_higher, _flag_priority, plugins in collected:
base_version_plugins.extend(plugins) # 收集 v1 版本插件 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) result = self.process_plugins_list(higher_version_plugins, base_version_plugins)
logger.info(f"获取到 {len(result)} 个线上插件") logger.info(f"获取到 {len(result)} 个线上插件")
@@ -1622,11 +1628,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
return None return None
plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy()) plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy())
# 如 package_version 为空,则需要判断插件是否兼容当前版本 # 如 package_version 为空package.json 来源),则需要判断插件是否兼容当前版本或任一向后兼容版本
if not package_version: if not package_version and not PluginHelper.is_plugin_info_compatible(plugin_info):
if plugin_info.get(settings.VERSION_FLAG) is not True: # 插件当前版本不兼容
# 插件当前版本不兼容 return None
return None
# 运行状插件 # 运行状插件
plugin_obj = self._running_plugins.get(pid) plugin_obj = self._running_plugins.get(pid)
@@ -1757,6 +1762,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
base_version_plugins = [] base_version_plugins = []
tasks = [] 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(","): for market in settings.PLUGIN_MARKET.split(","):
if not market: if not market:
continue continue
@@ -1765,12 +1775,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
fetch_market(market, None, "base_version", len(tasks)) fetch_market(market, None, "base_version", len(tasks))
) )
) )
if settings.VERSION_FLAG: for flag in compatible_flags:
tasks.append( tasks.append(
asyncio.create_task( asyncio.create_task(
fetch_market( fetch_market(
market, market,
settings.VERSION_FLAG, flag,
"higher_version", "higher_version",
len(tasks), len(tasks),
) )
+66 -18
View File
@@ -43,6 +43,11 @@ from version import APP_VERSION
PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins"
LOCAL_REPO_PREFIX = "local://" LOCAL_REPO_PREFIX = "local://"
PLUGIN_SYSTEM_VERSION_FIELD = "system_version" 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): class PluginHelper(metaclass=WeakSingleton):
@@ -156,6 +161,37 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"当前主程序版本号无法解析:{APP_VERSION}") logger.error(f"当前主程序版本号无法解析:{APP_VERSION}")
return None 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 @classmethod
def check_plugin_system_version(cls, plugin_info: Optional[dict]) -> Tuple[bool, str]: 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: if settings.VERSION_FLAG:
package_candidates.append((settings.VERSION_FLAG, self.__get_local_package(repo_path, package_candidates.append((settings.VERSION_FLAG, self.__get_local_package(repo_path,
settings.VERSION_FLAG))) 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))) package_candidates.append(("", self.__get_local_package(repo_path)))
for package_version, local_plugins in package_candidates: for package_version, local_plugins in package_candidates:
@@ -271,12 +310,8 @@ class PluginHelper(metaclass=WeakSingleton):
for pid, plugin_info in local_plugins.items(): for pid, plugin_info in local_plugins.items():
if not isinstance(plugin_info, dict): if not isinstance(plugin_info, dict):
continue continue
# package.json 中的旧结构需要声明兼容当前版本。 # package.json 中的旧结构需要声明兼容当前版本或任一向后兼容版本
if ( if not package_version and not self.is_plugin_info_compatible(plugin_info):
not package_version
and settings.VERSION_FLAG
and plugin_info.get(settings.VERSION_FLAG) is not True
):
continue continue
plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version) 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 package_version is None:
if settings.VERSION_FLAG: if settings.VERSION_FLAG:
package_versions.append(settings.VERSION_FLAG) package_versions.append(settings.VERSION_FLAG)
package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []))
package_versions.append("") package_versions.append("")
selected_candidate = None selected_candidate = None
for repo_order, local_repo_path in enumerate(self.get_local_repo_paths()): 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(): for candidate_pid, plugin_info in local_plugins.items():
if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict): if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict):
continue continue
is_compatible = not ( # 指定版本 package 文件视为可用;package.json 需声明当前版本或任一向后兼容版本。
not current_package_version is_compatible = (
and settings.VERSION_FLAG bool(current_package_version)
and plugin_info.get(settings.VERSION_FLAG) is not True or self.is_plugin_info_compatible(plugin_info)
) )
if not is_compatible and strict_compat: if not is_compatible and strict_compat:
continue continue
@@ -357,7 +393,9 @@ class PluginHelper(metaclass=WeakSingleton):
candidate["path"] = plugin_dir candidate["path"] = plugin_dir
if not is_compatible: if not is_compatible:
candidate["compatible"] = False 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) self.annotate_plugin_system_version(candidate)
if strict_system_version and candidate.get("system_version_compatible") is False: if strict_system_version and candidate.get("system_version_compatible") is False:
candidate["compatible"] = False candidate["compatible"] = False
@@ -575,14 +613,15 @@ class PluginHelper(metaclass=WeakSingleton):
检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测 检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测
1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置) 1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置)
2. 优先检查指定版本的插件(如 `package.v2.json` 2. 优先检查指定版本的插件(如 `package.v2.json`
3. 如果插件不存在于指定版本,检查 `package.json` 文件,查看该插件是否兼容指定版本 3. 向后兼容:检查更低版本的 package 文件,安装对应版本代码
4. 如果插件不存在或不兼容指定版本,返回 `None` 4. 检查 `package.json` 文件,插件声明当前版本或任一向后兼容版本均视为可用
5. 如果插件不存在或不兼容指定版本,返回 `None`
:param pid: 插件 ID,用于在插件列表中查找 :param pid: 插件 ID,用于在插件列表中查找
:param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址 :param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
:return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None :return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None
""" """
# 如果没有指定版本,则使用当前系统配置的版本(如 "v2" # 如果没有指定版本,则使用当前系统配置的版本(如 "v3"
if not package_version: if not package_version:
package_version = settings.VERSION_FLAG package_version = settings.VERSION_FLAG
@@ -590,10 +629,14 @@ class PluginHelper(metaclass=WeakSingleton):
if pid in (self.get_plugins(repo_url, package_version) or []): if pid in (self.get_plugins(repo_url, package_version) or []):
return package_version 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) plugin = (self.get_plugins(repo_url) or {}).get(pid, None)
# 检查插件是否明确支持当前指定的版本(如 v2 或 v3),如果支持,返回空字符串表示使用 package.jsonv1 if plugin and self.is_plugin_info_compatible(plugin):
if plugin and plugin.get(package_version) is True:
return "" return ""
# 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用 # 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用
@@ -2137,8 +2180,13 @@ class PluginHelper(metaclass=WeakSingleton):
if pid in (await self.async_get_plugins(repo_url, package_version) or []): if pid in (await self.async_get_plugins(repo_url, package_version) or []):
return package_version 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) 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 ""
return None return None
+1
View File
@@ -165,6 +165,7 @@ class ListenBrainzModule(_ModuleBase):
) )
@classmethod @classmethod
@cached(maxsize=settings.CONF.listenbrainz, ttl=settings.CONF.meta, skip_none=True)
def _request_json( def _request_json(
cls, cls,
path: str, path: str,
+2
View File
@@ -2,6 +2,7 @@ import threading
import time import time
from typing import Any, Optional, Tuple, Union from typing import Any, Optional, Tuple, Union
from app.core.cache import cached
from app.core.config import settings from app.core.config import settings
from app.core.music import ( from app.core.music import (
MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ALBUM,
@@ -582,6 +583,7 @@ class MusicBrainzModule(_ModuleBase):
cls._last_request_at = time.monotonic() cls._last_request_at = time.monotonic()
@classmethod @classmethod
@cached(maxsize=settings.CONF.musicbrainz, ttl=settings.CONF.meta, skip_none=True)
def _request_json( def _request_json(
cls, cls,
path: str, path: str,
+17
View File
@@ -133,6 +133,23 @@ class Subscribe(BaseModel):
model_config = ConfigDict(from_attributes=True) 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") @model_validator(mode="after")
def _fill_completed_episode(self) -> "Subscribe": def _fill_completed_episode(self) -> "Subscribe":
""" """
+64
View File
@@ -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 requested == {"days": 90, "sort": "release_date", "past": True, "future": False}
assert [item.media_id for item in results] == ["release-group-2"] assert [item.media_id for item in results] == ["release-group-2"]
assert results[0].music_type == "album" 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
+64
View File
@@ -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 [item.media_id for item in related] == ["artist-member", "artist-tribute"]
assert related[0].relation == "member of band" assert related[0].relation == "member of band"
assert related[0].music_type == "artist" 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
+78
View File
@@ -666,6 +666,84 @@ class TestPluginHelper:
assert plugins[0].plugin_label == "站点 通知" assert plugins[0].plugin_label == "站点 通知"
assert plugins[0].model_dump()["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 命中返回 v2package.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): def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
""" """
全市场刷新不清理 Release 缓存Release 接口按请求仓库协调刷新两类数据 全市场刷新不清理 Release 缓存Release 接口按请求仓库协调刷新两类数据
+89
View File
@@ -1066,3 +1066,92 @@ class _EndpointSubscribe:
async def async_update(self, _db, payload): async def async_update(self, _db, payload):
self.__dict__.update(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