diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 253072a09..d50d6cfc3 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -1706,12 +1706,7 @@ "tvdb", "musicbrainz", "theaudiodb", - "doubanmusic", - "bilibili", - "mangguodiscover", - "migu", - "tencentvideodiscover", - "iqiyidiscover" + "doubanmusic" ], "pattern": "^[a-z][a-z0-9._-]{0,63}$", "title": "MediaSource", diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 1e5d7f8e1..641bab50f 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -64,11 +64,6 @@ _BUILTIN_MEDIA_SOURCES = ( 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), ) diff --git a/app/domain/classification/sources.py b/app/domain/classification/sources.py index f48913338..7278ebaf4 100644 --- a/app/domain/classification/sources.py +++ b/app/domain/classification/sources.py @@ -18,15 +18,8 @@ FIXTURE_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = ( ) """首版分类体系具备真实标准投影 fixture 的内置来源顺序。""" -BUILTIN_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = ( - *FIXTURE_CLASSIFICATION_SOURCES, - MediaSource.Bilibili.value, - MediaSource.MangoTV.value, - MediaSource.MiguVideo.value, - MediaSource.TencentVideo.value, - MediaSource.Iqiyi.value, -) -"""媒体来源 API 当前暴露的全部内置来源顺序。""" +BUILTIN_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = FIXTURE_CLASSIFICATION_SOURCES +"""仅保留宿主模块实现的来源;插件来源由启用插件声明,不预占标识。""" STANDARD_CLASSIFICATION_FIELD_IDS: Final[tuple[str, ...]] = ( "identity.media_source", @@ -155,11 +148,6 @@ _SOURCE_FIELD_SUPPORT: Final[dict[str, dict[str, ClassificationSourceSupport]]] "music.genres": _PARTIAL, "music.tags": _PARTIAL, }, - MediaSource.Bilibili.value: {}, - MediaSource.MangoTV.value: {}, - MediaSource.MiguVideo.value: {}, - MediaSource.TencentVideo.value: {}, - MediaSource.Iqiyi.value: {}, } diff --git a/app/schemas/event.py b/app/schemas/event.py index a40b0fcc6..35a38f731 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -900,22 +900,9 @@ class DiscoverMediaSource(BaseModel): if media_source and not mediaid_prefix: normalized["mediaid_prefix"] = str(media_source) elif mediaid_prefix and not media_source: - normalized["media_source"] = cls._media_source_from_prefix( - str(mediaid_prefix) - ) + normalized["media_source"] = MediaSource(str(mediaid_prefix)) return normalized - @staticmethod - def _media_source_from_prefix(mediaid_prefix: str) -> MediaSource: - """将旧插件前缀映射为内置或插件扩展媒体来源。""" - aliases = { - "mangguo": MediaSource.MangoTV, - "tencentvideo": MediaSource.TencentVideo, - } - if mediaid_prefix in aliases: - return aliases[mediaid_prefix] - return MediaSource(mediaid_prefix) - class MediaSourceInfo(BaseModel): """ diff --git a/app/schemas/media.py b/app/schemas/media.py index ca5e2a341..bb4add604 100644 --- a/app/schemas/media.py +++ b/app/schemas/media.py @@ -28,15 +28,6 @@ MEDIA_SOURCE_ALIASES = { "audio_db": MediaSource.TheAudioDB, "doubanmusic": MediaSource.DoubanMusic, "douban_music": MediaSource.DoubanMusic, - "bilibili": MediaSource.Bilibili, - "mangguodiscover": MediaSource.MangoTV, - "mango_tv": MediaSource.MangoTV, - "migu": MediaSource.MiguVideo, - "migu_video": MediaSource.MiguVideo, - "tencentvideodiscover": MediaSource.TencentVideo, - "tencent_video": MediaSource.TencentVideo, - "iqiyi": MediaSource.Iqiyi, - "iqiyidiscover": MediaSource.Iqiyi, } MEDIA_SOURCE_PREFIXES = { @@ -49,11 +40,6 @@ MEDIA_SOURCE_PREFIXES = { MediaSource.MusicBrainz: "musicbrainz", MediaSource.TheAudioDB: "theaudiodb", MediaSource.DoubanMusic: "doubanmusic", - MediaSource.Bilibili: "bilibili", - MediaSource.MangoTV: "mangguodiscover", - MediaSource.MiguVideo: "migu", - MediaSource.TencentVideo: "tencentvideodiscover", - MediaSource.Iqiyi: "iqiyidiscover", } diff --git a/app/schemas/types.py b/app/schemas/types.py index 8ab951639..96493351a 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -85,10 +85,6 @@ _MEDIA_SOURCE_VALUE_ALIASES = { "tmdb": "themoviedb", "audio_db": "theaudiodb", "douban_music": "doubanmusic", - "mango_tv": "mangguodiscover", - "migu_video": "migu", - "tencent_video": "tencentvideodiscover", - "iqiyi": "iqiyidiscover", } @@ -104,11 +100,6 @@ class MediaSource(str, Enum): MusicBrainz = "musicbrainz" TheAudioDB = "theaudiodb" DoubanMusic = "doubanmusic" - Bilibili = "bilibili" - MangoTV = "mangguodiscover" - MiguVideo = "migu" - TencentVideo = "tencentvideodiscover" - Iqiyi = "iqiyidiscover" def __str__(self) -> str: """返回可直接用于 API 和数据库的规范值。""" diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 7b07a866e..92e3ea3e4 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -265,7 +265,9 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 #### 媒体识别 / 整理 -媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。内置来源通过 `MediaSource` 提供 `themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic`、`bilibili`、`mangguodiscover`、`migu` 和 `tencentvideodiscover` 等常量;该列表不是插件来源白名单,插件可以注册符合 OpenAPI 格式约束的稳定扩展标识。`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 +媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。内置来源通过 `MediaSource` 提供 `themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic` 九个具有宿主模块实现的常量;该列表不是插件来源白名单,插件可以注册符合 OpenAPI 格式约束的稳定扩展标识。`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 + +媒体来源列表 `/api/v1/media/source` 仅预置上述九个来源,其余来源由启用插件注册后提供。哔哩哔哩、芒果 TV、咪咕视频、腾讯视频、爱奇艺不再占用内置来源标识,宿主也不再转换这些插件来源的旧别名;调用方应使用插件声明的准确来源 ID。 影视自动识别在未指定来源时只使用 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。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 57f7f02e4..eea38d4b0 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -552,6 +552,7 @@ Purpose: List seasons for one exact media identity or a title-and-year fallback. ### `media.sources` `GET /api/v1/media/source`; policy effect: `safe_read`. Purpose: List metadata sources currently registered for MoviePilot media operations. +Only sources implemented by host modules are built in. Other sources appear after an enabled plugin registers them; use the exact returned identifier without converting plugin source aliases. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. - `path_params`: none - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result. diff --git a/tests/test_explicit_response_models.py b/tests/test_explicit_response_models.py index 8dce44482..40725d511 100644 --- a/tests/test_explicit_response_models.py +++ b/tests/test_explicit_response_models.py @@ -158,8 +158,8 @@ def test_collection_json_schemas_define_items_or_tuple_members(): assert crew_schema["items"]["$ref"].endswith("/TmdbEpisodeCrew") -def test_discover_media_source_keeps_legacy_prefix_compatible(): - """发现源应兼容旧插件前缀,并同时输出规范媒体来源。""" +def test_discover_media_source_preserves_plugin_identifiers(): + """发现源按插件标识补齐身份字段,不转换插件特定前缀。""" legacy = DiscoverMediaSource( name="哔哩哔哩", mediaid_prefix="bilibili", @@ -167,7 +167,7 @@ def test_discover_media_source_keeps_legacy_prefix_compatible(): ) current = DiscoverMediaSource( name="腾讯视频", - media_source=MediaSource.TencentVideo, + media_source=MediaSource("tencentvideodiscover"), api_path="plugin/TencentVideoDiscover/discover", ) historical_alias = DiscoverMediaSource( @@ -181,9 +181,9 @@ def test_discover_media_source_keeps_legacy_prefix_compatible(): api_path="plugin/AcmeVideo/discover", ) - assert legacy.media_source is MediaSource.Bilibili + assert legacy.media_source == 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 current.mediaid_prefix == "tencentvideodiscover" + assert historical_alias.media_source == MediaSource("mangguo") assert plugin_source.media_source == MediaSource("acme.video") assert plugin_source.mediaid_prefix == "acme.video" diff --git a/tests/test_media_classification_catalog.py b/tests/test_media_classification_catalog.py index 1aac05ca5..9db3a7063 100644 --- a/tests/test_media_classification_catalog.py +++ b/tests/test_media_classification_catalog.py @@ -98,20 +98,16 @@ def test_catalog_separates_retired_fields_from_new_rule_options() -> None: assert retired_fields[0].replacement_field == "media.countries" -def test_discover_only_builtin_sources_are_explicitly_unavailable() -> None: - """仅提供发现入口的内置来源不能被误认为可形成分类事实。""" +def test_plugin_sources_have_no_builtin_capability_declarations() -> None: + """无宿主模块的来源不得预占插件标识或预设不可用的字段能力。""" discover_sources = { - MediaSource.Bilibili.value, - MediaSource.MangoTV.value, - MediaSource.MiguVideo.value, - MediaSource.TencentVideo.value, - MediaSource.Iqiyi.value, + "bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover", } - for media_source in discover_sources: - assert set(builtin_source_field_support(media_source).values()) == { - "unavailable" - } + assert discover_sources.isdisjoint(BUILTIN_CLASSIFICATION_SOURCES) + assert discover_sources.isdisjoint(source.value for source in MediaSource) + for field in get_standard_classification_fields(): + assert discover_sources.isdisjoint(field.source_support) def test_catalog_dictionary_values_match_normalized_facts() -> None: diff --git a/tests/test_media_classification_plugin_extensions.py b/tests/test_media_classification_plugin_extensions.py index 4e5672c95..11fb5249f 100644 --- a/tests/test_media_classification_plugin_extensions.py +++ b/tests/test_media_classification_plugin_extensions.py @@ -306,6 +306,61 @@ def test_registry_rejects_builtin_duplicate_and_cross_plugin_sources() -> None: assert registry.sources(OTHER_PLUGIN_ID) == [] +@pytest.mark.parametrize( + "media_source", + ["bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover"], +) +def test_plugin_owned_sources_follow_registration_lifecycle( + media_source: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """无内置模块的来源由插件注册,API 和分类字段随插件启停增删。""" + from app.api.endpoints.media import _registered_media_sources + from app.application.classification.catalog import build_classification_field_catalog + from app.domain.classification.sources import BUILTIN_CLASSIFICATION_SOURCES + + registry = _registry() + monkeypatch.setattr( + "app.application.plugin.runtime.get_plugin_manager", + lambda: SimpleNamespace(get_media_sources=registry.sources), + ) + source_id = media_source + field_id = f"extensions.{source_id}.region_group" + baseline_sources = _registered_media_sources() + assert {source.media_source.value for source in baseline_sources} == set( + BUILTIN_CLASSIFICATION_SOURCES + ) + assert media_source not in {source.media_source for source in baseline_sources} + assert all( + source_id not in field.source_support + for field in build_classification_field_catalog() + ) + + registry.replace(PLUGIN_ID, [_source( + source_id=source_id, + fields=[_field("region_group", source_id=source_id)], + )]) + sources = _registered_media_sources() + assert [source for source in sources if source.media_source == media_source] == [ + MediaSourceInfo.model_validate(registry.sources()[0]) + ] + fields = {field.id: field for field in build_classification_field_catalog(registry.fields())} + assert fields[field_id].source_support == {source_id: "extension"} + media = MediaInfo( + media_source=media_source, + media_id="native-1", + type=MediaType.MOVIE, + classification_facts={field_id: "亚洲"}, + ) + assert registry.facts(media) == {source_id: {"region_group": "亚洲"}} + + registry.remove(PLUGIN_ID) + assert _registered_media_sources() == baseline_sources + assert field_id not in { + field.id for field in build_classification_field_catalog(registry.fields()) + } + + @pytest.mark.parametrize( ("field", "error_text"), [ diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index eebe519b4..aad606e71 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -26,13 +26,14 @@ def test_generic_source_id_resolves_as_fixed_enum() -> None: def test_iqiyi_source_routes_through_unified_identity() -> None: """爱奇艺探索来源应支持选择解析、身份解析和媒体键构造。""" assert parse_media_source_selection("iqiyi,iqiyidiscover") == ( - MediaSource.Iqiyi, + MediaSource("iqiyi"), MediaSource("iqiyidiscover"), ) assert resolve_media_identity( - media_source="iqiyi", + media_source="iqiyidiscover", media_id="album-1", - ) == (MediaSource.Iqiyi, "album-1") - assert build_media_key("iqiyi", "album-1") == "iqiyidiscover:album-1" + ) == (MediaSource("iqiyidiscover"), "album-1") + assert build_media_key("iqiyidiscover", "album-1") == "iqiyidiscover:album-1" + assert build_media_key("iqiyi", "album-1") == "iqiyi:album-1" def test_plugin_source_is_preserved_as_dynamic_enum() -> None: diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py index e3de08d70..9f8ad3603 100644 --- a/tests/test_search_media_sources.py +++ b/tests/test_search_media_sources.py @@ -7,7 +7,7 @@ import pytest from app.api.endpoints import media as media_endpoint from app.api.endpoints import search as search_endpoint from app.chain.subscribe import create as subscribe_create -from app.chain.subscribe import SubscribeChain +from app.chain.subscribe.facade import SubscribeChain from app.domain.context import MediaInfo from app.schemas.types import MediaSource, MediaType from app.schemas.media import normalize_media_source @@ -21,11 +21,16 @@ def test_media_source_normalization_accepts_plugin_source() -> None: assert normalize_media_source("plugin source:invalid") is None -def test_iqiyi_media_source_aliases_are_normalized() -> None: - """爱奇艺探索来源的历史前缀和规范前缀应归一到同一媒体来源。""" - assert normalize_media_source("iqiyi") is MediaSource.Iqiyi - assert normalize_media_source("iqiyidiscover") is MediaSource.Iqiyi - assert MediaSource("iqiyi") is MediaSource.Iqiyi +@pytest.mark.parametrize("source_id", [ + "bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover", + "mango_tv", "migu_video", "tencent_video", "iqiyi", +]) +def test_plugin_media_sources_preserve_exact_identifiers(source_id: str) -> None: + """插件来源不再属于内置枚举,也不替插件转换来源别名。""" + source = normalize_media_source(source_id) + assert source is MediaSource(source_id) + assert source.value == source_id + assert source not in list(MediaSource) def test_resolve_anilist_search_params_preserves_identity() -> None: