mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +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.adapters.web.security.access import verify_token, verify_apitoken
|
||||||
from app.api.deps import get_current_active_user, get_current_active_superuser
|
from app.api.deps import get_current_active_user, get_current_active_superuser
|
||||||
from app.schemas.category import CategoryConfig
|
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.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.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
|
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()
|
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, ...]:
|
def _split_media_source_query(value: object) -> tuple[str, ...]:
|
||||||
"""展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。"""
|
"""展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。"""
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
@@ -324,6 +373,16 @@ async def search(
|
|||||||
return sorted_result[(page - 1) * count : page * count]
|
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(
|
@router.post(
|
||||||
"/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None]
|
"/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -567,6 +567,17 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
获取图片名称和url,合并所有模块的结果。
|
获取图片名称和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 = {}
|
merged = {}
|
||||||
for module in sorted(
|
for module in sorted(
|
||||||
self.modulemanager.get_running_modules("metadata_img"),
|
self.modulemanager.get_running_modules("metadata_img"),
|
||||||
|
|||||||
@@ -199,6 +199,15 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
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]]:
|
def get_actions(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
获取插件工作流动作
|
获取插件工作流动作
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ PLUGIN_HOOK_CONTRACTS = {
|
|||||||
PluginHookContract("get_api"),
|
PluginHookContract("get_api"),
|
||||||
PluginHookContract("get_service", requires_enabled=True),
|
PluginHookContract("get_service", requires_enabled=True),
|
||||||
PluginHookContract("get_module", requires_enabled=True),
|
PluginHookContract("get_module", requires_enabled=True),
|
||||||
|
PluginHookContract("get_media_source", requires_enabled=True),
|
||||||
PluginHookContract("get_actions", requires_enabled=True),
|
PluginHookContract("get_actions", requires_enabled=True),
|
||||||
PluginHookContract("get_agent_tools", requires_enabled=True),
|
PluginHookContract("get_agent_tools", requires_enabled=True),
|
||||||
PluginHookContract("get_auth_providers", requires_enabled=True),
|
PluginHookContract("get_auth_providers", requires_enabled=True),
|
||||||
|
|||||||
@@ -92,6 +92,24 @@ class PluginProjection:
|
|||||||
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
||||||
return modules
|
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]]:
|
def actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""聚合启用插件的工作流动作。"""
|
"""聚合启用插件的工作流动作。"""
|
||||||
actions: list[dict] = []
|
actions: list[dict] = []
|
||||||
|
|||||||
@@ -616,6 +616,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self._plugin_projection().modules(pid)
|
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]]:
|
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 pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
from app.schemas.common import JsonData
|
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.file import FileItem
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource
|
from app.schemas.types import MediaSource
|
||||||
@@ -527,6 +527,22 @@ class DiscoverMediaSource(BaseModel):
|
|||||||
return MediaSource(mediaid_prefix)
|
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):
|
class DiscoverSourceEventData(ChainEventData):
|
||||||
"""
|
"""
|
||||||
DiscoverSource 事件的数据模型
|
DiscoverSource 事件的数据模型
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ SCHEMA_EXPORTS = {
|
|||||||
'MediaServerPlayItem': ('app.schemas.mediaserver', 'MediaServerPlayItem'),
|
'MediaServerPlayItem': ('app.schemas.mediaserver', 'MediaServerPlayItem'),
|
||||||
'MediaServerSeasonInfo': ('app.schemas.mediaserver', 'MediaServerSeasonInfo'),
|
'MediaServerSeasonInfo': ('app.schemas.mediaserver', 'MediaServerSeasonInfo'),
|
||||||
'MediaSource': ('app.schemas.transfer', 'MediaSource'),
|
'MediaSource': ('app.schemas.transfer', 'MediaSource'),
|
||||||
|
'MediaSourceInfo': ('app.schemas.event', 'MediaSourceInfo'),
|
||||||
'MediaType': ('app.schemas.subscribe', 'MediaType'),
|
'MediaType': ('app.schemas.subscribe', 'MediaType'),
|
||||||
'Message': ('app.schemas.message', 'Message'),
|
'Message': ('app.schemas.message', 'Message'),
|
||||||
'MessageClearBefore': ('app.schemas.message', 'MessageClearBefore'),
|
'MessageClearBefore': ('app.schemas.message', 'MessageClearBefore'),
|
||||||
@@ -420,7 +421,7 @@ SCHEMA_CONFLICTS = {
|
|||||||
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
'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'],
|
'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'],
|
'MetaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer'],
|
||||||
'MusicInfo': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'],
|
'MusicInfo': ['app.schemas.context', 'app.schemas.music', 'app.schemas.transfer'],
|
||||||
'MusicMeta': ['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
|
updated_time: Optional[str] = None
|
||||||
|
|
||||||
|
# 查询仓储返回 SQLAlchemy ORM 对象时,从对象属性读取字段。
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class SiteAuth(BaseModel):
|
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():
|
def test_projection_isolates_one_plugin_hook_failure():
|
||||||
"""单个插件 hook 失败只记日志,不阻断其他插件投影。"""
|
"""单个插件 hook 失败只记日志,不阻断其他插件投影。"""
|
||||||
errors = []
|
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