From dc23977b0c986be7b7f34757297d8e9bc4364655 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 18 Aug 2026 14:50:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE=E8=BD=AC=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AA=92=E4=BD=93=E6=9D=A5=E6=BA=90=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/media.py | 59 +++++++++++++++++++++ app/chain/scraping.py | 11 ++++ app/plugins/__init__.py | 9 ++++ app/runtime/extensions/plugin/contracts.py | 1 + app/runtime/extensions/plugin/projection.py | 18 +++++++ app/runtime/extensions/plugin_manager.py | 4 ++ app/schemas/event.py | 18 ++++++- app/schemas/exports.py | 3 +- app/schemas/site.py | 3 ++ tests/test_plugin_projection.py | 23 ++++++++ tests/test_site_query_service.py | 32 +++++++++++ 11 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 tests/test_site_query_service.py diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index c4df4753d..6fc15e8b2 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -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] ) diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 2da1ebe54..3a6a83708 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -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"), diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 66b0d02e9..2d00c6fc1 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -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]]: """ 获取插件工作流动作 diff --git a/app/runtime/extensions/plugin/contracts.py b/app/runtime/extensions/plugin/contracts.py index 716995cc8..3c5453d6e 100644 --- a/app/runtime/extensions/plugin/contracts.py +++ b/app/runtime/extensions/plugin/contracts.py @@ -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), diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 5d71670bd..7d3402cf1 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -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] = [] diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index ba75d75ee..08b9e1f44 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -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]]: """ 获取插件动作 diff --git a/app/schemas/event.py b/app/schemas/event.py index f8c12a85a..5d7da7311 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -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 事件的数据模型 diff --git a/app/schemas/exports.py b/app/schemas/exports.py index aabc008a1..149e80b88 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -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'], diff --git a/app/schemas/site.py b/app/schemas/site.py index 832357b93..818fe971a 100644 --- a/app/schemas/site.py +++ b/app/schemas/site.py @@ -124,6 +124,9 @@ class SiteUserData(BaseModel): # 更新时间 updated_time: Optional[str] = None + # 查询仓储返回 SQLAlchemy ORM 对象时,从对象属性读取字段。 + model_config = ConfigDict(from_attributes=True) + class SiteAuth(BaseModel): """站点认证模块及其参数。""" diff --git a/tests/test_plugin_projection.py b/tests/test_plugin_projection.py index b6e53237f..0142d5363 100644 --- a/tests/test_plugin_projection.py +++ b/tests/test_plugin_projection.py @@ -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 = [] diff --git a/tests/test_site_query_service.py b/tests/test_site_query_service.py new file mode 100644 index 000000000..586055596 --- /dev/null +++ b/tests/test_site_query_service.py @@ -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