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

View File

@@ -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,

View File

@@ -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),
)

View File

@@ -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_FLAGv1时返回空列表表示仅使用 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_FLAGv1时默认全部兼容否则需声明当前 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.jsonv1
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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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":
"""

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 [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

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 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

View File

@@ -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 命中返回 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):
"""
全市场刷新不清理 Release 缓存Release 接口按请求仓库协调刷新两类数据。

View File

@@ -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