fix: 修复站点用户数据转换并完善媒体来源扩展

This commit is contained in:
jxxghp
2026-08-18 14:50:09 +08:00
parent c2a27f7c71
commit dc23977b0c
11 changed files with 179 additions and 2 deletions
+59
View File
@@ -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]
) )
+11
View File
@@ -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"),
+9
View File
@@ -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] = []
+4
View File
@@ -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
View File
@@ -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 事件的数据模型
+2 -1
View File
@@ -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'],
+3
View File
@@ -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):
"""站点认证模块及其参数。""" """站点认证模块及其参数。"""
+23
View File
@@ -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 = []
+32
View File
@@ -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