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",
"musicbrainz",
"theaudiodb",
"doubanmusic",
"bilibili",
"mangguodiscover",
"migu",
"tencentvideodiscover",
"iqiyidiscover"
"doubanmusic"
],
"pattern": "^[a-z][a-z0-9._-]{0,63}$",
"title": "MediaSource",
-5
View File
@@ -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),
)
+2 -14
View File
@@ -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: {},
}
+1 -14
View File
@@ -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):
"""
-14
View File
@@ -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",
}
-9
View File
@@ -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 和数据库的规范值。"""
+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。
+1
View File
@@ -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.
+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")
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"
+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"
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:
@@ -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"),
[
+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:
"""爱奇艺探索来源应支持选择解析、身份解析和媒体键构造。"""
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:
+11 -6
View File
@@ -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: