Files
MoviePilot/app/application/plugin/catalog.py
T

339 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件市场目录应用服务。"""
from __future__ import annotations
import asyncio
import concurrent.futures
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Optional
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
TrustedPluginSourceType,
)
from app.schemas.plugin import Plugin, PluginSourceBindingStatus
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
AsyncMarketLoader = Callable[
[str, Optional[str], bool],
Awaitable[Optional[dict[str, dict]]],
]
PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
ProgressCallback = Callable[..., Any]
def apply_declared_metadata_fallback(
plugins: Sequence[Plugin],
identities: Mapping[str, PluginIdentity],
) -> list[Plugin]:
"""用已提交快照补齐加载失败插件,不覆盖真实运行态字段。"""
result: list[Plugin] = []
for plugin in plugins:
identity = identities.get((plugin.id or "").lower())
updates: dict[str, object] = {}
if plugin.installed and not plugin.is_instance:
if identity is None:
updates["source_binding_status"] = PluginSourceBindingStatus.BINDING_REQUIRED
elif identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
updates["source_binding_status"] = (
PluginSourceBindingStatus.LOCAL_ONLY
if identity.binding_basis is PluginBindingBasis.LOCAL_ONLY
else PluginSourceBindingStatus.BINDING_REQUIRED
)
else:
updates["source_binding_status"] = PluginSourceBindingStatus.BOUND
if (
identity is None
or identity.declared_metadata is None
or identity.declared_version is None
):
result.append(plugin.model_copy(update=updates) if updates else plugin)
continue
fallback = identity.declared_metadata.display_fallback(
installed_version=identity.declared_version
)
if not plugin.plugin_version:
updates["plugin_version"] = fallback["plugin_version"]
if (
(not plugin.plugin_name or plugin.plugin_name == plugin.id)
and "plugin_name" in fallback
):
updates["plugin_name"] = fallback["plugin_name"]
if not plugin.plugin_desc and "plugin_desc" in fallback:
updates["plugin_desc"] = fallback["plugin_desc"]
if not plugin.plugin_icon and "plugin_icon" in fallback:
updates["plugin_icon"] = fallback["plugin_icon"]
if not plugin.plugin_author and "plugin_author" in fallback:
updates["plugin_author"] = fallback["plugin_author"]
if not plugin.plugin_label and "plugin_label" in fallback:
updates["plugin_label"] = fallback["plugin_label"]
result.append(plugin.model_copy(update=updates) if updates else plugin)
return result
class PluginCatalogService:
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
def __init__(
self,
*,
market_loader: MarketLoader,
async_market_loader: AsyncMarketLoader,
installed_plugins_provider: Callable[[], list[str]],
plugin_mapper: PluginMapper,
is_local_repo: Callable[[Optional[str]], bool],
version_compare: Callable[[str, str, str], bool],
warning: Callable[[str], Any],
error: Callable[[str], Any],
) -> None:
"""保存市场读取、插件映射和版本比较端口。"""
self._market_loader = market_loader
self._async_market_loader = async_market_loader
self._installed_plugins_provider = installed_plugins_provider
self._plugin_mapper = plugin_mapper
self._is_local_repo = is_local_repo
self._version_compare = version_compare
self._warning = warning
self._error = error
def load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""同步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = self._market_loader(market, package_version, force)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
async def async_load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""异步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = await self._async_market_loader(
market,
package_version,
force,
)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
def collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[[str, Optional[str], bool], list[Any]],
) -> list[Any]:
"""并发读取多个市场和代际,并按稳定优先级合并。"""
with concurrent.futures.ThreadPoolExecutor() as executor:
futures_meta: dict[
concurrent.futures.Future,
tuple[int, bool, int],
] = {}
for market_index, market in enumerate(markets):
base_future = executor.submit(loader, market, None, force)
futures_meta[base_future] = (market_index, False, 0)
for flag_priority, flag in enumerate(compatible_flags):
higher_future = executor.submit(loader, market, flag, force)
futures_meta[higher_future] = (
market_index,
True,
flag_priority,
)
collected = []
for future in concurrent.futures.as_completed(futures_meta):
plugins = future.result()
market_index, is_higher, flag_priority = futures_meta[future]
collected.append((
market_index,
is_higher,
flag_priority,
plugins or [],
))
collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2]))
higher_plugins = []
base_plugins = []
for _market_index, is_higher, _flag_priority, plugins in collected:
(higher_plugins if is_higher else base_plugins).extend(plugins)
return self.merge(higher_plugins, base_plugins, markets)
async def async_collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[
[str, Optional[str], bool],
Awaitable[list[Any]],
],
progress_callback: Optional[ProgressCallback] = None,
) -> list[Any]:
"""异步读取多个市场和代际,并持续报告稳定进度。"""
async def fetch(
market: str,
package_version: Optional[str],
result_version: str,
task_index: int,
) -> tuple[int, str, list[Any]]:
"""读取一个市场代际并保留创建时的稳定任务序号。"""
plugins = await loader(market, package_version, force)
return task_index, result_version, plugins or []
tasks: list[asyncio.Task[tuple[int, str, list[Any]]]] = []
for market in markets:
tasks.append(asyncio.create_task(
fetch(market, None, "base_version", len(tasks)),
name="plugin.catalog.fetch",
))
for flag in compatible_flags:
tasks.append(asyncio.create_task(
fetch(market, flag, "higher_version", len(tasks)),
name="plugin.catalog.fetch",
))
try:
higher_plugins = []
base_plugins = []
if tasks:
total_tasks = len(tasks)
finished_tasks = 0
task_results = {}
if progress_callback:
progress_callback(
value=0,
text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...",
data={"total": total_tasks, "finished": 0},
)
for completed_task in asyncio.as_completed(tasks):
try:
task_index, version, plugins = await completed_task
task_results[task_index] = (version, plugins)
except Exception as err:
self._error(f"获取插件市场数据失败:{str(err)}")
finished_tasks += 1
if progress_callback:
progress_callback(
value=finished_tasks / total_tasks * 100,
text=(
f"插件市场请求({finished_tasks}/{total_tasks}"
"处理完成"
),
data={"total": total_tasks, "finished": finished_tasks},
)
for task_index in sorted(task_results):
version, plugins = task_results[task_index]
target = higher_plugins if version == "higher_version" else base_plugins
target.extend(plugins)
result = self.merge(higher_plugins, base_plugins, markets)
if progress_callback:
progress_callback(value=100, text="插件市场缓存刷新完成")
return result
finally:
for task in tasks:
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def merge(
self,
higher_plugins: list[Any],
base_plugins: list[Any],
markets: list[str],
) -> list[Any]:
"""按代际、来源顺序和版本合并插件目录。"""
all_plugins = list(higher_plugins)
higher_keys = {
f"{plugin.id}{plugin.plugin_version}"
for plugin in higher_plugins
}
all_plugins.extend(
plugin
for plugin in base_plugins
if f"{plugin.id}{plugin.plugin_version}" not in higher_keys
)
def repo_order(plugin: Any) -> int:
"""本地来源排在远程市场之后,远程来源保持配置顺序。"""
if self._is_local_repo(plugin.repo_url):
return len(markets) + 1
if plugin.repo_url in markets:
return markets.index(plugin.repo_url)
return len(markets)
deduplicated = {}
for plugin in sorted(all_plugins, key=repo_order):
key = f"{plugin.id}{plugin.plugin_version}"
exists = deduplicated.get(key)
if not exists or (
self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
deduplicated[key] = plugin
result_by_id = {}
for plugin in sorted(deduplicated.values(), key=repo_order):
exists = result_by_id.get(plugin.id)
if not exists \
or self._version_compare(
plugin.plugin_version,
">",
exists.plugin_version,
) \
or (
plugin.plugin_version == exists.plugin_version
and self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
result_by_id[plugin.id] = plugin
return list(result_by_id.values())
def _map_plugins(
self,
online_plugins: dict[str, dict],
market: str,
package_version: Optional[str],
) -> list[Any]:
"""把一个市场索引映射为宿主插件 DTO。"""
installed_plugins = self._installed_plugins_provider()
result = []
add_time = len(online_plugins)
for plugin_id, plugin_info in online_plugins.items():
plugin = self._plugin_mapper(
plugin_id,
plugin_info,
market,
installed_plugins,
add_time,
package_version,
)
if plugin:
result.append(plugin)
add_time -= 1
return result