From b4863a655026d23631b41a6635af1ee1053e1fe2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 13 Aug 2026 13:23:52 +0800 Subject: [PATCH] feat(media): support extensible media sources --- app/api/endpoints/music.py | 21 ++---- app/chain/media.py | 43 ++++++++--- app/chain/musicbrainz.py | 33 ++++----- app/db/models/media_identity.py | 12 +-- app/schemas/event.py | 12 +-- app/schemas/media.py | 4 +- app/schemas/transfer.py | 2 +- app/schemas/types.py | 49 +++++++++++- app/utils/media.py | 14 +++- database/versions/8a4c7e1d2f90_3_0_0.py | 51 ++++++++++--- database/versions/b3d7e9f1a2c4_3_0_0.py | 74 +++++++++++++++++++ docs/cli.md | 2 +- docs/mcp-api.md | 6 +- docs/rules/03-commands.md | 7 +- tests/test_agent_music_tools.py | 6 +- tests/test_bangumi_media_type.py | 63 ++++++++++++++-- tests/test_database_index_migration.py | 8 +- tests/test_explicit_response_models.py | 7 ++ .../test_extensible_media_source_migration.py | 66 +++++++++++++++++ .../test_media_identity_cleanup_migration.py | 33 +++++++-- tests/test_media_search_source_selection.py | 18 ++--- tests/test_media_source_routing.py | 12 ++- tests/test_message_media_identity.py | 9 ++- tests/test_music_metadata_sources.py | 12 +++ tests/test_music_plugin_recognize.py | 7 +- tests/test_search_media_sources.py | 7 +- tests/test_subscribe_endpoint.py | 2 +- tests/test_transfer_tmdb_category.py | 12 +-- 28 files changed, 460 insertions(+), 132 deletions(-) create mode 100644 database/versions/b3d7e9f1a2c4_3_0_0.py create mode 100644 tests/test_extensible_media_source_migration.py diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 769ab0e7a..6ce059e08 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -42,11 +42,6 @@ MusicAlbumTypeParam = Annotated[ Optional[str], Query(pattern="^(album|single|ep|broadcast|other|compilation|soundtrack|live|remix)$"), ] -_MUSIC_DETAIL_SOURCES = frozenset({ - MediaSource.MusicBrainz, - MediaSource.TheAudioDB, - MediaSource.DoubanMusic, -}) _MUSIC_EXPLORE_SOURCES = frozenset({ MediaSource.MusicBrainz, MediaSource.DoubanMusic, @@ -55,14 +50,14 @@ _MUSIC_EXPLORE_SOURCES = frozenset({ def _validate_music_source( media_source: MediaSource, - allowed_sources: frozenset[MediaSource], + allowed_sources: Optional[frozenset[MediaSource]] = None, ) -> MediaSource: - """将 HTTP 或直接调用参数规范为音乐来源枚举,并拒绝不支持的来源。""" + """规范音乐来源;仅来源专属端点额外限制内置来源集合。""" try: normalized_source = MediaSource(media_source) except (TypeError, ValueError) as err: raise HTTPException(status_code=422, detail="无效的媒体来源") from err - if normalized_source not in allowed_sources: + if allowed_sources is not None and normalized_source not in allowed_sources: raise HTTPException(status_code=422, detail="该媒体来源不支持此音乐接口") return normalized_source @@ -228,7 +223,7 @@ async def music_album( _: schemas.TokenPayload = Depends(verify_token), ) -> schemas.MusicAlbumInfo: """按专辑标准 ID 返回专辑详情、曲目列表和发行版本。""" - media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_album( media_source=media_source, media_id=album_id ) @@ -249,7 +244,7 @@ async def music_album_related( _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicInfo]: """按来源和专辑 ID 返回可继续浏览的关联专辑。""" - media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_album_related( media_source=media_source, media_id=album_id, @@ -272,7 +267,7 @@ async def music_artist_albums( _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicInfo]: """按艺术家标准 ID 分页返回其专辑、EP 和单曲。""" - media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_albums( media_source=media_source, media_id=artist_id, @@ -295,7 +290,7 @@ async def music_artist_related( _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicArtistInfo]: """按艺术家关系返回可继续浏览的关联艺术家。""" - media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + media_source = _validate_music_source(media_source) results = await MediaChain().async_get_music_artist_related( media_source=media_source, media_id=artist_id, @@ -315,7 +310,7 @@ async def music_artist( _: schemas.TokenPayload = Depends(verify_token), ) -> schemas.MusicArtistInfo: """按艺术家标准 ID 返回艺术家详情。""" - media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + media_source = _validate_music_source(media_source) info = await MediaChain().async_get_music_artist( media_source=media_source, media_id=artist_id ) diff --git a/app/chain/media.py b/app/chain/media.py index 1c21b17bb..4b62155e1 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -10,7 +10,7 @@ from app import schemas from app.chain import ChainBase from app.chain.acoustid import AcoustIdChain from app.chain.douban import DoubanChain -from app.chain.musicbrainz import MusicBrainzChain +from app.chain.musicbrainz import MusicBrainzChain, _MusicMetadataSourceChain from app.chain.theaudiodb import TheAudioDbChain from app.core.cache import async_fresh, fresh from app.core.config import settings @@ -61,23 +61,29 @@ class MediaChain(ChainBase, metaclass=Singleton): @staticmethod def _music_source_chain( media_source: MediaSource, - ) -> Optional[MusicBrainzChain | TheAudioDbChain | DoubanChain]: - """按固定音乐来源返回对应来源链。""" + ) -> Optional[_MusicMetadataSourceChain | DoubanChain]: + """返回内置来源专用链,或绑定插件扩展来源的通用音乐端口。""" source = normalize_media_source(media_source) + if not source: + return None chains = { MediaSource.MusicBrainz: MusicBrainzChain, MediaSource.TheAudioDB: TheAudioDbChain, MediaSource.DoubanMusic: DoubanChain, } chain_type = chains.get(source) - return chain_type() if chain_type else None + if chain_type: + return chain_type() + plugin_chain = _MusicMetadataSourceChain() + plugin_chain.source = source + return plugin_chain @classmethod def _music_search_sources( cls, media_source: Optional[MediaSourceSelection], ) -> list[MediaSource]: - """解析有序音乐搜索来源集合,忽略未知来源和重复项。""" + """解析有序音乐搜索来源集合,保留合法插件扩展来源并去重。""" if not media_source: return [cls._music_primary_source] raw_sources = ( @@ -87,13 +93,14 @@ class MediaChain(ChainBase, metaclass=Singleton): ) sources: list[MediaSource] = [] for raw_source in raw_sources: - if is_music_media_source(raw_source) and raw_source not in sources: - sources.append(raw_source) + source = normalize_media_source(raw_source) + if source and source not in sources: + sources.append(source) return sources @staticmethod async def _async_search_music_source( - chain: MusicBrainzChain | TheAudioDbChain | DoubanChain, + chain: _MusicMetadataSourceChain | DoubanChain, source: MediaSource, meta: MetaMusic, limit: int, @@ -1480,7 +1487,15 @@ class MediaChain(ChainBase, metaclass=Singleton): mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), season=season if season is not None else meta.begin_season, ) - return None + event_data = schemas.MediaRecognizeConvertEventData( + media_source=media_source, + media_id=media_id, + target_media_source=target_source, + ) + event = eventmanager.send_event( + ChainEventType.MediaRecognizeConvert, event_data, + ) + return event_data.media_dict if event and event_data.media_dict else None @staticmethod @@ -2018,4 +2033,12 @@ class MediaChain(ChainBase, metaclass=Singleton): mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), season=season if season is not None else meta.begin_season, ) - return None + event_data = schemas.MediaRecognizeConvertEventData( + media_source=media_source, + media_id=media_id, + target_media_source=target_source, + ) + event = await eventmanager.async_send_event( + ChainEventType.MediaRecognizeConvert, event_data, + ) + return event_data.media_dict if event and event_data.media_dict else None diff --git a/app/chain/musicbrainz.py b/app/chain/musicbrainz.py index 049b1f37e..5f7eb3e99 100644 --- a/app/chain/musicbrainz.py +++ b/app/chain/musicbrainz.py @@ -168,21 +168,23 @@ class _MusicMetadataSourceChain(ChainBase): normalized = str(media_id).strip() if media_id is not None else "" return normalized if normalized and normalized != "0" else None - @classmethod - def _music_infos(cls, result: Any, limit: Optional[int] = None) -> list[MusicInfo]: - """将模块或插件结果统一转换为音乐候选列表。""" + def _music_infos( + self, + result: Any, + limit: Optional[int] = None, + ) -> list[MusicInfo]: + """将模块或插件结果统一转换为当前来源的音乐候选列表。""" candidates = result if isinstance(result, list) else [] infos = [ item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item) for item in candidates if isinstance(item, (MusicInfo, dict)) ] - infos = [info for info in infos if info.media_source == cls.source] + infos = [info for info in infos if info.media_source == self.source] return infos[:limit] if limit else infos - @classmethod def _music_info( - cls, + self, result: Any, media_id: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -193,15 +195,14 @@ class _MusicMetadataSourceChain(ChainBase): info = MusicInfo.from_dict(result) else: return None - if info.media_source and info.media_source != cls.source: + if info.media_source and info.media_source != self.source: return None - if media_id and (info.media_source != cls.source or info.media_id != media_id): + if media_id and (info.media_source != self.source or info.media_id != media_id): return None return info - @classmethod def _music_album( - cls, + self, result: Any, media_id: Optional[str] = None, ) -> Optional[MusicAlbumInfo]: @@ -212,15 +213,14 @@ class _MusicMetadataSourceChain(ChainBase): album = MusicAlbumInfo.from_dict(result) else: return None - if album.media_source != cls.source: + if album.media_source != self.source: return None if media_id and album.media_id != media_id: return None return album - @classmethod def _music_artist( - cls, + self, result: Any, media_id: Optional[str] = None, ) -> Optional[MusicArtistInfo]: @@ -231,15 +231,14 @@ class _MusicMetadataSourceChain(ChainBase): artist = MusicArtistInfo.from_dict(result) else: return None - if artist.media_source != cls.source: + if artist.media_source != self.source: return None if media_id and artist.media_id != media_id: return None return artist - @classmethod def _music_artists( - cls, + self, result: Any, limit: Optional[int] = None, ) -> list[MusicArtistInfo]: @@ -250,7 +249,7 @@ class _MusicMetadataSourceChain(ChainBase): for item in candidates if isinstance(item, (MusicArtistInfo, dict)) ] - artists = [artist for artist in artists if artist.media_source == cls.source] + artists = [artist for artist in artists if artist.media_source == self.source] return artists[:limit] if limit else artists diff --git a/app/db/models/media_identity.py b/app/db/models/media_identity.py index 57e72e411..40480b351 100644 --- a/app/db/models/media_identity.py +++ b/app/db/models/media_identity.py @@ -1,21 +1,17 @@ from sqlalchemy import CheckConstraint -from app.schemas.types import MediaSource - - -MEDIA_SOURCE_SQL_VALUES = ", ".join( - f"'{media_source.value}'" for media_source in MediaSource -) MEDIA_IDENTITY_CHECK_SQL = ( "(media_source IS NULL AND media_id IS NULL) OR " "(media_source IS NOT NULL AND " - f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND " + "trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND " + "length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND " + "media_source NOT LIKE '% %' AND " "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" ) def media_identity_constraint(table_name: str) -> CheckConstraint: - """构造通用媒体表使用的来源枚举与身份成对数据库约束。""" + """构造允许插件扩展来源且保证身份成对的数据库约束。""" return CheckConstraint( MEDIA_IDENTITY_CHECK_SQL, name=f"ck_{table_name}_media_identity", diff --git a/app/schemas/event.py b/app/schemas/event.py index bdae7cae6..920b211e8 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -482,12 +482,12 @@ class DiscoverMediaSource(BaseModel): 探索媒体数据源的基类。 ``mediaid_prefix`` 是既有插件与前端标签使用的稳定标识; - ``media_source`` 是新的规范媒体来源。模型同时输出两者,并在输入时互相补齐, - 以兼容尚未升级的已安装插件。 + ``media_source`` 是新的规范媒体来源,可以是内置常量或插件扩展成员。模型同时 + 输出两者,并在输入时互相补齐,以兼容尚未升级的已安装插件。 """ name: str = Field(..., description="数据源名称") - media_source: MediaSource = Field(..., description="媒体来源枚举") + media_source: MediaSource = Field(..., description="内置或插件扩展媒体来源") mediaid_prefix: str = Field(..., description="兼容插件使用的媒体ID前缀") api_path: str = Field(..., description="媒体数据源API地址") filter_params: Optional[Dict[str, JsonData]] = Field( @@ -517,7 +517,7 @@ class DiscoverMediaSource(BaseModel): @staticmethod def _media_source_from_prefix(mediaid_prefix: str) -> MediaSource: - """将旧插件使用的历史前缀映射为规范媒体来源枚举。""" + """将旧插件前缀映射为内置或插件扩展媒体来源。""" aliases = { "mangguo": MediaSource.MangoTV, "tencentvideo": MediaSource.TencentVideo, @@ -573,9 +573,9 @@ class MediaRecognizeConvertEventData(RequiredMediaIdentityMixin, ChainEventData) Attributes: # 输入参数 - media_source (MediaSource): 输入媒体来源 + media_source (MediaSource): 输入内置或插件扩展媒体来源 media_id (str): 数据源原生 ID - target_media_source (MediaSource): 需要转换到的目标媒体来源 + target_media_source (MediaSource): 需要转换到的内置或插件扩展媒体来源 # 输出参数 media_dict (dict): TheMovieDb/豆瓣的媒体数据 diff --git a/app/schemas/media.py b/app/schemas/media.py index a76b1dab5..6566c29aa 100644 --- a/app/schemas/media.py +++ b/app/schemas/media.py @@ -4,7 +4,7 @@ from app.schemas.types import MediaSource class OptionalMediaIdentityMixin: - """为可选媒体身份模型统一校验来源枚举与原生 ID 的成对约束。""" + """为可选媒体身份模型统一校验内置或插件来源与原生 ID 的成对约束。""" @model_validator(mode="before") @classmethod @@ -52,7 +52,7 @@ class OptionalMediaIdentityMixin: class RequiredMediaIdentityMixin: - """为必填媒体身份模型统一校验来源枚举与原生 ID。""" + """为必填媒体身份模型统一校验内置或插件来源与原生 ID。""" @model_validator(mode="after") def _validate_required_media_identity(self): diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index c5264ce66..cd3793db7 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -224,7 +224,7 @@ class EpisodeFormatRecommendItem(BaseModel): class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel): - """手动整理请求,媒体身份只接受来源枚举与原生 ID。""" + """手动整理请求,媒体身份接受内置或插件来源与原生 ID。""" # 文件项 fileitem: FileItem = None diff --git a/app/schemas/types.py b/app/schemas/types.py index 704891c41..2f38385f0 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -1,6 +1,10 @@ +import re from enum import Enum from typing import Literal, Optional, Tuple, Union +from pydantic import GetJsonSchemaHandler +from pydantic_core import CoreSchema + # 音乐实体命名空间由公共类型模块统一持有,避免模型、接口和工具层重复定义。 MUSIC_ENTITY_RECORDING = "recording" @@ -46,8 +50,20 @@ class MediaType(Enum): }.get(self, self.value) +MEDIA_SOURCE_IDENTIFIER_PATTERN = r"^[a-z][a-z0-9._-]{0,63}$" +_MEDIA_SOURCE_IDENTIFIER_RE = re.compile(MEDIA_SOURCE_IDENTIFIER_PATTERN) +_MEDIA_SOURCE_VALUE_ALIASES = { + "tmdb": "themoviedb", + "audio_db": "theaudiodb", + "douban_music": "doubanmusic", + "mango_tv": "mangguodiscover", + "migu_video": "migu", + "tencent_video": "tencentvideodiscover", +} + + class MediaSource(str, Enum): - """媒体主身份的数据来源。""" + """媒体主身份的数据来源,内置来源为常量,插件来源为动态扩展成员。""" TMDB = "themoviedb" Douban = "douban" @@ -67,8 +83,37 @@ class MediaSource(str, Enum): """返回可直接用于 API 和数据库的规范值。""" return self.value + @classmethod + def _missing_(cls, value: object) -> Optional["MediaSource"]: + """将合法插件来源标识解析为动态枚举成员,并规范化内置别名。""" + if not isinstance(value, str): + return None + normalized = value.strip().casefold() + normalized = _MEDIA_SOURCE_VALUE_ALIASES.get(normalized, normalized) + known_member = cls._value2member_map_.get(normalized) + if known_member: + return known_member + if not _MEDIA_SOURCE_IDENTIFIER_RE.fullmatch(normalized): + return None + member = str.__new__(cls, normalized) + member._name_ = normalized + member._value_ = normalized + cls._value2member_map_.setdefault(normalized, member) + return cls._value2member_map_[normalized] -# 搜索可以选择一个或多个来源,但集合中的每一项都必须是固定枚举。 + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler, + ) -> dict: + """在 OpenAPI 中声明可扩展标识格式,避免把内置成员误写成完整白名单。""" + schema = handler(core_schema) + schema.pop("enum", None) + schema["pattern"] = MEDIA_SOURCE_IDENTIFIER_PATTERN + schema["examples"] = [source.value for source in cls] + return schema + + +# 搜索可以选择一个或多个内置或插件扩展来源。 MediaSourceSelection = Union[MediaSource, Tuple[MediaSource, ...]] diff --git a/app/utils/media.py b/app/utils/media.py index 5fe545a67..7146f2f42 100644 --- a/app/utils/media.py +++ b/app/utils/media.py @@ -75,13 +75,19 @@ def is_music_media_source( def normalize_media_source( source: Optional[Union[MediaSource, str]], ) -> Optional[MediaSource]: - """将来源别名规范化为固定枚举,未知来源返回 None。""" + """将内置别名或插件扩展标识规范化为 MediaSource。""" if not source: return None if isinstance(source, MediaSource): return source normalized = str(source).strip().casefold() - return MEDIA_SOURCE_ALIASES.get(normalized) + builtin_source = MEDIA_SOURCE_ALIASES.get(normalized) + if builtin_source: + return builtin_source + try: + return MediaSource(normalized) + except ValueError: + return None def parse_media_source_selection(value: Optional[str]) -> Tuple[MediaSource, ...]: @@ -90,7 +96,7 @@ def parse_media_source_selection(value: Optional[str]) -> Tuple[MediaSource, ... :param value: 逗号分隔的来源值;空值表示未显式选择来源 :return: 去重后的媒体来源枚举元组 - :raises ValueError: 包含固定枚举之外的来源 + :raises ValueError: 包含格式非法的来源标识 """ if not value: return () @@ -241,5 +247,5 @@ def build_media_key( normalized_id = str(media_id).strip() if media_id is not None else "" if not normalized_source or not normalized_id or normalized_id == "0": return "" - prefix = MEDIA_SOURCE_PREFIXES[normalized_source] + prefix = MEDIA_SOURCE_PREFIXES.get(normalized_source, normalized_source.value) return f"{prefix}:{normalized_id}" diff --git a/database/versions/8a4c7e1d2f90_3_0_0.py b/database/versions/8a4c7e1d2f90_3_0_0.py index 342779101..7ba0d6ebc 100644 --- a/database/versions/8a4c7e1d2f90_3_0_0.py +++ b/database/versions/8a4c7e1d2f90_3_0_0.py @@ -7,6 +7,7 @@ Create Date: 2026-08-12 """ from collections.abc import Iterable +import re from alembic import op import sqlalchemy as sa @@ -69,16 +70,15 @@ SOURCE_ALIASES = { "tencentvideodiscover": "tencentvideodiscover", "tencent_video": "tencentvideodiscover", } -MEDIA_SOURCE_VALUES = frozenset(SOURCE_ALIASES.values()) -MEDIA_SOURCE_SQL_VALUES = ", ".join( - f"'{source}'" for source in sorted(MEDIA_SOURCE_VALUES) -) MEDIA_IDENTITY_CHECK_SQL = ( "(media_source IS NULL AND media_id IS NULL) OR " "(media_source IS NOT NULL AND " - f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND " + "trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND " + "length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND " + "media_source NOT LIKE '% %' AND " "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" ) +MEDIA_SOURCE_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,63}$") def _inspector() -> sa.Inspector: @@ -118,7 +118,7 @@ def _identity_missing(table: sa.TableClause): def _normalize_existing_sources(table_name: str) -> None: - """把旧版本允许的来源别名规范化为当前枚举值。""" + """规范内置来源别名,并保留插件注册的扩展来源标识。""" table = sa.table( table_name, sa.column("media_source", sa.String()), @@ -133,7 +133,7 @@ def _normalize_existing_sources(table_name: str) -> None: connection.execute( table.update() .where(table.c.media_source.is_not(None)) - .values(media_source=sa.func.trim(table.c.media_source)) + .values(media_source=sa.func.lower(sa.func.trim(table.c.media_source))) ) @@ -147,9 +147,9 @@ def _clear_invalid_or_partial_identity(table_name: str) -> None: invalid_identity = sa.or_( table.c.media_source.is_(None), sa.func.trim(table.c.media_source) == "", - sa.func.lower(sa.func.trim(table.c.media_source)).not_in( - MEDIA_SOURCE_VALUES - ), + sa.func.length(sa.func.trim(table.c.media_source)) > 64, + sa.func.trim(table.c.media_source).contains(":"), + sa.func.trim(table.c.media_source).contains(" "), table.c.media_id.is_(None), sa.func.trim(table.c.media_id) == "", sa.func.trim(table.c.media_id) == "0", @@ -167,11 +167,12 @@ def _clear_invalid_or_partial_identity(table_name: str) -> None: def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None: - """从旧的 ``prefix:id`` 组合字段回填规范身份。""" + """从旧的 ``prefix:id`` 组合字段回填内置或插件扩展身份。""" if "mediaid" not in columns: return table = sa.table( table_name, + sa.column("id", sa.Integer()), sa.column("mediaid", sa.String()), sa.column("media_source", sa.String()), sa.column("media_id", sa.String()), @@ -205,6 +206,32 @@ def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None: ) ) + # 插件来源无法预先枚举,已知别名批量回填后再解析剩余合法前缀。 + connection = op.get_bind() + rows = connection.execute( + sa.select(table.c.id, table.c.mediaid) + .where(_identity_missing(table)) + .where(table.c.mediaid.is_not(None)) + ).mappings().all() + for row in rows: + raw_media_id = str(row["mediaid"]).strip() + raw_source, separator, raw_native_id = raw_media_id.partition(":") + media_source = raw_source.strip().casefold() + media_id = raw_native_id.strip() + if ( + not separator + or not MEDIA_SOURCE_PATTERN.fullmatch(media_source) + or not media_id + or media_id == "0" + ): + continue + connection.execute( + table.update() + .where(table.c.id == row["id"]) + .where(_identity_missing(table)) + .values(media_source=media_source, media_id=media_id) + ) + def _backfill_source_columns(table_name: str, columns: set[str]) -> None: """按确定优先级从旧的来源专用字段回填规范身份。""" @@ -302,7 +329,7 @@ def _ensure_identity_indexes() -> None: def _ensure_identity_constraints() -> None: - """为六张通用媒体表建立来源枚举与身份成对数据库约束。""" + """为六张通用媒体表建立可扩展来源与身份成对数据库约束。""" for table_name in LEGACY_COLUMNS: if not _has_table(table_name): continue diff --git a/database/versions/b3d7e9f1a2c4_3_0_0.py b/database/versions/b3d7e9f1a2c4_3_0_0.py new file mode 100644 index 000000000..2ca29fe86 --- /dev/null +++ b/database/versions/b3d7e9f1a2c4_3_0_0.py @@ -0,0 +1,74 @@ +"""3.0.0 +允许插件扩展媒体来源 + +Revision ID: b3d7e9f1a2c4 +Revises: e3d9f4b7c806 +Create Date: 2026-08-13 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "b3d7e9f1a2c4" +down_revision = "e3d9f4b7c806" +branch_labels = None +depends_on = None + + +MEDIA_TABLES = ( + "subscribe", + "subscribehistory", + "downloadhistory", + "transferhistory", + "downloadfailure", + "mediaserveritem", +) +EXTENSIBLE_IDENTITY_CHECK_SQL = ( + "(media_source IS NULL AND media_id IS NULL) OR " + "(media_source IS NOT NULL AND " + "trim(media_source) <> '' AND media_source = lower(trim(media_source)) AND " + "length(media_source) <= 64 AND media_source NOT LIKE '%:%' AND " + "media_source NOT LIKE '% %' AND " + "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" +) +BUILTIN_IDENTITY_CHECK_SQL = ( + "(media_source IS NULL AND media_id IS NULL) OR " + "(media_source IS NOT NULL AND media_source IN (" + "'anilist', 'bangumi', 'bilibili', 'douban', 'doubanmusic', 'imdb', " + "'mangguodiscover', 'migu', 'musicbrainz', 'tencentvideodiscover', " + "'theaudiodb', 'themoviedb', 'tvdb') AND " + "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" +) + + +def _inspector() -> sa.Inspector: + """返回使用当前迁移连接的数据库检查器。""" + return sa.inspect(op.get_bind()) + + +def _replace_constraints(check_sql: str) -> None: + """在现有媒体表上以批处理方式替换统一身份约束。""" + table_names = set(_inspector().get_table_names()) + for table_name in MEDIA_TABLES: + if table_name not in table_names: + continue + constraint_name = f"ck_{table_name}_media_identity" + existing = { + constraint.get("name") + for constraint in _inspector().get_check_constraints(table_name) + } + with op.batch_alter_table(table_name) as batch_op: + if constraint_name in existing: + batch_op.drop_constraint(constraint_name, type_="check") + batch_op.create_check_constraint(constraint_name, check_sql) + + +def upgrade() -> None: + """把固定内置来源白名单替换为允许插件来源的格式约束。""" + _replace_constraints(EXTENSIBLE_IDENTITY_CHECK_SQL) + + +def downgrade() -> None: + """恢复只允许当前内置来源的旧约束。""" + _replace_constraints(BUILTIN_IDENTITY_CHECK_SQL) diff --git a/docs/cli.md b/docs/cli.md index b81ea2fcf..2fb58784a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -489,7 +489,7 @@ moviepilot tool run search_torrents media_type=movie media_source=themoviedb med - `tool list` 用于动态发现当前服务可调用的工具 - `tool show` 会输出参数名、类型和描述 - `tool run` 参数格式固定为 `key=value` -- 涉及精确媒体身份的通用工具统一使用 `media_source` + `media_id`;`media_source` 必须是工具 Schema 列出的 `MediaSource` 枚举值,两个字段必须成对传递并复用搜索结果。TMDB 等单数据源专属工具按各自 Schema 保留原生 ID 参数 +- 涉及精确媒体身份的通用工具统一使用 `media_source` + `media_id`;内置来源使用工具 Schema 中的 `MediaSource` 常量,插件可以注册符合 Schema 格式的扩展来源,两个字段必须成对传递并复用搜索结果。TMDB 等单数据源专属工具按各自 Schema 保留原生 ID 参数 - `read_file`、`write_file`、`edit_file` 和 `execute_command` 属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时 由 Agent 按当前用户权限直接调用这些工具。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index ca89da74a..a59ce49ff 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -131,13 +131,13 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 #### 媒体识别 / 整理 -媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。`media_source` 必须是 `MediaSource` 枚举值:`themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic`、`bilibili`、`mangguodiscover`、`migu` 或 `tencentvideodiscover`;`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 +媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。内置来源通过 `MediaSource` 提供 `themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic`、`bilibili`、`mangguodiscover`、`migu` 和 `tencentvideodiscover` 等常量;该列表不是插件来源白名单,插件可以注册符合 OpenAPI 格式约束的稳定扩展标识。`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `media_source`,或通过完整的 `media_source` + `media_id` 精确指定时使用,不修改系统默认值,也不会跨来源兜底。`MediaInfo` 响应仍可能包含 `tmdb_id`、`douban_id`、`bangumi_id`、`anilist_id` 等跨源映射辅助字段,但这些字段不是通用请求入口。明确归属 `/tmdb`、`/douban`、`/bangumi`、`/anilist` 的接口,以及固定使用 TMDB 的剧集组和排期接口,仍可按其单数据源契约接收原生 ID。 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/media/search` | 按标题搜索媒体、合集、人物或音乐,参数:`title`、`type`、`page`、`count`,可重复传入可选 `media_source`;不同搜索类型仅接受其支持的 `MediaSource` 枚举值,旧客户端的逗号格式仅在输入边界兼容 | +| GET | `/api/v1/media/search` | 按标题搜索媒体、合集、人物或音乐,参数:`title`、`type`、`page`、`count`,可重复传入可选 `media_source`;内置模块只处理自身支持的来源,插件模块可以处理其注册的扩展来源,旧客户端的逗号格式仅在输入边界兼容 | | GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | | GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | | GET | `/api/v1/media/{media_id}` | 按原生 ID 查询媒体详情;必填参数:`media_source`、`type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份 | @@ -296,7 +296,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用 `start_line`、`end_line` 指定更小的行号范围继续读取。 -媒体相关 MCP 工具以 `MediaSource` 枚举 `media_source` + 来源原生 `media_id` 传递精确身份。`query_media_detail`、`search_torrents`、`query_library_exists` 必须提供完整字段对;`add_subscribe`、`transfer_file`、`scrape_metadata` 在显式指定身份时也必须成对提供。`search_media` 和 `recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口。 +媒体相关 MCP 工具以 `media_source` + 来源原生 `media_id` 传递精确身份;内置来源使用 `MediaSource` 常量,插件来源使用注册的稳定扩展标识。`query_media_detail`、`search_torrents`、`query_library_exists` 必须提供完整字段对;`add_subscribe`、`transfer_file`、`scrape_metadata` 在显式指定身份时也必须成对提供。`search_media` 和 `recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口。 Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。 diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 615bd0586..369678750 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -229,9 +229,10 @@ moviepilot scheduler run subscribe_refresh ``` **Media identity rule:** Generic media tools use the complete `media_source` + -`media_id` pair returned by media search. `media_source` must be a `MediaSource` -enum value. A source-owned tool such as `query_episode_schedule` may retain its -native ID parameter because its schema and implementation are single-source. +`media_id` pair returned by media search. Built-in sources use `MediaSource` +constants; plugins may register a schema-valid extension identifier. A +source-owned tool such as `query_episode_schedule` may retain its native ID +parameter because its schema and implementation are single-source. --- diff --git a/tests/test_agent_music_tools.py b/tests/test_agent_music_tools.py index c75f07c74..c24371fdb 100644 --- a/tests/test_agent_music_tools.py +++ b/tests/test_agent_music_tools.py @@ -291,8 +291,8 @@ def test_scrape_album_uses_unified_entity_recognition(tmp_path): assert async_recognize.await_args.kwargs["music_type"] == "album" -def test_scrape_metadata_rejects_unknown_media_source_before_file_access(tmp_path): - """Agent 直接调用工具时也必须拒绝固定枚举之外的媒体来源。""" +def test_scrape_metadata_rejects_invalid_media_source_before_file_access(tmp_path): + """Agent 直接调用工具时也必须拒绝格式非法的媒体来源。""" audio_file = tmp_path / "unknown-source.flac" audio_file.write_bytes(b"audio") tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") @@ -300,7 +300,7 @@ def test_scrape_metadata_rejects_unknown_media_source_before_file_access(tmp_pat result = asyncio.run(tool.run( path=str(audio_file), media_type="music", - media_source="plugin-source", + media_source="plugin source:invalid", media_id="recording-1", )) diff --git a/tests/test_bangumi_media_type.py b/tests/test_bangumi_media_type.py index 7e62e2fc8..0d9d08407 100644 --- a/tests/test_bangumi_media_type.py +++ b/tests/test_bangumi_media_type.py @@ -1,5 +1,6 @@ import asyncio from typing import Optional +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -97,8 +98,8 @@ def test_bangumi_movie_conversion_uses_movie_type() -> None: assert chain.douban_mtype == MediaType.MOVIE -def test_media_identity_conversion_rejects_invalid_or_unsupported_pairs() -> None: - """跨源转换只接受完整非零 pair 和受支持的来源组合。""" +def test_media_identity_conversion_rejects_invalid_pair_without_plugin_handler() -> None: + """跨源转换拒绝无效 pair,且没有插件处理器时返回空结果。""" chain = _SyncBangumiMediaChain() assert MediaChain.convert_media_identity( @@ -107,16 +108,38 @@ def test_media_identity_conversion_rejects_invalid_or_unsupported_pairs() -> Non media_source=MediaSource.Bangumi, media_id="0", ) is None - assert MediaChain.convert_media_identity( - chain, - target_source=MediaSource.TheAudioDB, - media_source=MediaSource.Bangumi, - media_id="1", - ) is None + with patch("app.chain.media.eventmanager.send_event", return_value=None): + assert MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TheAudioDB, + media_source=MediaSource.Bangumi, + media_id="1", + ) is None assert chain.tmdb_mtype is None assert chain.douban_mtype is None +def test_media_identity_conversion_dispatches_plugin_source() -> None: + """内置转换无匹配时应把动态来源交给插件转换事件。""" + chain = _SyncBangumiMediaChain() + result = {"media_source": MediaSource.TMDB, "media_id": "550"} + + def handle_event(_event_type, event_data): + """模拟插件在链式事件中写入转换结果。""" + event_data.media_dict.update(result) + return Mock(event_data=event_data) + + with patch("app.chain.media.eventmanager.send_event", side_effect=handle_event): + converted = MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TMDB, + media_source=MediaSource("acme.video"), + media_id="custom-1", + ) + + assert converted == result + + class _AsyncBangumiMediaChain: """异步Bangumi跨数据源转换测试桩。""" @@ -176,3 +199,27 @@ def test_async_bangumi_movie_conversion_uses_movie_type() -> None: assert douban_info == {"id": "200"} assert chain.tmdb_mtype == MediaType.MOVIE assert chain.douban_mtype == MediaType.MOVIE + + +def test_async_media_identity_conversion_dispatches_plugin_source() -> None: + """异步内置转换无匹配时也应分派插件转换事件。""" + chain = _AsyncBangumiMediaChain() + result = {"media_source": MediaSource.Douban, "media_id": "1295644"} + + async def handle_event(_event_type, event_data): + """模拟异步插件在链式事件中写入转换结果。""" + event_data.media_dict.update(result) + return Mock(event_data=event_data) + + with patch( + "app.chain.media.eventmanager.async_send_event", + new=AsyncMock(side_effect=handle_event), + ): + converted = asyncio.run(MediaChain.async_convert_media_identity( + chain, + target_source=MediaSource.Douban, + media_source=MediaSource("acme.video"), + media_id="custom-1", + )) + + assert converted == result diff --git a/tests/test_database_index_migration.py b/tests/test_database_index_migration.py index 0045ce7c4..b8b054d9a 100644 --- a/tests/test_database_index_migration.py +++ b/tests/test_database_index_migration.py @@ -143,8 +143,8 @@ with Engine.connect() as connection: "media_idisnull", "media_sourceisnotnull", "media_idisnotnull", - "'themoviedb'", - "'anilist'", + "length(media_source)", + "media_sourcenotlike'%:%'", ): assert fragment in normalized_sql, ( table_name, @@ -174,12 +174,12 @@ with Engine.connect() as connection: "INSERT INTO mediaserveritem (media_source, media_id) " "VALUES (:media_source, :media_id)" ), - {{"media_source": "invalid_source", "media_id": "1"}}, + {{"media_source": "invalid:source", "media_id": "1"}}, ) except IntegrityError as error: assert constraint_name in str(error.orig), str(error.orig) else: - raise AssertionError("非法媒体身份未被具名检查约束拒绝") + raise AssertionError("格式非法的媒体身份未被具名检查约束拒绝") """.format( media_tables=MEDIA_TABLES, legacy_identity_columns=LEGACY_IDENTITY_COLUMNS, diff --git a/tests/test_explicit_response_models.py b/tests/test_explicit_response_models.py index 219f83fed..bbbd9a26b 100644 --- a/tests/test_explicit_response_models.py +++ b/tests/test_explicit_response_models.py @@ -153,8 +153,15 @@ def test_discover_media_source_keeps_legacy_prefix_compatible(): mediaid_prefix="mangguo", api_path="plugin/MangoTVDiscover/discover", ) + plugin_source = DiscoverMediaSource( + name="Acme Video", + media_source=MediaSource("acme.video"), + api_path="plugin/AcmeVideo/discover", + ) assert legacy.media_source is MediaSource.Bilibili assert legacy.model_dump(mode="json")["mediaid_prefix"] == "bilibili" assert current.mediaid_prefix == MediaSource.TencentVideo.value assert historical_alias.media_source is MediaSource.MangoTV + assert plugin_source.media_source == MediaSource("acme.video") + assert plugin_source.mediaid_prefix == "acme.video" diff --git a/tests/test_extensible_media_source_migration.py b/tests/test_extensible_media_source_migration.py new file mode 100644 index 000000000..91746230f --- /dev/null +++ b/tests/test_extensible_media_source_migration.py @@ -0,0 +1,66 @@ +"""插件扩展媒体来源数据库迁移测试。""" + +import importlib + +from alembic.migration import MigrationContext +from alembic.operations import Operations +import pytest +import sqlalchemy as sa + + +def _operations(connection: sa.Connection) -> Operations: + """为内存 SQLite 连接构造 Alembic 操作对象。""" + return Operations(MigrationContext.configure(connection)) + + +def _create_fixed_constraint_table(connection: sa.Connection) -> None: + """创建模拟已执行旧固定白名单 revision 的订阅表。""" + metadata = sa.MetaData() + sa.Table( + "subscribe", + metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("media_source", sa.String), + sa.Column("media_id", sa.String), + sa.CheckConstraint( + "(media_source IS NULL AND media_id IS NULL) OR " + "(media_source IN ('themoviedb', 'douban') AND media_id IS NOT NULL " + "AND trim(media_id) <> '' AND trim(media_id) <> '0')", + name="ck_subscribe_media_identity", + ), + ) + metadata.create_all(connection) + + +def test_upgrade_replaces_fixed_source_whitelist(monkeypatch) -> None: + """升级后应保留原数据、允许插件来源并继续拒绝非法身份。""" + migration = importlib.import_module( + "database.versions.b3d7e9f1a2c4_3_0_0" + ) + engine = sa.create_engine("sqlite://") + + with engine.begin() as connection: + _create_fixed_constraint_table(connection) + connection.execute(sa.text( + "INSERT INTO subscribe (id, media_source, media_id) " + "VALUES (1, 'themoviedb', '550')" + )) + monkeypatch.setattr(migration, "op", _operations(connection)) + + migration.upgrade() + + connection.execute(sa.text( + "INSERT INTO subscribe (id, media_source, media_id) " + "VALUES (2, 'acme.video', 'custom-1')" + )) + with pytest.raises(sa.exc.IntegrityError): + with connection.begin_nested(): + connection.execute(sa.text( + "INSERT INTO subscribe (id, media_source, media_id) " + "VALUES (3, 'invalid:source', 'custom-2')" + )) + rows = connection.execute(sa.text( + "SELECT media_source, media_id FROM subscribe ORDER BY id" + )).all() + + assert rows == [("themoviedb", "550"), ("acme.video", "custom-1")] diff --git a/tests/test_media_identity_cleanup_migration.py b/tests/test_media_identity_cleanup_migration.py index 6a60c2361..8672736ec 100644 --- a/tests/test_media_identity_cleanup_migration.py +++ b/tests/test_media_identity_cleanup_migration.py @@ -171,6 +171,14 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( "doubanid": None, "mediaid": None, }, + { + "id": 7, + "media_source": None, + "media_id": None, + "tmdbid": None, + "doubanid": None, + "mediaid": "acme.video:custom-7", + }, ]) connection.execute(tables["subscribehistory"].insert(), { "id": 1, "mediaid": "bangumi:400602", @@ -225,7 +233,7 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( "themoviedb", "1396", ) assert (subscribe_rows[3]["media_source"], subscribe_rows[3]["media_id"]) == ( - "douban", "35209731", + "plugin_source", "custom-1", ) assert (subscribe_rows[4]["media_source"], subscribe_rows[4]["media_id"]) == ( "douban", "1295644", @@ -233,6 +241,9 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( assert (subscribe_rows[5]["media_source"], subscribe_rows[5]["media_id"]) == ( "musicbrainz", "release-group-1", ) + assert (subscribe_rows[6]["media_source"], subscribe_rows[6]["media_id"]) == ( + "acme.video", "custom-7", + ) assert identities["subscribehistory"]["media_source"] == "bangumi" assert identities["downloadhistory"]["media_source"] == "douban" assert identities["transferhistory"]["media_source"] == "anilist" @@ -257,9 +268,9 @@ def test_cleanup_migration_backfills_every_supported_mediaid_prefix(monkeypatch) "mediaid": f"{prefix}:native-{index}", } for index, (prefix, _) in enumerate(PREFIXED_IDENTITIES, start=1) - ] + [{ - "id": len(PREFIXED_IDENTITIES) + 1, - "mediaid": "audioXdb:must-not-match-alias", + ] + [{ + "id": len(PREFIXED_IDENTITIES) + 1, + "mediaid": "audioXdb:must-not-match-alias", }], ) @@ -283,11 +294,13 @@ def test_cleanup_migration_backfills_every_supported_mediaid_prefix(monkeypatch) (source, f"native-{index}") for index, (_, source) in enumerate(PREFIXED_IDENTITIES, start=1) ] - assert (rows[-1]["media_source"], rows[-1]["media_id"]) == (None, None) + assert (rows[-1]["media_source"], rows[-1]["media_id"]) == ( + "audioxdb", "must-not-match-alias", + ) def test_cleanup_migration_rejects_invalid_database_identity_pairs(monkeypatch) -> None: - """升级后的数据库应拒绝半对、未知来源和零值身份。""" + """升级后的数据库应允许插件来源,并拒绝半对、非法来源和零值身份。""" migration = importlib.import_module( "database.versions.8a4c7e1d2f90_3_0_0" ) @@ -303,7 +316,7 @@ def test_cleanup_migration_rejects_invalid_database_identity_pairs(monkeypatch) for identity in ( {"media_source": "themoviedb", "media_id": None}, {"media_source": None, "media_id": "550"}, - {"media_source": "plugin_source", "media_id": "550"}, + {"media_source": "invalid:source", "media_id": "550"}, {"media_source": "themoviedb", "media_id": "0"}, ): with pytest.raises(sa.exc.IntegrityError): @@ -313,6 +326,12 @@ def test_cleanup_migration_rejects_invalid_database_identity_pairs(monkeypatch) **identity, }) + connection.execute(subscribe.insert(), { + "name": "plugin", + "media_source": "plugin_source", + "media_id": "custom-1", + }) + connection.execute(subscribe.insert(), [ {"name": "empty", "media_source": None, "media_id": None}, { diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py index aa1c0c37a..f7f1d000a 100644 --- a/tests/test_media_search_source_selection.py +++ b/tests/test_media_search_source_selection.py @@ -46,7 +46,7 @@ def test_media_search_endpoint_forwards_source( def test_media_search_endpoint_forwards_multi_source() -> None: - """媒体搜索接口应将逗号分隔来源解析为固定枚举元组。""" + """媒体搜索接口应将逗号分隔来源解析为规范来源元组。""" chain = Mock() chain.async_search = AsyncMock(return_value=(Mock(), [])) @@ -69,7 +69,7 @@ def test_media_search_endpoint_forwards_multi_source() -> None: @pytest.mark.anyio async def test_media_search_route_accepts_comma_separated_music_sources() -> None: - """真实路由在旧逗号格式兼容边界后应把每项转换为固定枚举。""" + """真实路由在旧逗号格式兼容边界后应把每项转换为 MediaSource。""" chain = Mock() chain.async_search_music = AsyncMock(return_value=[]) app = FastAPI() @@ -166,8 +166,8 @@ async def test_media_search_route_deduplicates_repeated_sources() -> None: @pytest.mark.anyio -async def test_media_search_route_rejects_unknown_source() -> None: - """真实搜索路由应在进入处理链前拒绝固定枚举之外的数据源。""" +async def test_media_search_route_forwards_plugin_source() -> None: + """真实搜索路由应把插件扩展来源传入完整模块调度。""" chain = Mock() chain.async_search = AsyncMock(return_value=(Mock(), [])) app = FastAPI() @@ -184,11 +184,11 @@ async def test_media_search_route_rejects_unknown_source() -> None: params={"title": "测试", "media_source": "plugin-source"}, ) - assert response.status_code == 422 - detail = response.json()["detail"][0] - assert detail["type"] == "enum" - assert detail["input"] == "plugin-source" - chain.async_search.assert_not_awaited() + assert response.status_code == 200 + chain.async_search.assert_awaited_once_with( + title="测试", + media_source=(MediaSource("plugin-source"),), + ) @pytest.mark.parametrize( diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index a6f7c1d68..40134dd91 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -22,11 +22,19 @@ def test_generic_source_id_resolves_as_fixed_enum() -> None: ) == (MediaSource.AniList, "154587") -def test_unknown_plugin_source_is_rejected() -> None: - """固定枚举以外的来源不能进入通用识别链。""" +def test_plugin_source_is_preserved_as_dynamic_enum() -> None: + """格式合法的插件来源应作为动态枚举成员进入通用识别链。""" assert resolve_media_identity( media_source="plugin_source", media_id="custom-1", + ) == (MediaSource("plugin_source"), "custom-1") + + +def test_invalid_source_identifier_is_rejected() -> None: + """包含空格或分隔符的来源标识不得进入通用识别链。""" + assert resolve_media_identity( + media_source="Plugin Source:Invalid", + media_id="custom-1", ) == (None, None) diff --git a/tests/test_message_media_identity.py b/tests/test_message_media_identity.py index 44ee2bc71..e4696094f 100644 --- a/tests/test_message_media_identity.py +++ b/tests/test_message_media_identity.py @@ -29,8 +29,8 @@ def test_message_context_contains_primary_and_auxiliary_media_fields() -> None: assert media.to_dict()["media_id"] == "170942" -def test_media_info_rejects_unknown_plugin_source_identity() -> None: - """核心媒体对象应丢弃枚举以外的插件自定义来源。""" +def test_media_info_preserves_plugin_source_identity() -> None: + """核心媒体对象应保留格式合法的插件自定义来源。""" media = MediaInfo( media_source="plugin_source", media_id="custom-100", @@ -38,5 +38,6 @@ def test_media_info_rejects_unknown_plugin_source_identity() -> None: title="插件电影", ) - assert media.media_source is None - assert media.media_id is None + assert media.media_source == MediaSource("plugin_source") + assert media.media_id == "custom-100" + assert media.to_dict()["media_source"] == "plugin_source" diff --git a/tests/test_music_metadata_sources.py b/tests/test_music_metadata_sources.py index 34039c9e2..b34c3a7ce 100644 --- a/tests/test_music_metadata_sources.py +++ b/tests/test_music_metadata_sources.py @@ -509,6 +509,15 @@ async def test_media_chain_aggregates_music_sources_and_isolates_source_failure( ]) source_chains = { "musicbrainz": musicbrainz, + "acme.music": Mock( + async_search_music=AsyncMock(return_value=[ + MusicInfo( + media_source="acme.music", + media_id="plugin-1", + title="Yellow", + ) + ]) + ), "theaudiodb": theaudiodb, "doubanmusic": douban, } @@ -520,6 +529,7 @@ async def test_media_chain_aggregates_music_sources_and_isolates_source_failure( limit=30, media_source=( MediaSource.MusicBrainz, + MediaSource("acme.music"), MediaSource.TheAudioDB, MediaSource.DoubanMusic, MediaSource.MusicBrainz, @@ -528,10 +538,12 @@ async def test_media_chain_aggregates_music_sources_and_isolates_source_failure( assert [(str(item.media_source), item.media_id) for item in results] == [ ("musicbrainz", "recording-1"), + ("acme.music", "plugin-1"), ("doubanmusic", "album-1"), ] assert [str(item.args[0]) for item in select_chain.call_args_list] == [ "musicbrainz", + "acme.music", "theaudiodb", "doubanmusic", ] diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index 79d32d3cf..d404132ef 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -405,8 +405,8 @@ def test_chain_async_supplement_media_recognize(): assert result.media_id == "song-1" -def test_chain_supplement_rejects_unknown_media_source(): - """插件返回非固定枚举来源时不得进入统一识别链。""" +def test_chain_supplement_accepts_plugin_media_source(): + """插件返回的规范扩展来源应进入统一识别链。""" chain = ChainBase() event = Event( ChainEventType.MusicMediaRecognize, @@ -429,4 +429,5 @@ def test_chain_supplement_rejects_unknown_media_source(): mediainfo=None, ) - assert result is None + assert result.media_source == MediaSource("qqmusic") + assert result.media_id == "song-1" diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py index 7617cc118..60f711bda 100644 --- a/tests/test_search_media_sources.py +++ b/tests/test_search_media_sources.py @@ -13,10 +13,11 @@ from app.schemas.types import MediaSource, MediaType from app.utils.media import normalize_media_source -def test_media_source_normalization_rejects_unknown_source() -> None: - """固定枚举之外的来源不能进入统一身份链路。""" - assert normalize_media_source("plugin_source") is None +def test_media_source_normalization_accepts_plugin_source() -> None: + """来源规范化应同时支持内置别名和插件扩展标识。""" + assert normalize_media_source(" Plugin_Source ") == MediaSource("plugin_source") assert normalize_media_source("tmdb") == MediaSource.TMDB + assert normalize_media_source("plugin source:invalid") is None def test_resolve_anilist_search_params_preserves_identity() -> None: diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 76273a6a8..26d8dd423 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -1358,7 +1358,7 @@ def test_subscribe_preserves_explicit_zero_and_numeric_string_values(): {"media_id": "123"}, {"media_source": ""}, {"media_id": ""}, - {"media_source": "unknown", "media_id": "123"}, + {"media_source": "invalid source:", "media_id": "123"}, {"media_source": MediaSource.TMDB, "media_id": "0"}, {"media_source": MediaSource.TMDB, "media_id": " "}, ], diff --git a/tests/test_transfer_tmdb_category.py b/tests/test_transfer_tmdb_category.py index acd1dc983..7002b90be 100644 --- a/tests/test_transfer_tmdb_category.py +++ b/tests/test_transfer_tmdb_category.py @@ -7,8 +7,8 @@ from app.schemas import FileItem, TransferDirectoryConf, TransferTask from app.schemas.types import MediaSource, MediaType -def test_transfer_rejects_partial_or_unknown_explicit_identity() -> None: - """整理公共入口不得把半套身份或未知来源传入后台任务。""" +def test_transfer_rejects_partial_or_invalid_explicit_identity() -> None: + """整理公共入口不得把半套身份或格式非法来源传入后台任务。""" chain = object.__new__(TransferChain) fileitem = FileItem( storage="local", @@ -20,16 +20,16 @@ def test_transfer_rejects_partial_or_unknown_explicit_identity() -> None: fileitem=fileitem, media_source=MediaSource.TMDB, ) - unknown_state, unknown_message = chain.do_transfer( + invalid_state, invalid_message = chain.do_transfer( fileitem=fileitem, - media_source="plugin-source", + media_source="plugin source:invalid", media_id="1234", ) assert not partial_state assert "media_source" in partial_message - assert not unknown_state - assert "media_source" in unknown_message + assert not invalid_state + assert "media_source" in invalid_message def test_transfer_resolves_complete_identity_before_building_tasks(monkeypatch) -> None: