mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: 修复插件市场空白和API缓存 (#6253)
This commit is contained in:
@@ -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
@@ -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
@@ -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.json(v1)
|
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
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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":
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 命中返回 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):
|
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
||||||
"""
|
"""
|
||||||
全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。
|
全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user