fix: release plugin media sources from built-in declarations

This commit is contained in:
jxxghp
2026-09-08 10:00:33 +08:00
parent bf11461d11
commit ca627a4f00
13 changed files with 92 additions and 90 deletions
@@ -1706,12 +1706,7 @@
"tvdb", "tvdb",
"musicbrainz", "musicbrainz",
"theaudiodb", "theaudiodb",
"doubanmusic", "doubanmusic"
"bilibili",
"mangguodiscover",
"migu",
"tencentvideodiscover",
"iqiyidiscover"
], ],
"pattern": "^[a-z][a-z0-9._-]{0,63}$", "pattern": "^[a-z][a-z0-9._-]{0,63}$",
"title": "MediaSource", "title": "MediaSource",
-5
View File
@@ -64,11 +64,6 @@ _BUILTIN_MEDIA_SOURCES = (
media_source=MediaSource.DoubanMusic, media_source=MediaSource.DoubanMusic,
media_types=[MediaType.MUSIC], 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),
) )
+2 -14
View File
@@ -18,15 +18,8 @@ FIXTURE_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = (
) )
"""首版分类体系具备真实标准投影 fixture 的内置来源顺序。""" """首版分类体系具备真实标准投影 fixture 的内置来源顺序。"""
BUILTIN_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = ( BUILTIN_CLASSIFICATION_SOURCES: Final[tuple[str, ...]] = FIXTURE_CLASSIFICATION_SOURCES
*FIXTURE_CLASSIFICATION_SOURCES, """仅保留宿主模块实现的来源;插件来源由启用插件声明,不预占标识。"""
MediaSource.Bilibili.value,
MediaSource.MangoTV.value,
MediaSource.MiguVideo.value,
MediaSource.TencentVideo.value,
MediaSource.Iqiyi.value,
)
"""媒体来源 API 当前暴露的全部内置来源顺序。"""
STANDARD_CLASSIFICATION_FIELD_IDS: Final[tuple[str, ...]] = ( STANDARD_CLASSIFICATION_FIELD_IDS: Final[tuple[str, ...]] = (
"identity.media_source", "identity.media_source",
@@ -155,11 +148,6 @@ _SOURCE_FIELD_SUPPORT: Final[dict[str, dict[str, ClassificationSourceSupport]]]
"music.genres": _PARTIAL, "music.genres": _PARTIAL,
"music.tags": _PARTIAL, "music.tags": _PARTIAL,
}, },
MediaSource.Bilibili.value: {},
MediaSource.MangoTV.value: {},
MediaSource.MiguVideo.value: {},
MediaSource.TencentVideo.value: {},
MediaSource.Iqiyi.value: {},
} }
+1 -14
View File
@@ -900,22 +900,9 @@ class DiscoverMediaSource(BaseModel):
if media_source and not mediaid_prefix: if media_source and not mediaid_prefix:
normalized["mediaid_prefix"] = str(media_source) normalized["mediaid_prefix"] = str(media_source)
elif mediaid_prefix and not media_source: elif mediaid_prefix and not media_source:
normalized["media_source"] = cls._media_source_from_prefix( normalized["media_source"] = MediaSource(str(mediaid_prefix))
str(mediaid_prefix)
)
return normalized 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): class MediaSourceInfo(BaseModel):
""" """
-14
View File
@@ -28,15 +28,6 @@ MEDIA_SOURCE_ALIASES = {
"audio_db": MediaSource.TheAudioDB, "audio_db": MediaSource.TheAudioDB,
"doubanmusic": MediaSource.DoubanMusic, "doubanmusic": MediaSource.DoubanMusic,
"douban_music": 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 = { MEDIA_SOURCE_PREFIXES = {
@@ -49,11 +40,6 @@ MEDIA_SOURCE_PREFIXES = {
MediaSource.MusicBrainz: "musicbrainz", MediaSource.MusicBrainz: "musicbrainz",
MediaSource.TheAudioDB: "theaudiodb", MediaSource.TheAudioDB: "theaudiodb",
MediaSource.DoubanMusic: "doubanmusic", MediaSource.DoubanMusic: "doubanmusic",
MediaSource.Bilibili: "bilibili",
MediaSource.MangoTV: "mangguodiscover",
MediaSource.MiguVideo: "migu",
MediaSource.TencentVideo: "tencentvideodiscover",
MediaSource.Iqiyi: "iqiyidiscover",
} }
-9
View File
@@ -85,10 +85,6 @@ _MEDIA_SOURCE_VALUE_ALIASES = {
"tmdb": "themoviedb", "tmdb": "themoviedb",
"audio_db": "theaudiodb", "audio_db": "theaudiodb",
"douban_music": "doubanmusic", "douban_music": "doubanmusic",
"mango_tv": "mangguodiscover",
"migu_video": "migu",
"tencent_video": "tencentvideodiscover",
"iqiyi": "iqiyidiscover",
} }
@@ -104,11 +100,6 @@ class MediaSource(str, Enum):
MusicBrainz = "musicbrainz" MusicBrainz = "musicbrainz"
TheAudioDB = "theaudiodb" TheAudioDB = "theaudiodb"
DoubanMusic = "doubanmusic" DoubanMusic = "doubanmusic"
Bilibili = "bilibili"
MangoTV = "mangguodiscover"
MiguVideo = "migu"
TencentVideo = "tencentvideodiscover"
Iqiyi = "iqiyidiscover"
def __str__(self) -> str: def __str__(self) -> str:
"""返回可直接用于 API 和数据库的规范值。""" """返回可直接用于 API 和数据库的规范值。"""
+3 -1
View File
@@ -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。 影视自动识别在未指定来源时只使用 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。
+1
View File
@@ -552,6 +552,7 @@ Purpose: List seasons for one exact media identity or a title-and-year fallback.
### `media.sources` ### `media.sources`
`GET /api/v1/media/source`; policy effect: `safe_read`. `GET /api/v1/media/source`; policy effect: `safe_read`.
Purpose: List metadata sources currently registered for MoviePilot media operations. 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. - `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 - `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. - `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.
+6 -6
View File
@@ -158,8 +158,8 @@ def test_collection_json_schemas_define_items_or_tuple_members():
assert crew_schema["items"]["$ref"].endswith("/TmdbEpisodeCrew") 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( legacy = DiscoverMediaSource(
name="哔哩哔哩", name="哔哩哔哩",
mediaid_prefix="bilibili", mediaid_prefix="bilibili",
@@ -167,7 +167,7 @@ def test_discover_media_source_keeps_legacy_prefix_compatible():
) )
current = DiscoverMediaSource( current = DiscoverMediaSource(
name="腾讯视频", name="腾讯视频",
media_source=MediaSource.TencentVideo, media_source=MediaSource("tencentvideodiscover"),
api_path="plugin/TencentVideoDiscover/discover", api_path="plugin/TencentVideoDiscover/discover",
) )
historical_alias = DiscoverMediaSource( historical_alias = DiscoverMediaSource(
@@ -181,9 +181,9 @@ def test_discover_media_source_keeps_legacy_prefix_compatible():
api_path="plugin/AcmeVideo/discover", 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 legacy.model_dump(mode="json")["mediaid_prefix"] == "bilibili"
assert current.mediaid_prefix == MediaSource.TencentVideo.value assert current.mediaid_prefix == "tencentvideodiscover"
assert historical_alias.media_source is MediaSource.MangoTV assert historical_alias.media_source == MediaSource("mangguo")
assert plugin_source.media_source == MediaSource("acme.video") assert plugin_source.media_source == MediaSource("acme.video")
assert plugin_source.mediaid_prefix == "acme.video" assert plugin_source.mediaid_prefix == "acme.video"
+7 -11
View File
@@ -98,20 +98,16 @@ def test_catalog_separates_retired_fields_from_new_rule_options() -> None:
assert retired_fields[0].replacement_field == "media.countries" 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 = { discover_sources = {
MediaSource.Bilibili.value, "bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover",
MediaSource.MangoTV.value,
MediaSource.MiguVideo.value,
MediaSource.TencentVideo.value,
MediaSource.Iqiyi.value,
} }
for media_source in discover_sources: assert discover_sources.isdisjoint(BUILTIN_CLASSIFICATION_SOURCES)
assert set(builtin_source_field_support(media_source).values()) == { assert discover_sources.isdisjoint(source.value for source in MediaSource)
"unavailable" for field in get_standard_classification_fields():
} assert discover_sources.isdisjoint(field.source_support)
def test_catalog_dictionary_values_match_normalized_facts() -> None: def test_catalog_dictionary_values_match_normalized_facts() -> None:
@@ -306,6 +306,61 @@ def test_registry_rejects_builtin_duplicate_and_cross_plugin_sources() -> None:
assert registry.sources(OTHER_PLUGIN_ID) == [] 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( @pytest.mark.parametrize(
("field", "error_text"), ("field", "error_text"),
[ [
+5 -4
View File
@@ -26,13 +26,14 @@ def test_generic_source_id_resolves_as_fixed_enum() -> None:
def test_iqiyi_source_routes_through_unified_identity() -> None: def test_iqiyi_source_routes_through_unified_identity() -> None:
"""爱奇艺探索来源应支持选择解析、身份解析和媒体键构造。""" """爱奇艺探索来源应支持选择解析、身份解析和媒体键构造。"""
assert parse_media_source_selection("iqiyi,iqiyidiscover") == ( assert parse_media_source_selection("iqiyi,iqiyidiscover") == (
MediaSource.Iqiyi, MediaSource("iqiyi"), MediaSource("iqiyidiscover"),
) )
assert resolve_media_identity( assert resolve_media_identity(
media_source="iqiyi", media_source="iqiyidiscover",
media_id="album-1", media_id="album-1",
) == (MediaSource.Iqiyi, "album-1") ) == (MediaSource("iqiyidiscover"), "album-1")
assert build_media_key("iqiyi", "album-1") == "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: def test_plugin_source_is_preserved_as_dynamic_enum() -> None:
+11 -6
View File
@@ -7,7 +7,7 @@ import pytest
from app.api.endpoints import media as media_endpoint from app.api.endpoints import media as media_endpoint
from app.api.endpoints import search as search_endpoint from app.api.endpoints import search as search_endpoint
from app.chain.subscribe import create as subscribe_create 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.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType from app.schemas.types import MediaSource, MediaType
from app.schemas.media import normalize_media_source 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 assert normalize_media_source("plugin source:invalid") is None
def test_iqiyi_media_source_aliases_are_normalized() -> None: @pytest.mark.parametrize("source_id", [
"""爱奇艺探索来源的历史前缀和规范前缀应归一到同一媒体来源。""" "bilibili", "mangguodiscover", "migu", "tencentvideodiscover", "iqiyidiscover",
assert normalize_media_source("iqiyi") is MediaSource.Iqiyi "mango_tv", "migu_video", "tencent_video", "iqiyi",
assert normalize_media_source("iqiyidiscover") is MediaSource.Iqiyi ])
assert MediaSource("iqiyi") is MediaSource.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: def test_resolve_anilist_search_params_preserves_identity() -> None: