mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix: 修复站点用户数据转换并完善媒体来源扩展
This commit is contained in:
@@ -28,6 +28,7 @@ from app.domain.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.adapters.web.security.access import verify_token, verify_apitoken
|
||||
from app.api.deps import get_current_active_user, get_current_active_superuser
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.event import MediaSourceInfo as _SchemaMediaSourceInfo
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType
|
||||
from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection
|
||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||
@@ -35,6 +36,54 @@ from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
_BUILTIN_MEDIA_SOURCES = (
|
||||
_SchemaMediaSourceInfo(name="TheMovieDb", media_source=MediaSource.TMDB),
|
||||
_SchemaMediaSourceInfo(name="豆瓣", media_source=MediaSource.Douban),
|
||||
_SchemaMediaSourceInfo(name="Bangumi", media_source=MediaSource.Bangumi),
|
||||
_SchemaMediaSourceInfo(name="AniList", media_source=MediaSource.AniList),
|
||||
_SchemaMediaSourceInfo(name="IMDb", media_source=MediaSource.IMDb),
|
||||
_SchemaMediaSourceInfo(name="TVDB", media_source=MediaSource.TVDB),
|
||||
_SchemaMediaSourceInfo(
|
||||
name="MusicBrainz",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_types=[MediaType.MUSIC],
|
||||
),
|
||||
_SchemaMediaSourceInfo(
|
||||
name="TheAudioDB",
|
||||
media_source=MediaSource.TheAudioDB,
|
||||
media_types=[MediaType.MUSIC],
|
||||
),
|
||||
_SchemaMediaSourceInfo(
|
||||
name="豆瓣音乐",
|
||||
media_source=MediaSource.DoubanMusic,
|
||||
media_types=[MediaType.MUSIC],
|
||||
),
|
||||
_SchemaMediaSourceInfo(name="哔哩哔哩", media_source=MediaSource.Bilibili),
|
||||
_SchemaMediaSourceInfo(name="芒果TV", media_source=MediaSource.MangoTV),
|
||||
_SchemaMediaSourceInfo(name="咪咕视频", media_source=MediaSource.MiguVideo),
|
||||
_SchemaMediaSourceInfo(name="腾讯视频", media_source=MediaSource.TencentVideo),
|
||||
_SchemaMediaSourceInfo(name="爱奇艺", media_source=MediaSource.Iqiyi),
|
||||
)
|
||||
|
||||
|
||||
def _registered_media_sources() -> list[_SchemaMediaSourceInfo]:
|
||||
"""合并内置与启用插件声明的媒体来源,并按来源标识去重。"""
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
|
||||
result = list(_BUILTIN_MEDIA_SOURCES)
|
||||
seen = {source.media_source for source in result}
|
||||
for raw_source in PluginManager().get_media_sources():
|
||||
try:
|
||||
source = _SchemaMediaSourceInfo.model_validate(raw_source)
|
||||
except Exception:
|
||||
continue
|
||||
if source.media_source in seen:
|
||||
continue
|
||||
result.append(source)
|
||||
seen.add(source.media_source)
|
||||
return result
|
||||
|
||||
|
||||
def _split_media_source_query(value: object) -> tuple[str, ...]:
|
||||
"""展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。"""
|
||||
if value in (None, ""):
|
||||
@@ -324,6 +373,16 @@ async def search(
|
||||
return sorted_result[(page - 1) * count : page * count]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/source",
|
||||
summary="获取媒体数据源",
|
||||
response_model=list[_SchemaMediaSourceInfo],
|
||||
)
|
||||
def source(_: _SchemaTokenPayload = Depends(verify_token)) -> list[_SchemaMediaSourceInfo]:
|
||||
"""返回内置及启用插件注册的媒体数据源,供前端统一构造来源选项。"""
|
||||
return _registered_media_sources()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None]
|
||||
)
|
||||
|
||||
@@ -567,6 +567,17 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
获取图片名称和url,合并所有模块的结果。
|
||||
优先使用高优先级模块的图片,低优先级模块补充缺失的图片类型。
|
||||
"""
|
||||
# 插件扩展来源没有宿主内置刮削器,优先让插件按自身来源返回图片地址;
|
||||
# 内置来源仍保留原有多模块合并逻辑,避免改变既有图片补全顺序。
|
||||
if mediainfo and mediainfo.media_source not in tuple(MediaSource):
|
||||
plugin_images = self.run_module(
|
||||
"metadata_img",
|
||||
mediainfo=mediainfo,
|
||||
season=season,
|
||||
episode=episode,
|
||||
)
|
||||
if isinstance(plugin_images, dict):
|
||||
return plugin_images or None
|
||||
merged = {}
|
||||
for module in sorted(
|
||||
self.modulemanager.get_running_modules("metadata_img"),
|
||||
|
||||
@@ -199,6 +199,15 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_media_source(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件提供的媒体数据源。
|
||||
|
||||
返回的每项至少包含 ``name``、``media_source`` 和 ``media_types``;实际的
|
||||
搜索、识别、图片和 NFO 刮削实现通过 ``get_module`` 暴露对应方法。
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_actions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件工作流动作
|
||||
|
||||
@@ -34,6 +34,7 @@ PLUGIN_HOOK_CONTRACTS = {
|
||||
PluginHookContract("get_api"),
|
||||
PluginHookContract("get_service", requires_enabled=True),
|
||||
PluginHookContract("get_module", requires_enabled=True),
|
||||
PluginHookContract("get_media_source", requires_enabled=True),
|
||||
PluginHookContract("get_actions", requires_enabled=True),
|
||||
PluginHookContract("get_agent_tools", requires_enabled=True),
|
||||
PluginHookContract("get_auth_providers", requires_enabled=True),
|
||||
|
||||
@@ -92,6 +92,24 @@ class PluginProjection:
|
||||
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
||||
return modules
|
||||
|
||||
def media_sources(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""聚合启用插件声明的媒体数据源。"""
|
||||
sources: list[dict] = []
|
||||
for plugin_id, plugin in self._items(pid):
|
||||
if not supports_plugin_hook(plugin, "get_media_source"):
|
||||
continue
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
for source in plugin.get_media_source() or []:
|
||||
if isinstance(source, dict):
|
||||
item = source.copy()
|
||||
item.setdefault("plugin_id", plugin_id)
|
||||
sources.append(item)
|
||||
except Exception as error:
|
||||
self._logger.error(f"获取插件 {plugin_id} 媒体数据源出错:{str(error)}")
|
||||
return sources
|
||||
|
||||
def actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""聚合启用插件的工作流动作。"""
|
||||
actions: list[dict] = []
|
||||
|
||||
@@ -616,6 +616,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
return self._plugin_projection().modules(pid)
|
||||
|
||||
def get_media_sources(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""获取运行中插件声明的媒体数据源。"""
|
||||
return self._plugin_projection().media_sources(pid)
|
||||
|
||||
def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件动作
|
||||
|
||||
+17
-1
@@ -4,7 +4,7 @@ from typing import Iterable, Optional, Dict, Any, List, Set, Callable
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import NotificationChannel
|
||||
from app.schemas.types import MediaType, NotificationChannel
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
@@ -527,6 +527,22 @@ class DiscoverMediaSource(BaseModel):
|
||||
return MediaSource(mediaid_prefix)
|
||||
|
||||
|
||||
class MediaSourceInfo(BaseModel):
|
||||
"""
|
||||
媒体数据源注册描述。
|
||||
|
||||
插件通过该描述声明来源的展示名称和支持的媒体类型;识别、搜索和刮削的
|
||||
实际实现仍由插件模块方法提供,宿主只负责把来源传递到统一媒体链路。
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="数据源展示名称")
|
||||
media_source: MediaSource = Field(..., description="规范媒体来源标识")
|
||||
media_types: List[MediaType] = Field(
|
||||
default_factory=lambda: [MediaType.MOVIE, MediaType.TV],
|
||||
description="支持的媒体类型",
|
||||
)
|
||||
|
||||
|
||||
class DiscoverSourceEventData(ChainEventData):
|
||||
"""
|
||||
DiscoverSource 事件的数据模型
|
||||
|
||||
@@ -186,6 +186,7 @@ SCHEMA_EXPORTS = {
|
||||
'MediaServerPlayItem': ('app.schemas.mediaserver', 'MediaServerPlayItem'),
|
||||
'MediaServerSeasonInfo': ('app.schemas.mediaserver', 'MediaServerSeasonInfo'),
|
||||
'MediaSource': ('app.schemas.transfer', 'MediaSource'),
|
||||
'MediaSourceInfo': ('app.schemas.event', 'MediaSourceInfo'),
|
||||
'MediaType': ('app.schemas.subscribe', 'MediaType'),
|
||||
'Message': ('app.schemas.message', 'Message'),
|
||||
'MessageClearBefore': ('app.schemas.message', 'MessageClearBefore'),
|
||||
@@ -420,7 +421,7 @@ SCHEMA_CONFLICTS = {
|
||||
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
|
||||
'MediaType': ['app.schemas.mediaserver', 'app.schemas.subscribe'],
|
||||
'MediaType': ['app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'],
|
||||
'MetaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer'],
|
||||
'MusicInfo': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'],
|
||||
'MusicMeta': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'],
|
||||
|
||||
@@ -124,6 +124,9 @@ class SiteUserData(BaseModel):
|
||||
# 更新时间
|
||||
updated_time: Optional[str] = None
|
||||
|
||||
# 查询仓储返回 SQLAlchemy ORM 对象时,从对象属性读取字段。
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SiteAuth(BaseModel):
|
||||
"""站点认证模块及其参数。"""
|
||||
|
||||
@@ -80,6 +80,29 @@ def test_projection_preserves_services_modules_actions_and_pid_filter():
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_collects_enabled_media_source_declarations():
|
||||
"""只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。"""
|
||||
demo = _Plugin(
|
||||
get_media_source=lambda: [{
|
||||
"name": "Acme Video",
|
||||
"media_source": "acme.video",
|
||||
"media_types": ["电影", "电视剧"],
|
||||
}],
|
||||
)
|
||||
disabled = _Plugin(
|
||||
enabled=False,
|
||||
get_media_source=lambda: [{"name": "Disabled", "media_source": "disabled"}],
|
||||
)
|
||||
projection = PluginProjection({"Demo": demo, "Disabled": disabled})
|
||||
|
||||
assert projection.media_sources() == [{
|
||||
"name": "Acme Video",
|
||||
"media_source": "acme.video",
|
||||
"media_types": ["电影", "电视剧"],
|
||||
"plugin_id": "Demo",
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_isolates_one_plugin_hook_failure():
|
||||
"""单个插件 hook 失败只记日志,不阻断其他插件投影。"""
|
||||
errors = []
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""站点查询应用服务的 ORM 投影契约测试。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.site.query import SiteQueryService
|
||||
from app.db.models.siteuserdata import SiteUserData as SiteUserDataRecord
|
||||
from app.schemas.site import SiteUserData
|
||||
|
||||
|
||||
def test_userdata_latest_sync_projects_orm_record_to_dto() -> None:
|
||||
"""同步站点用户数据查询应支持 SQLAlchemy ORM 记录。"""
|
||||
record = SiteUserDataRecord(
|
||||
domain="site.example",
|
||||
username="tester",
|
||||
userid="42",
|
||||
user_level="Power User",
|
||||
upload=1024.0,
|
||||
download=512.0,
|
||||
seeding=3.0,
|
||||
leeching=1.0,
|
||||
seeding_size=2048.0,
|
||||
leeching_size=256.0,
|
||||
)
|
||||
repository = Mock()
|
||||
repository.get_userdata_latest.return_value = [record]
|
||||
|
||||
result = SiteQueryService(repository).userdata_latest_sync()
|
||||
|
||||
assert isinstance(result[0], SiteUserData)
|
||||
assert result[0].domain == "site.example"
|
||||
assert result[0].userid == "42"
|
||||
assert result[0].upload == 1024
|
||||
Reference in New Issue
Block a user