mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
feat: 修复插件市场空白和API缓存 (#6253)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 接口按请求仓库协调刷新两类数据。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user