mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
feat(music): 支持多源识别与元数据刮削
This commit is contained in:
@@ -134,10 +134,7 @@ class RecognizeMediaTool(MoviePilotTool):
|
||||
metainfo.artists = [artist]
|
||||
if album:
|
||||
metainfo.album = album
|
||||
mediainfo = await music_chain.async_recognize_media(
|
||||
meta=metainfo,
|
||||
source="musicbrainz",
|
||||
)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
|
||||
if mediainfo:
|
||||
context = Context(meta_info=metainfo, media_info=mediainfo)
|
||||
return self._format_context_result(context, "音乐标题")
|
||||
|
||||
@@ -43,7 +43,11 @@ class ScrapeMetadataInput(BaseModel):
|
||||
)
|
||||
media_source: Optional[str] = Field(
|
||||
None,
|
||||
description="Music metadata source, normally musicbrainz. Must be paired with media_id",
|
||||
description=(
|
||||
"Music metadata source: musicbrainz, theaudiodb, or doubanmusic. "
|
||||
"When omitted, automatic music recognition compares all sources. "
|
||||
"Must be paired with media_id when an ID is supplied"
|
||||
),
|
||||
)
|
||||
media_id: Optional[str] = Field(
|
||||
None,
|
||||
@@ -209,6 +213,7 @@ class ScrapeMetadataTool(MoviePilotTool):
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=bool(overwrite),
|
||||
source=media_source,
|
||||
)
|
||||
result = {
|
||||
"success": success,
|
||||
|
||||
@@ -18,7 +18,15 @@ from app.schemas.types import SystemConfigKey
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
|
||||
MediaSource = Literal[
|
||||
"themoviedb",
|
||||
"douban",
|
||||
"bangumi",
|
||||
"anilist",
|
||||
"musicbrainz",
|
||||
"theaudiodb",
|
||||
"doubanmusic",
|
||||
]
|
||||
|
||||
|
||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||
|
||||
@@ -19,14 +19,18 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus
|
||||
from app.schemas import MediaType, MediaRecognizeConvertEventData
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.media import MEDIA_SOURCE_ID_FIELDS, parse_media_key
|
||||
from app.utils.media import (
|
||||
MEDIA_SOURCE_ID_FIELDS,
|
||||
is_music_media_source,
|
||||
parse_media_key,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = str
|
||||
|
||||
|
||||
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
|
||||
"""按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它现有来源使用数字 ID。"""
|
||||
"""按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它内置来源使用数字 ID。"""
|
||||
if source == "musicbrainz":
|
||||
try:
|
||||
UUID(media_id)
|
||||
@@ -122,8 +126,8 @@ async def recognize(
|
||||
"""
|
||||
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
|
||||
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
|
||||
# MusicBrainz 仅支持音乐识别,非音频后缀的标题统一按音乐元数据解析
|
||||
if source == "musicbrainz" and not isinstance(metainfo, MetaMusic):
|
||||
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
|
||||
if is_music_media_source(source) and not isinstance(metainfo, MetaMusic):
|
||||
metainfo = MusicChain.parse_query(title)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
@@ -218,9 +222,13 @@ async def search(
|
||||
return obj.source
|
||||
|
||||
media_chain = MediaChain()
|
||||
if type == "music" or source == "musicbrainz":
|
||||
if type == "music" or is_music_media_source(source):
|
||||
# 音乐搜索统一入口,与影视搜索共用 /media/search
|
||||
music_infos = await MusicChain().async_search(query=title, limit=count)
|
||||
music_search_params = {"query": title, "limit": count}
|
||||
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
|
||||
if source:
|
||||
music_search_params["source"] = source
|
||||
music_infos = await MusicChain().async_search(**music_search_params)
|
||||
return [
|
||||
info.to_dict()
|
||||
for info in music_infos
|
||||
@@ -283,12 +291,12 @@ def scrape(
|
||||
|
||||
is_music = (
|
||||
type_name == MediaType.MUSIC
|
||||
or media_source == "musicbrainz"
|
||||
or is_music_media_source(media_source)
|
||||
or MediaChain.is_audio_path(fileitem.path)
|
||||
)
|
||||
if is_music:
|
||||
if type_name not in (None, MediaType.MUSIC):
|
||||
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
|
||||
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
|
||||
music_info: Optional[MusicInfo] = None
|
||||
if normalized_media_id:
|
||||
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
@@ -303,6 +311,7 @@ def scrape(
|
||||
fileitem=fileitem,
|
||||
mediainfo=music_info,
|
||||
overwrite=True,
|
||||
source=media_source,
|
||||
)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ router = APIRouter()
|
||||
|
||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
||||
PageParam = Annotated[int, Query(ge=1)]
|
||||
MusicSourceParam = Annotated[str, Query(pattern="^musicbrainz$")]
|
||||
MusicSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
|
||||
]
|
||||
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
|
||||
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
|
||||
MusicRangeParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_CHART_RANGES)})$")]
|
||||
|
||||
@@ -41,7 +41,7 @@ from app.schemas import (
|
||||
MessageResponse,
|
||||
)
|
||||
from app.utils.identity import normalize_internal_user_id
|
||||
from app.utils.media import normalize_media_source
|
||||
from app.utils.media import is_music_media_source, normalize_media_source
|
||||
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import (
|
||||
@@ -635,6 +635,72 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return "anilist", None, None, None, int(anilistid)
|
||||
return source, None, None, None, None
|
||||
|
||||
def _run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按媒体领域执行同步原生识别,音乐请求只允许进入音乐数据源。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
mtype = module_kwargs.get("mtype")
|
||||
source = module_kwargs.get("source")
|
||||
if (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or is_music_media_source(source)
|
||||
):
|
||||
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
music_chain = MusicChain()
|
||||
if source:
|
||||
with fresh(not cache):
|
||||
return music_chain.recognize_from_source(
|
||||
source=source,
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
mediaid=module_kwargs.get("mediaid"),
|
||||
cache=cache,
|
||||
)
|
||||
if isinstance(meta, MetaMusic):
|
||||
return music_chain.recognize_best(meta=meta, cache=cache)
|
||||
return None
|
||||
with fresh(not cache):
|
||||
return self.run_module("recognize_media", **module_kwargs)
|
||||
|
||||
async def _async_run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按媒体领域执行异步原生识别,音乐请求只允许进入音乐数据源。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
mtype = module_kwargs.get("mtype")
|
||||
source = module_kwargs.get("source")
|
||||
if (
|
||||
isinstance(meta, MetaMusic)
|
||||
or mtype == MediaType.MUSIC
|
||||
or is_music_media_source(source)
|
||||
):
|
||||
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
music_chain = MusicChain()
|
||||
if source:
|
||||
async with async_fresh(not cache):
|
||||
return await music_chain.async_recognize_from_source(
|
||||
source=source,
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
mediaid=module_kwargs.get("mediaid"),
|
||||
cache=cache,
|
||||
)
|
||||
if isinstance(meta, MetaMusic):
|
||||
return await music_chain.async_recognize_best(meta=meta, cache=cache)
|
||||
return None
|
||||
async with async_fresh(not cache):
|
||||
return await self.async_run_module(
|
||||
"async_recognize_media", **module_kwargs
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
@@ -685,7 +751,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
anilistid=anilistid,
|
||||
)
|
||||
# 检索显式 TMDB ID 由请求方自行消歧,不能被标题推断类型误导。
|
||||
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
if not mtype and not tmdbid and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
@@ -700,11 +768,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
with fresh(not cache):
|
||||
mediainfo = self.run_module(
|
||||
"recognize_media",
|
||||
**module_kwargs,
|
||||
)
|
||||
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = self._supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, source=source,
|
||||
@@ -731,20 +795,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
with fresh(not cache):
|
||||
mediainfo = self.run_module(
|
||||
"recognize_media",
|
||||
meta=meta,
|
||||
mtype=shared_params.get("mtype") or mtype,
|
||||
source=shared_params.get("source"),
|
||||
mediaid=shared_params.get("mediaid"),
|
||||
tmdbid=shared_params.get("tmdbid"),
|
||||
doubanid=shared_params.get("doubanid"),
|
||||
bangumiid=shared_params.get("bangumiid"),
|
||||
anilistid=shared_params.get("anilistid"),
|
||||
episode_group=episode_group,
|
||||
cache=cache,
|
||||
)
|
||||
mediainfo = self._run_native_media_recognize(
|
||||
{
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"source": shared_params.get("source"),
|
||||
"mediaid": shared_params.get("mediaid"),
|
||||
"tmdbid": shared_params.get("tmdbid"),
|
||||
"doubanid": shared_params.get("doubanid"),
|
||||
"bangumiid": shared_params.get("bangumiid"),
|
||||
"anilistid": shared_params.get("anilistid"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
},
|
||||
cache,
|
||||
)
|
||||
if mediainfo:
|
||||
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
self._record_media_recognize_share_hit()
|
||||
@@ -801,7 +866,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
anilistid=anilistid,
|
||||
)
|
||||
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
|
||||
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
|
||||
if not mtype and not tmdbid and meta and meta.type in [
|
||||
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
|
||||
]:
|
||||
mtype = meta.type
|
||||
share_query_meta = share_meta or meta
|
||||
module_kwargs = {
|
||||
@@ -816,11 +883,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
}
|
||||
async with async_fresh(not cache):
|
||||
mediainfo = await self.async_run_module(
|
||||
"async_recognize_media",
|
||||
**module_kwargs,
|
||||
)
|
||||
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
|
||||
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
|
||||
mediainfo = await self._async_supplement_media_recognize(
|
||||
meta=meta, mtype=mtype, source=source,
|
||||
@@ -847,20 +910,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
)
|
||||
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
|
||||
if shared_params:
|
||||
async with async_fresh(not cache):
|
||||
mediainfo = await self.async_run_module(
|
||||
"async_recognize_media",
|
||||
meta=meta,
|
||||
mtype=shared_params.get("mtype") or mtype,
|
||||
source=shared_params.get("source"),
|
||||
mediaid=shared_params.get("mediaid"),
|
||||
tmdbid=shared_params.get("tmdbid"),
|
||||
doubanid=shared_params.get("doubanid"),
|
||||
bangumiid=shared_params.get("bangumiid"),
|
||||
anilistid=shared_params.get("anilistid"),
|
||||
episode_group=episode_group,
|
||||
cache=cache,
|
||||
)
|
||||
mediainfo = await self._async_run_native_media_recognize(
|
||||
{
|
||||
"meta": meta,
|
||||
"mtype": shared_params.get("mtype") or mtype,
|
||||
"source": shared_params.get("source"),
|
||||
"mediaid": shared_params.get("mediaid"),
|
||||
"tmdbid": shared_params.get("tmdbid"),
|
||||
"doubanid": shared_params.get("doubanid"),
|
||||
"bangumiid": shared_params.get("bangumiid"),
|
||||
"anilistid": shared_params.get("anilistid"),
|
||||
"episode_group": episode_group,
|
||||
"cache": cache,
|
||||
},
|
||||
cache,
|
||||
)
|
||||
if mediainfo:
|
||||
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
|
||||
await run_in_threadpool(self._record_media_recognize_share_hit)
|
||||
@@ -1122,6 +1186,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if mediainfo and mediainfo.type == MediaType.MUSIC:
|
||||
return mediainfo
|
||||
return self.run_module("obtain_images", mediainfo=mediainfo)
|
||||
|
||||
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
@@ -1130,6 +1196,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if mediainfo and mediainfo.type == MediaType.MUSIC:
|
||||
return mediainfo
|
||||
return await self.async_run_module("async_obtain_images", mediainfo=mediainfo)
|
||||
|
||||
def obtain_specific_image(
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.schemas.types import (
|
||||
SystemConfigKey,
|
||||
)
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_music_media_source
|
||||
from app.utils.mixins import ConfigReloadMixin
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
@@ -211,6 +212,34 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self.storagechain = StorageChain()
|
||||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||||
|
||||
def _run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""自动音乐识别交给 MusicChain 多源评分,显式来源保持原有单源分发。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
|
||||
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
return MusicChain().recognize_best(meta=meta, cache=cache)
|
||||
return super()._run_native_media_recognize(module_kwargs, cache)
|
||||
|
||||
async def _async_run_native_media_recognize(
|
||||
self,
|
||||
module_kwargs: dict,
|
||||
cache: bool,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""异步自动音乐识别并发比较多源结果,显式来源保持原有单源分发。"""
|
||||
meta = module_kwargs.get("meta")
|
||||
if isinstance(meta, MetaMusic) and not module_kwargs.get("source"):
|
||||
# 延迟导入保持 MediaChain -> MusicChain 的单向依赖。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
return await MusicChain().async_recognize_best(meta=meta, cache=cache)
|
||||
return await super()._async_run_native_media_recognize(module_kwargs, cache)
|
||||
|
||||
def on_config_changed(self):
|
||||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||||
|
||||
@@ -641,6 +670,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息
|
||||
@@ -649,9 +679,11 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
"""
|
||||
mediainfo = self._recognize_with_fallback_by_meta(
|
||||
metainfo=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
episode_group=episode_group,
|
||||
obtain_images=obtain_images,
|
||||
@@ -772,6 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
def _recognize_with_fallback_by_meta(
|
||||
self,
|
||||
metainfo: MetaBase,
|
||||
mtype: Optional[MediaType] = None,
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
@@ -780,6 +813,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
根据标题识别媒体信息,必要时回退到辅助识别。
|
||||
|
||||
:param metainfo: 标题解析元数据
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
@@ -790,7 +824,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = metainfo.title
|
||||
share_meta = deepcopy(metainfo)
|
||||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||||
is_music = isinstance(metainfo, MetaMusic)
|
||||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||||
is_recognized = (
|
||||
(lambda result: bool(result and result.source)) if is_music else None
|
||||
)
|
||||
@@ -799,6 +833,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""使用请求级数据源执行原生识别。"""
|
||||
return self.recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
share_meta=share_meta,
|
||||
episode_group=episode_group,
|
||||
@@ -806,6 +841,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
def plugin_recognize() -> Optional[MediaInfo]:
|
||||
"""执行辅助识别并保持请求级数据源约束。"""
|
||||
if is_music and not isinstance(metainfo, MetaMusic):
|
||||
return None
|
||||
return self.recognize_help(
|
||||
title=title,
|
||||
org_meta=metainfo,
|
||||
@@ -1009,11 +1046,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
|
||||
file_path = Path(path)
|
||||
if file_path.exists() and file_path.is_file():
|
||||
meta = AudioMetadataHelper.read(file_path)
|
||||
else:
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
return AudioMetadataHelper.read(file_path)
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
|
||||
return meta.apply_path_context(file_path)
|
||||
|
||||
@@ -1070,7 +1106,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
def recognize_music_by_path(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
source: str = "musicbrainz",
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[MetaMusic, MusicInfo]:
|
||||
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
|
||||
meta = self.read_path_meta(path)
|
||||
@@ -1079,7 +1115,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
result = self._merge_music_audio_quality(
|
||||
info or self._music_info_from_path_meta(meta), meta
|
||||
)
|
||||
if not result.source:
|
||||
if not result.source and source in (None, "musicbrainz"):
|
||||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||||
matched = self._music_album_dir_fallback(path)
|
||||
if matched:
|
||||
@@ -1089,7 +1125,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
async def async_recognize_music_by_path(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
source: str = "musicbrainz",
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[MetaMusic, MusicInfo]:
|
||||
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
|
||||
# Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。
|
||||
@@ -1099,7 +1135,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
result = self._merge_music_audio_quality(
|
||||
info or self._music_info_from_path_meta(meta), meta
|
||||
)
|
||||
if not result.source:
|
||||
if not result.source and source in (None, "musicbrainz"):
|
||||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||||
matched = await run_in_threadpool(self._music_album_dir_fallback, path)
|
||||
if matched:
|
||||
@@ -1108,7 +1144,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
def _is_music_path_request(self, path: str, source: Optional[str]) -> bool:
|
||||
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
|
||||
return self.is_audio_path(path) or source == "musicbrainz"
|
||||
return self.is_audio_path(path) or is_music_media_source(source)
|
||||
|
||||
def recognize_by_path(
|
||||
self,
|
||||
@@ -1130,7 +1166,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||||
if self._is_music_path_request(path, source):
|
||||
music_meta, music_info = self.recognize_music_by_path(
|
||||
path, source=source or "musicbrainz"
|
||||
path, source=source
|
||||
)
|
||||
return Context(meta_info=music_meta, media_info=music_info)
|
||||
file_path = Path(path)
|
||||
@@ -1655,6 +1691,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
fileitem: schemas.FileItem,
|
||||
mediainfo: Optional[MusicInfo] = None,
|
||||
overwrite: bool = True,
|
||||
source: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
|
||||
|
||||
@@ -1668,7 +1705,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
and len(files) > 1
|
||||
and mediainfo.music_type != MUSIC_ENTITY_ALBUM
|
||||
):
|
||||
return False, "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
|
||||
# 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。
|
||||
nfo_option = self.scraping_policies.option("music", "nfo")
|
||||
@@ -1726,6 +1763,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=overwrite or lyrics_option.is_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
if not result.metadata_success:
|
||||
failures.append(
|
||||
@@ -1806,6 +1844,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite: bool = False,
|
||||
music_chain: Optional["MusicChain"] = None,
|
||||
album_info: Optional[MusicAlbumInfo] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。"""
|
||||
storage = self.storagechain
|
||||
@@ -1834,6 +1873,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
|
||||
@@ -1853,6 +1893,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def _apply_music_file_scrape(
|
||||
@@ -1869,9 +1910,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
lyrics_overwrite: bool,
|
||||
music_chain: Optional["MusicChain"],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
source: Optional[str],
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, source=source)
|
||||
metadata_requested = write_tags or with_cover
|
||||
metadata_success = True
|
||||
if metadata_requested:
|
||||
@@ -1884,6 +1926,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover_overwrite=cover_overwrite,
|
||||
cover=cover,
|
||||
scrape_info=scrape_info,
|
||||
source=source,
|
||||
)
|
||||
|
||||
lyrics_status = self._scrape_music_lyrics(
|
||||
@@ -1990,6 +2033,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cls,
|
||||
local_path: Path,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[MetaMusic | MusicInfo]:
|
||||
"""在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。"""
|
||||
if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM:
|
||||
@@ -2002,7 +2046,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if mediainfo:
|
||||
return None
|
||||
|
||||
_, recognized = cls.recognize_music_by_path(local_path, source="musicbrainz")
|
||||
_, recognized = cls.recognize_music_by_path(local_path, source=source)
|
||||
return recognized
|
||||
|
||||
def _write_music_metadata(
|
||||
@@ -2015,9 +2059,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
cover_overwrite: bool,
|
||||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||||
scrape_info: Optional[MetaMusic | MusicInfo] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""解析单个本地音轨并按独立策略写入标签和封面。"""
|
||||
scrape_info = scrape_info or self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
scrape_info = scrape_info or self._resolve_music_scrape_info(
|
||||
local_path,
|
||||
mediainfo,
|
||||
source=source,
|
||||
)
|
||||
if not scrape_info or not scrape_info.title:
|
||||
logger.warning(f"无法识别音乐信息:{local_path}")
|
||||
return False
|
||||
@@ -2492,6 +2541,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
根据主副标题识别媒体信息(异步版本)
|
||||
@@ -2500,10 +2550,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
mediainfo = await self._async_recognize_with_fallback_by_meta(
|
||||
metainfo=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
episode_group=episode_group,
|
||||
obtain_images=obtain_images,
|
||||
@@ -2515,6 +2567,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
async def _async_recognize_with_fallback_by_meta(
|
||||
self,
|
||||
metainfo: MetaBase,
|
||||
mtype: Optional[MediaType] = None,
|
||||
source: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
obtain_images: bool = False,
|
||||
@@ -2523,6 +2576,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
异步根据标题识别媒体信息,必要时回退到辅助识别。
|
||||
|
||||
:param metainfo: 标题解析元数据
|
||||
:param mtype: 上游已确定的媒体类型
|
||||
:param source: 请求级识别数据源
|
||||
:param episode_group: 剧集组
|
||||
:param obtain_images: 是否补充图片
|
||||
@@ -2533,7 +2587,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
title = metainfo.title
|
||||
share_meta = deepcopy(metainfo)
|
||||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||||
is_music = isinstance(metainfo, MetaMusic)
|
||||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||||
is_recognized = (
|
||||
(lambda result: bool(result and result.source)) if is_music else None
|
||||
)
|
||||
@@ -2542,6 +2596,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""异步使用请求级数据源执行原生识别。"""
|
||||
return await self.async_recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
share_meta=share_meta,
|
||||
episode_group=episode_group,
|
||||
@@ -2549,6 +2604,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
async def plugin_recognize() -> Optional[MediaInfo]:
|
||||
"""异步执行辅助识别并保持请求级数据源约束。"""
|
||||
if is_music and not isinstance(metainfo, MetaMusic):
|
||||
return None
|
||||
return await self.async_recognize_help(
|
||||
title=title,
|
||||
org_meta=metainfo,
|
||||
@@ -2727,7 +2784,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||||
if self._is_music_path_request(path, source):
|
||||
music_meta, music_info = await self.async_recognize_music_by_path(
|
||||
path, source=source or "musicbrainz"
|
||||
path, source=source
|
||||
)
|
||||
return Context(meta_info=music_meta, media_info=music_info)
|
||||
file_path = Path(path)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.cache import async_fresh, fresh
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
@@ -17,6 +20,13 @@ from app.core.context import (
|
||||
from app.core.meta import MetaMusic
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.media import (
|
||||
MUSIC_MEDIA_SOURCE_ORDER,
|
||||
is_music_media_source,
|
||||
normalize_media_source,
|
||||
)
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
class MusicChain(ChainBase):
|
||||
@@ -27,6 +37,9 @@ class MusicChain(ChainBase):
|
||||
_album_dir_cache_max = 128
|
||||
# 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理
|
||||
_album_match_min_files = 2
|
||||
# 自动识别会比较全部来源;该顺序仅用于同分时的确定性选择。
|
||||
_recognize_source_order = MUSIC_MEDIA_SOURCE_ORDER
|
||||
_recognize_min_score = 45.0
|
||||
|
||||
@classmethod
|
||||
def parse_query(cls, query: str) -> MetaMusic:
|
||||
@@ -110,16 +123,112 @@ class MusicChain(ChainBase):
|
||||
break
|
||||
return results
|
||||
|
||||
def search(self, query: str, limit: int = 20) -> list[MusicInfo]:
|
||||
"""调用已启用的音乐元数据模块搜索候选。"""
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按请求来源调用音乐元数据模块搜索候选,未指定时默认使用 MusicBrainz。"""
|
||||
meta = self.parse_query(query)
|
||||
candidates = self.run_module("search_music", meta=meta, limit=limit)
|
||||
candidates = self.run_module(
|
||||
"search_music",
|
||||
meta=meta,
|
||||
limit=limit,
|
||||
source=source or "musicbrainz",
|
||||
)
|
||||
return self.normalize_candidates(candidates, limit=limit)
|
||||
|
||||
async def async_search(self, query: str, limit: int = 20) -> list[MusicInfo]:
|
||||
"""异步调用已启用的音乐元数据模块搜索候选。"""
|
||||
def recognize_best(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""依次查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
|
||||
candidates: list[MusicInfo] = []
|
||||
offline_fallback: Optional[MusicInfo] = None
|
||||
with fresh(not cache):
|
||||
for source in self._recognize_source_order:
|
||||
result = self._recognize_from_source(meta, source, cache)
|
||||
candidate = self._normalize_recognize_result(result, source)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
elif isinstance(result, MusicInfo) and not result.source:
|
||||
# MusicBrainz 会返回无远端身份的离线结果,全部来源失败时仍需保留。
|
||||
offline_fallback = offline_fallback or result
|
||||
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
|
||||
|
||||
async def async_recognize_best(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""并发查询全部内置音乐源,统一评分后返回最可信的自动识别结果。"""
|
||||
async with async_fresh(not cache):
|
||||
results = await asyncio.gather(*(
|
||||
self._async_recognize_from_source(meta, source, cache)
|
||||
for source in self._recognize_source_order
|
||||
))
|
||||
candidates: list[MusicInfo] = []
|
||||
offline_fallback: Optional[MusicInfo] = None
|
||||
for source, result in zip(self._recognize_source_order, results):
|
||||
candidate = self._normalize_recognize_result(result, source)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
elif isinstance(result, MusicInfo) and not result.source:
|
||||
offline_fallback = offline_fallback or result
|
||||
return self._select_best_recognize_candidate(meta, candidates) or offline_fallback
|
||||
|
||||
def recognize_from_source(
|
||||
self,
|
||||
source: str,
|
||||
meta: Optional[MetaMusic] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
|
||||
normalized_source = normalize_media_source(source)
|
||||
if not is_music_media_source(normalized_source):
|
||||
return None
|
||||
return self._recognize_from_source(
|
||||
meta=meta,
|
||||
source=normalized_source,
|
||||
cache=cache,
|
||||
mediaid=mediaid,
|
||||
)
|
||||
|
||||
async def async_recognize_from_source(
|
||||
self,
|
||||
source: str,
|
||||
meta: Optional[MetaMusic] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步只调用指定音乐数据源进行识别,拒绝影视或未知来源。"""
|
||||
normalized_source = normalize_media_source(source)
|
||||
if not is_music_media_source(normalized_source):
|
||||
return None
|
||||
return await self._async_recognize_from_source(
|
||||
meta=meta,
|
||||
source=normalized_source,
|
||||
cache=cache,
|
||||
mediaid=mediaid,
|
||||
)
|
||||
|
||||
async def async_search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""异步按请求来源搜索音乐候选,未指定时默认使用 MusicBrainz。"""
|
||||
meta = self.parse_query(query)
|
||||
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
|
||||
candidates = await self.async_run_module(
|
||||
"search_music",
|
||||
meta=meta,
|
||||
limit=limit,
|
||||
source=source or "musicbrainz",
|
||||
)
|
||||
return self.normalize_candidates(candidates, limit=limit)
|
||||
|
||||
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
|
||||
@@ -305,6 +414,200 @@ class MusicChain(ChainBase):
|
||||
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
|
||||
return MetaMusic.compact_text(value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recognize_result(
|
||||
result: Any,
|
||||
source: str,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""标准化单个来源结果,并拒绝插件或模块返回的跨来源身份。"""
|
||||
if isinstance(result, dict):
|
||||
try:
|
||||
result = MusicInfo.from_dict(result)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(result, MusicInfo):
|
||||
return None
|
||||
if not result.source or not result.media_id or result.source != source:
|
||||
return None
|
||||
if result.music_type != MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
return result
|
||||
|
||||
def _recognize_from_source(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: str,
|
||||
cache: bool,
|
||||
mediaid: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。"""
|
||||
module = self._music_recognize_module(source)
|
||||
if not module:
|
||||
return None
|
||||
try:
|
||||
return module.recognize_media(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"{source} 音乐自动识别失败:{err}")
|
||||
return None
|
||||
|
||||
async def _async_recognize_from_source(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: str,
|
||||
cache: bool,
|
||||
mediaid: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步调用指定音乐来源模块,单个来源失败不影响其它候选。"""
|
||||
module = self._music_recognize_module(source)
|
||||
if not module:
|
||||
return None
|
||||
try:
|
||||
async_method = getattr(module, "async_recognize_media", None)
|
||||
if async_method:
|
||||
return await async_method(
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
return await run_in_threadpool(
|
||||
module.recognize_media,
|
||||
meta=meta,
|
||||
mtype=MediaType.MUSIC,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
cache=cache,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"{source} 音乐自动识别失败:{err}")
|
||||
return None
|
||||
|
||||
def _music_recognize_module(self, source: str) -> Optional[Any]:
|
||||
"""枚举运行中的系统模块并返回声明了指定音乐来源的实现。"""
|
||||
for module in self.modulemanager.get_running_modules("recognize_media"):
|
||||
get_music_source = getattr(module, "get_music_source", None)
|
||||
if get_music_source and get_music_source() == source:
|
||||
return module
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_best_recognize_candidate(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: Iterable[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按统一证据评分选择最佳音轨,同分时使用默认来源顺序。"""
|
||||
source_order = {
|
||||
source: index for index, source in enumerate(cls._recognize_source_order)
|
||||
}
|
||||
ranked: list[tuple[float, int, MusicInfo]] = []
|
||||
for candidate in candidates:
|
||||
score = cls._recognize_candidate_score(meta, candidate)
|
||||
if score is None or score < cls._recognize_min_score:
|
||||
continue
|
||||
logger.debug(
|
||||
f"音乐自动识别候选:{candidate.source} {candidate.title},评分 {score:.1f}"
|
||||
)
|
||||
ranked.append((
|
||||
score,
|
||||
-source_order.get(candidate.source or "", len(source_order)),
|
||||
candidate,
|
||||
))
|
||||
if not ranked:
|
||||
return None
|
||||
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
best_score, _, best = ranked[0]
|
||||
logger.info(
|
||||
f"音乐自动识别采用 {best.source}:{best.title},匹配评分 {best_score:.1f}"
|
||||
)
|
||||
return best
|
||||
|
||||
@classmethod
|
||||
def _recognize_candidate_score(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidate: MusicInfo,
|
||||
) -> Optional[float]:
|
||||
"""综合曲名、艺术家、专辑、ISRC、时长、曲序和年份计算匹配分。"""
|
||||
if candidate.music_type != MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
isrc_match = bool(
|
||||
meta.isrc
|
||||
and candidate.isrc
|
||||
and cls._match_similarity(meta.isrc, candidate.isrc) == 1.0
|
||||
)
|
||||
if meta.isrc and candidate.isrc and not isrc_match:
|
||||
return None
|
||||
|
||||
title_similarity = cls._match_similarity(meta.title, candidate.title)
|
||||
if not isrc_match and (not meta.title or title_similarity < 0.7):
|
||||
return None
|
||||
score = title_similarity * 50
|
||||
|
||||
expected_artists = cls._unique_texts([
|
||||
*(meta.artists or []),
|
||||
meta.album_artist,
|
||||
])
|
||||
candidate_artists = cls._unique_texts([
|
||||
*(candidate.artists or []),
|
||||
candidate.album_artist,
|
||||
])
|
||||
if expected_artists:
|
||||
artist_similarity = max(
|
||||
(
|
||||
cls._match_similarity(expected, actual)
|
||||
for expected in expected_artists
|
||||
for actual in candidate_artists
|
||||
),
|
||||
default=0.0,
|
||||
)
|
||||
if not isrc_match and artist_similarity < 0.6:
|
||||
return None
|
||||
score += artist_similarity * 25
|
||||
|
||||
if meta.album and candidate.album:
|
||||
score += cls._match_similarity(meta.album, candidate.album) * 12
|
||||
if meta.duration and candidate.duration:
|
||||
duration_delta = abs(meta.duration - candidate.duration) / max(
|
||||
meta.duration, candidate.duration
|
||||
)
|
||||
if duration_delta <= 0.02:
|
||||
score += 8
|
||||
elif duration_delta <= 0.05:
|
||||
score += 6
|
||||
elif duration_delta <= 0.1:
|
||||
score += 3
|
||||
elif duration_delta > 0.2:
|
||||
score -= 8
|
||||
if meta.track_number and candidate.track_number:
|
||||
score += 3 if meta.track_number == candidate.track_number else -1
|
||||
if meta.year and candidate.year:
|
||||
year_delta = abs(int(meta.year) - int(candidate.year))
|
||||
score += 2 if year_delta == 0 else 1 if year_delta == 1 else 0
|
||||
if isrc_match:
|
||||
score += 50
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _match_similarity(left: Optional[str], right: Optional[str]) -> float:
|
||||
"""忽略繁简、大小写和标点后计算两段音乐文本的相似度。"""
|
||||
normalized_left = MetaMusic.compact_text(
|
||||
zhconv_convert(str(left or ""), "zh-hans")
|
||||
)
|
||||
normalized_right = MetaMusic.compact_text(
|
||||
zhconv_convert(str(right or ""), "zh-hans")
|
||||
)
|
||||
if not normalized_left or not normalized_right:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, normalized_left, normalized_right).ratio()
|
||||
|
||||
def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
|
||||
"""按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。
|
||||
|
||||
@@ -364,11 +667,10 @@ class MusicChain(ChainBase):
|
||||
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
|
||||
file_path = Path(path)
|
||||
if file_path.exists() and file_path.is_file():
|
||||
meta = AudioMetadataHelper.read(file_path)
|
||||
else:
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
return AudioMetadataHelper.read(file_path)
|
||||
meta = MetaMusic(
|
||||
org_string=file_path.stem, title=file_path.stem, parse_title=True
|
||||
)
|
||||
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
|
||||
return meta.apply_path_context(file_path)
|
||||
|
||||
|
||||
@@ -525,9 +525,10 @@ class JobManager:
|
||||
self._job_view.pop(mediaid)
|
||||
# 移除季集信息
|
||||
if mediaid in self._season_episodes:
|
||||
episodes = getattr(task.meta, "episode_list", None) or []
|
||||
self._season_episodes[mediaid] = list(
|
||||
set(self._season_episodes[mediaid])
|
||||
- set(task.meta.episode_list)
|
||||
- set(episodes)
|
||||
)
|
||||
return task, mediaid
|
||||
return None, None
|
||||
@@ -2022,7 +2023,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
):
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: Optional[MediaInfo] = self.recognize_media(
|
||||
mtype=MediaType(download_history.type),
|
||||
mtype=task.mtype or MediaType(download_history.type),
|
||||
tmdbid=download_history.tmdbid,
|
||||
doubanid=download_history.doubanid,
|
||||
bangumiid=download_history.bangumiid,
|
||||
@@ -2045,6 +2046,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
recognize_kwargs = {"obtain_images": True}
|
||||
if task.media_source:
|
||||
recognize_kwargs["source"] = task.media_source
|
||||
if task.mtype:
|
||||
recognize_kwargs["mtype"] = task.mtype
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
task.meta, **recognize_kwargs
|
||||
)
|
||||
@@ -2055,10 +2058,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
recognize_kwargs = {"obtain_images": True}
|
||||
if task.media_source:
|
||||
recognize_kwargs["source"] = task.media_source
|
||||
if task.mtype:
|
||||
recognize_kwargs["mtype"] = task.mtype
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
task.meta, **recognize_kwargs
|
||||
)
|
||||
|
||||
# 音乐必须先经过音乐元数据模块识别;远端不可用时再保留本地标签结果,
|
||||
# 避免因离线兜底提前赋值而跳过音乐识别链。
|
||||
if not mediainfo and isinstance(task.meta, MetaMusic):
|
||||
mediainfo = self._music_info_from_meta(task.meta)
|
||||
|
||||
# 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。
|
||||
if mediainfo and need_obtain_images:
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
@@ -3745,8 +3755,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
file_meta, task_mediainfo = self._match_music_album_context(
|
||||
file_item, file_path, file_meta
|
||||
)
|
||||
if not task_mediainfo:
|
||||
task_mediainfo = self._music_info_from_meta(file_meta)
|
||||
if (
|
||||
not manual
|
||||
and self._is_movie_year_conflict(file_meta, task_mediainfo)
|
||||
@@ -3759,6 +3767,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
meta=file_meta,
|
||||
mediainfo=task_mediainfo,
|
||||
media_source=media_source,
|
||||
mtype=mtype,
|
||||
target_directory=target_directory,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
|
||||
@@ -44,6 +44,8 @@ class SystemConfModel(BaseModel):
|
||||
fanart: int = 0
|
||||
# MusicBrainz请求缓存数量
|
||||
musicbrainz: int = 0
|
||||
# TheAudioDB请求缓存数量
|
||||
theaudiodb: int = 0
|
||||
# ListenBrainz请求缓存数量
|
||||
listenbrainz: int = 0
|
||||
# 元数据缓存过期时间(秒)
|
||||
@@ -205,11 +207,11 @@ class ConfigModel(BaseModel):
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
|
||||
# ==================== 媒体元数据配置 ====================
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb"
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
@@ -229,6 +231,8 @@ class ConfigModel(BaseModel):
|
||||
# ==================== 音乐配置 ====================
|
||||
# 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)
|
||||
MUSIC_COVER_PROXY: str = ""
|
||||
# TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖
|
||||
THEAUDIODB_API_KEY: str = "123"
|
||||
|
||||
# ==================== TVDB配置 ====================
|
||||
# TVDB API Key
|
||||
@@ -527,6 +531,7 @@ class ConfigModel(BaseModel):
|
||||
"anilist.co",
|
||||
"coverartarchive.org",
|
||||
"archive.org",
|
||||
"theaudiodb.com",
|
||||
"commons.wikimedia.org",
|
||||
"upload.wikimedia.org",
|
||||
]
|
||||
@@ -999,6 +1004,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
bangumi=512,
|
||||
fanart=512,
|
||||
musicbrainz=512,
|
||||
theaudiodb=512,
|
||||
listenbrainz=256,
|
||||
meta=(self.META_CACHE_EXPIRE or 72) * 3600,
|
||||
scheduler=100,
|
||||
@@ -1012,6 +1018,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
bangumi=256,
|
||||
fanart=128,
|
||||
musicbrainz=256,
|
||||
theaudiodb=256,
|
||||
listenbrainz=128,
|
||||
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
scheduler=50,
|
||||
|
||||
@@ -212,9 +212,14 @@ class MusicInfo:
|
||||
return None
|
||||
|
||||
@property
|
||||
def douban_id(self) -> None:
|
||||
"""音乐不使用豆瓣 ID,兼容现有下载历史字段。"""
|
||||
return None
|
||||
def douban_id(self) -> str | None:
|
||||
"""豆瓣音乐来源返回原生条目 ID,其它音乐源保持兼容空值。"""
|
||||
return self.media_id if self.source == "doubanmusic" else None
|
||||
|
||||
@property
|
||||
def theaudiodb_id(self) -> str | None:
|
||||
"""TheAudioDB 来源返回原生条目 ID,供通用身份解析复用。"""
|
||||
return self.media_id if self.source == "theaudiodb" else None
|
||||
|
||||
@property
|
||||
def bangumi_id(self) -> None:
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
from .metabase import MetaBase
|
||||
from .metavideo import MetaVideo
|
||||
from .metaanime import MetaAnime
|
||||
from .metamusic import MetaMusic
|
||||
from .metamusic import (
|
||||
MetaMusic,
|
||||
MusicNameContext,
|
||||
MusicNameParseResult,
|
||||
MusicNameParser,
|
||||
MusicNamePattern,
|
||||
MusicNamePatternMatch,
|
||||
MusicNameRegistry,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,19 +16,22 @@ class AudioMetadataHelper:
|
||||
|
||||
@classmethod
|
||||
def read(cls, path: Path) -> MetaMusic:
|
||||
"""读取本地音频文件标签;读取失败时返回基于文件名的最小元数据。"""
|
||||
fallback = MetaMusic(
|
||||
org_string=path.name,
|
||||
title=path.stem,
|
||||
audio_format=path.suffix.lstrip(".").upper() or None,
|
||||
)
|
||||
"""读取本地音频标签,并以完整文件名模式和目录线索补充缺失字段。"""
|
||||
def filename_fallback() -> MetaMusic:
|
||||
"""构造无标签结果,完整文件名解析只在确有需要时执行。"""
|
||||
return MetaMusic(
|
||||
org_string=path.name,
|
||||
title=path.stem,
|
||||
audio_format=path.suffix.lstrip(".").upper() or None,
|
||||
).apply_path_context(path)
|
||||
|
||||
try:
|
||||
audio = MutagenFile(path, easy=True)
|
||||
except Exception as err:
|
||||
logger.warning(f"读取音频标签失败:{path} - {err}")
|
||||
return fallback
|
||||
return filename_fallback()
|
||||
if not audio:
|
||||
return fallback
|
||||
return filename_fallback()
|
||||
|
||||
tags = audio.tags or {}
|
||||
track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber"))
|
||||
@@ -52,7 +55,7 @@ class AudioMetadataHelper:
|
||||
bitrate=cls._optional_int(getattr(info, "bitrate", None)),
|
||||
duration=round(info.length) if info and getattr(info, "length", None) else None,
|
||||
isrc=cls._first(tags, "isrc"),
|
||||
)
|
||||
).apply_path_context(path)
|
||||
|
||||
@classmethod
|
||||
def write(
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID",
|
||||
"媒体ID格式无效": "Invalid media ID format",
|
||||
"MusicBrainz 只能用于音乐刮削": "MusicBrainz can only be used for music scraping",
|
||||
"音乐元数据源只能用于音乐刮削": "Music metadata sources can only be used for music scraping",
|
||||
"刮削失败,无法识别音乐信息": "Scraping failed: unable to recognize music information",
|
||||
"刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information",
|
||||
"刮削路径不存在": "Scraping path does not exist",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
|
||||
"媒体ID格式无效": "媒體ID格式無效",
|
||||
"MusicBrainz 只能用于音乐刮削": "MusicBrainz 只能用於音樂刮削",
|
||||
"音乐元数据源只能用于音乐刮削": "音樂中繼資料來源只能用於音樂刮削",
|
||||
"刮削失败,无法识别音乐信息": "刮削失敗,無法識別音樂資訊",
|
||||
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
|
||||
"刮削路径不存在": "刮削路徑不存在",
|
||||
|
||||
@@ -229,6 +229,12 @@ class AniListModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# AniList 只处理动画影视,不能在音乐模块未响应时接管音乐请求。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
|
||||
@@ -258,6 +264,12 @@ class AniListModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 AniList。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
return None
|
||||
info = (
|
||||
|
||||
@@ -93,6 +93,12 @@ class BangumiModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# Bangumi 只处理影视,不能在音乐模块未响应时接管音乐请求。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
@@ -129,6 +135,12 @@ class BangumiModule(_ModuleBase):
|
||||
:param source: 请求级识别数据源
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 Bangumi。
|
||||
if (
|
||||
kwargs.get("mtype") == MediaType.MUSIC
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import re
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import cn2an
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicAlbumInfo, MusicInfo
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
@@ -17,11 +17,14 @@ from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.limit import rate_limit_exponential
|
||||
from app.utils.media import is_media_source_enabled
|
||||
from app.utils.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
|
||||
|
||||
_music_source = "doubanmusic"
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -48,6 +51,11 @@ class DoubanModule(_ModuleBase):
|
||||
def get_name() -> str:
|
||||
return "豆瓣"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return DoubanModule._music_source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
@@ -69,6 +77,424 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
return 2
|
||||
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[List[MusicInfo]]:
|
||||
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
|
||||
if not is_media_source_selected(source, self._music_source):
|
||||
return None
|
||||
keyword = meta.album or meta.title
|
||||
if not keyword:
|
||||
return []
|
||||
result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100)))
|
||||
return self._build_music_search_results(result)
|
||||
|
||||
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
|
||||
"""按豆瓣音乐原生 ID 获取专辑或专辑内曲目详情。"""
|
||||
if source != self._music_source or not media_id:
|
||||
return None
|
||||
album_id, separator, track_id = str(media_id).partition(":")
|
||||
album = self.music_album(source, album_id)
|
||||
if not album:
|
||||
return None
|
||||
if separator and track_id:
|
||||
return next(
|
||||
(
|
||||
track for track in album.tracks
|
||||
if track.media_id == media_id or str(track.track_number or "") == track_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return album.to_music_info()
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._music_source or not media_id:
|
||||
return None
|
||||
info = self.doubanapi.music_detail(subject_id=str(media_id))
|
||||
return self._douban_music_to_album(info) if info else None
|
||||
|
||||
def _recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
return self.recognize_music(source, str(resolved_media_id))
|
||||
if not meta:
|
||||
return None
|
||||
candidates = self.search_music(meta=meta, limit=20, source=source) or []
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not self._same_music_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and candidate.artists and not any(
|
||||
self._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
if meta.album and meta.title:
|
||||
album = self.music_album(source, candidate.media_id)
|
||||
matched_track = self._select_douban_music_track(meta, album)
|
||||
if matched_track:
|
||||
return matched_track
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
async def _async_recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
album_id, separator, track_id = str(resolved_media_id).partition(":")
|
||||
info = await self.doubanapi.async_music_detail(subject_id=album_id)
|
||||
album = self._douban_music_to_album(info) if info else None
|
||||
if not album:
|
||||
return None
|
||||
if separator and track_id:
|
||||
return next(
|
||||
(
|
||||
track for track in album.tracks
|
||||
if track.media_id == resolved_media_id
|
||||
or str(track.track_number or "") == track_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return album.to_music_info()
|
||||
if not meta:
|
||||
return None
|
||||
keyword = meta.album or meta.title
|
||||
if not keyword:
|
||||
return None
|
||||
result = await self.doubanapi.async_music_search(keyword=keyword, count=20)
|
||||
candidates = self._build_music_search_results(result)
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not self._same_music_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and candidate.artists and not any(
|
||||
self._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
if meta.album and meta.title:
|
||||
info = await self.doubanapi.async_music_detail(
|
||||
subject_id=str(candidate.media_id)
|
||||
)
|
||||
album = self._douban_music_to_album(info) if info else None
|
||||
matched_track = self._select_douban_music_track(meta, album)
|
||||
if matched_track:
|
||||
return matched_track
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_douban_music_track(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
album: Optional[MusicAlbumInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""从豆瓣专辑曲目中选择与本地曲名、艺术家及曲序最一致的音轨。"""
|
||||
if not album:
|
||||
return None
|
||||
candidates = [
|
||||
track for track in album.tracks
|
||||
if cls._same_music_text(meta.title, track.title)
|
||||
]
|
||||
if meta.artists:
|
||||
candidates = [
|
||||
track for track in candidates
|
||||
if any(
|
||||
cls._same_music_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in track.artists
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(
|
||||
key=lambda track: (
|
||||
bool(meta.track_number and track.track_number == meta.track_number),
|
||||
-abs((meta.duration or track.duration or 0) - (track.duration or meta.duration or 0)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
@classmethod
|
||||
def _build_music_search_results(cls, result: Optional[dict]) -> List[MusicInfo]:
|
||||
"""把豆瓣音乐搜索响应转换为专辑候选列表。"""
|
||||
items = (result or {}).get("items") or (result or {}).get("musics") or []
|
||||
candidates = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target_type = str(item.get("target_type") or item.get("type") or "").casefold()
|
||||
target = item.get("target") if isinstance(item.get("target"), dict) else item
|
||||
type_name = str(target.get("type_name") or target.get("subtype") or "")
|
||||
if target_type and target_type not in {"music", "音乐"}:
|
||||
continue
|
||||
if type_name and type_name not in {"音乐", "music"}:
|
||||
continue
|
||||
media_id = cls._douban_music_text(
|
||||
target.get("id") or item.get("target_id") or item.get("id")
|
||||
)
|
||||
title = cls._douban_music_text(target.get("title") or target.get("name"))
|
||||
if not media_id or not title:
|
||||
continue
|
||||
artists = cls._douban_music_artists(target)
|
||||
release_date = cls._douban_music_date(target)
|
||||
cover_url = cls._douban_music_cover(target)
|
||||
candidate = MusicInfo(
|
||||
source=cls._music_source,
|
||||
media_id=media_id,
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title=title,
|
||||
artists=artists,
|
||||
album=title,
|
||||
album_artist=" / ".join(artists) or None,
|
||||
album_id=media_id,
|
||||
year=cls._douban_music_year(target.get("year") or release_date),
|
||||
release_date=release_date,
|
||||
cover_url=cover_url,
|
||||
names=[title],
|
||||
detail_link=f"https://music.douban.com/subject/{media_id}/",
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
@classmethod
|
||||
def _douban_music_to_album(cls, info: dict[str, Any]) -> Optional[MusicAlbumInfo]:
|
||||
"""把豆瓣音乐详情转换为标准专辑信息和曲目。"""
|
||||
media_id = cls._douban_music_text(info.get("id") or info.get("subject_id"))
|
||||
title = cls._douban_music_text(info.get("title") or info.get("name"))
|
||||
if not media_id or not title:
|
||||
return None
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
artists = cls._douban_music_artists(info)
|
||||
release_date = cls._douban_music_date(info)
|
||||
tags = [
|
||||
cls._douban_music_text(item.get("name") if isinstance(item, dict) else item)
|
||||
for item in (info.get("tags") or [])
|
||||
]
|
||||
genres = [str(item) for item in info.get("genres") or [] if item]
|
||||
rating = info.get("rating") if isinstance(info.get("rating"), dict) else {}
|
||||
album = MusicAlbumInfo(
|
||||
source=cls._music_source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=artists,
|
||||
album_type=cls._douban_music_first(
|
||||
info.get("media") or attrs.get("media")
|
||||
) or "Album",
|
||||
release_date=release_date,
|
||||
cover_url=cls._douban_music_cover(info),
|
||||
genres=genres,
|
||||
tags=[item for item in tags if item],
|
||||
rating=cls._douban_music_float(rating.get("value") or rating.get("average")),
|
||||
rating_votes=cls._douban_music_int(
|
||||
rating.get("count") or rating.get("numRaters") or info.get("ratings_count")
|
||||
),
|
||||
detail_link=f"https://music.douban.com/subject/{media_id}/",
|
||||
raw_data={
|
||||
"overview": cls._douban_music_text(info.get("intro") or info.get("summary")),
|
||||
"publisher": cls._douban_music_first(
|
||||
info.get("publisher") or attrs.get("publisher")
|
||||
),
|
||||
},
|
||||
)
|
||||
album.tracks = cls._douban_music_tracks(info, album)
|
||||
return album
|
||||
|
||||
@classmethod
|
||||
def _douban_music_tracks(
|
||||
cls,
|
||||
info: dict[str, Any],
|
||||
album: MusicAlbumInfo,
|
||||
) -> List[MusicInfo]:
|
||||
"""从豆瓣新旧响应结构中提取专辑曲目。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
# Frodo 当前音乐详情使用 songs;tracks/attrs.tracks 兼容旧接口响应。
|
||||
tracks = info.get("songs") or info.get("tracks") or attrs.get("tracks") or []
|
||||
if isinstance(tracks, str):
|
||||
tracks = tracks.splitlines()
|
||||
elif not isinstance(tracks, list):
|
||||
tracks = []
|
||||
elif len(tracks) == 1 and isinstance(tracks[0], str) and "\n" in tracks[0]:
|
||||
tracks = tracks[0].splitlines()
|
||||
results = []
|
||||
for index, item in enumerate(tracks, start=1):
|
||||
if isinstance(item, dict):
|
||||
title = cls._douban_music_text(item.get("title") or item.get("name"))
|
||||
track_number = cls._douban_music_int(item.get("track_number") or item.get("position")) or index
|
||||
duration = cls._douban_music_int(item.get("duration"))
|
||||
duration = duration if duration and duration > 0 else None
|
||||
disc_number = cls._douban_music_int(
|
||||
item.get("disc_number") or item.get("disc")
|
||||
)
|
||||
artists = cls._douban_music_artists(item) or list(album.artists)
|
||||
cover_url = cls._douban_music_text(item.get("cover_url")) or album.cover_url
|
||||
raw_data = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"apple_album_id": item.get("apple_album_id"),
|
||||
"apple_track_id": item.get("apple_track_id"),
|
||||
"preview_url": item.get("preview_url"),
|
||||
}.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
else:
|
||||
title = cls._clean_douban_track_title(item)
|
||||
track_number = index
|
||||
duration = None
|
||||
disc_number = None
|
||||
artists = list(album.artists)
|
||||
cover_url = album.cover_url
|
||||
raw_data = {}
|
||||
if not title:
|
||||
continue
|
||||
results.append(MusicInfo(
|
||||
source=cls._music_source,
|
||||
# 豆瓣歌曲没有独立 subject ID,使用专辑内绝对顺序避免多碟曲序重复。
|
||||
media_id=f"{album.media_id}:{index}",
|
||||
title=title,
|
||||
artists=artists,
|
||||
album=album.title,
|
||||
album_artist=album.artist or None,
|
||||
album_id=album.media_id,
|
||||
album_type=album.album_type,
|
||||
year=album.year,
|
||||
release_date=album.release_date,
|
||||
disc_number=disc_number,
|
||||
track_number=track_number,
|
||||
duration=duration,
|
||||
cover_url=cover_url,
|
||||
genres=list(album.genres),
|
||||
names=[title],
|
||||
detail_link=album.detail_link,
|
||||
raw_data=raw_data,
|
||||
))
|
||||
for track in results:
|
||||
track.total_tracks = len(results)
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _douban_music_artists(cls, info: dict[str, Any]) -> List[str]:
|
||||
"""从豆瓣新旧响应结构中提取艺术家名称。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
values = (
|
||||
info.get("artists")
|
||||
or info.get("artist_names")
|
||||
or info.get("author")
|
||||
or info.get("singer")
|
||||
or attrs.get("singer")
|
||||
or []
|
||||
)
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
artists = []
|
||||
seen = set()
|
||||
for item in values:
|
||||
value = item.get("name") if isinstance(item, dict) else item
|
||||
text = cls._douban_music_text(value)
|
||||
identity = MetaMusic.compact_text(text) if text else ""
|
||||
if not text or identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
artists.append(text)
|
||||
return artists
|
||||
|
||||
@classmethod
|
||||
def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]:
|
||||
"""从豆瓣多种图片字段中提取清晰封面。"""
|
||||
pic = info.get("pic") if isinstance(info.get("pic"), dict) else {}
|
||||
cover = info.get("cover") if isinstance(info.get("cover"), dict) else {}
|
||||
cover_img = info.get("cover_img") if isinstance(info.get("cover_img"), dict) else {}
|
||||
return next(
|
||||
(
|
||||
text for value in [
|
||||
pic.get("large"),
|
||||
cover_img.get("url"),
|
||||
cover.get("large"),
|
||||
cover.get("normal"),
|
||||
info.get("cover_url"),
|
||||
info.get("image"),
|
||||
]
|
||||
if (text := cls._douban_music_text(value))
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _douban_music_date(cls, info: dict[str, Any]) -> Optional[str]:
|
||||
"""从豆瓣新旧响应结构中提取首个发行日期。"""
|
||||
attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {}
|
||||
return cls._douban_music_first(info.get("pubdate") or attrs.get("pubdate"))
|
||||
|
||||
@staticmethod
|
||||
def _clean_douban_track_title(value: Any) -> Optional[str]:
|
||||
"""清理豆瓣旧接口曲目文本开头的序号。"""
|
||||
text = str(value or "").strip()
|
||||
return re.sub(r"^\s*(?:\d+[\.、)]\s*)", "", text) or None
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_text(value: Any) -> Optional[str]:
|
||||
"""把豆瓣外部响应值转换为去空白文本。"""
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or None
|
||||
|
||||
@classmethod
|
||||
def _douban_music_first(cls, value: Any) -> Optional[str]:
|
||||
"""从豆瓣列表或标量字段中提取首个文本。"""
|
||||
if isinstance(value, list):
|
||||
return next((text for item in value if (text := cls._douban_music_text(item))), None)
|
||||
return cls._douban_music_text(value)
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_int(value: Any) -> Optional[int]:
|
||||
"""将豆瓣外部响应值安全转换为整数。"""
|
||||
try:
|
||||
return int(value) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _douban_music_float(value: Any) -> float:
|
||||
"""将豆瓣外部评分安全转换为浮点数。"""
|
||||
try:
|
||||
return float(value) if value not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _douban_music_year(cls, value: Any) -> Optional[int]:
|
||||
"""从豆瓣年份或日期文本中提取四位年份。"""
|
||||
text = cls._douban_music_text(value)
|
||||
return int(text[:4]) if text and text[:4].isdigit() else None
|
||||
|
||||
@staticmethod
|
||||
def _same_music_text(left: Optional[str], right: Optional[str]) -> bool:
|
||||
"""使用音乐元数据紧凑文本规则比较豆瓣候选。"""
|
||||
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
|
||||
|
||||
@staticmethod
|
||||
def _prepare_search_names(meta: MetaBase) -> List[str]:
|
||||
"""
|
||||
@@ -258,6 +684,16 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
return self._recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
return self._recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
@@ -278,6 +714,16 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
return await self._async_recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
return await self._async_recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
|
||||
@@ -370,6 +370,31 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
return await self.__async_invoke_search(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def music_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""搜索豆瓣音乐条目。"""
|
||||
return self.__invoke_search(
|
||||
self._urls["search_subject"],
|
||||
type="music",
|
||||
q=keyword,
|
||||
start=start,
|
||||
count=count,
|
||||
_ts=ts,
|
||||
)
|
||||
|
||||
async def async_music_search(self, keyword: str, start: Optional[int] = 0,
|
||||
count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""异步搜索豆瓣音乐条目。"""
|
||||
return await self.__async_invoke_search(
|
||||
self._urls["search_subject"],
|
||||
type="music",
|
||||
q=keyword,
|
||||
start=start,
|
||||
count=count,
|
||||
_ts=ts,
|
||||
)
|
||||
|
||||
def group_search(self, keyword: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
@@ -590,6 +615,14 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
"""
|
||||
return await self.__async_invoke_search(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def music_detail(self, subject_id: str) -> dict:
|
||||
"""获取豆瓣音乐详情。"""
|
||||
return self.__invoke_search(self._urls["music_detail"] + subject_id)
|
||||
|
||||
async def async_music_detail(self, subject_id: str) -> dict:
|
||||
"""异步获取豆瓣音乐详情。"""
|
||||
return await self.__async_invoke_search(self._urls["music_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from requests import Session
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
@@ -22,6 +21,7 @@ from app.modules import _ModuleBase
|
||||
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_selected
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ class MusicBrainzModule(_ModuleBase):
|
||||
"""返回模块展示名称。"""
|
||||
return "MusicBrainz"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return MusicBrainzModule._source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的媒体识别类型。"""
|
||||
@@ -130,8 +135,15 @@ class MusicBrainzModule(_ModuleBase):
|
||||
"""音乐识别在所有 MediaRecognize 模块中最先响应,避免音乐请求被影视模块误识别。"""
|
||||
return 0
|
||||
|
||||
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
recordings = self._search_recordings(meta, limit=normalized_limit)
|
||||
albums = self._search_albums(meta, limit=normalized_limit)
|
||||
@@ -623,6 +635,9 @@ class MusicBrainzModule(_ModuleBase):
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
|
||||
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
|
||||
if source and source != self._source:
|
||||
return None
|
||||
# 非音乐请求交给影视识别模块,不占用识别管线
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
|
||||
return None
|
||||
|
||||
514
app/modules/theaudiodb/__init__.py
Normal file
514
app/modules/theaudiodb/__init__.py
Normal file
@@ -0,0 +1,514 @@
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.core.context import (
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
)
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_selected
|
||||
|
||||
|
||||
class TheAudioDbModule(_ModuleBase):
|
||||
"""通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。"""
|
||||
|
||||
_source = "theaudiodb"
|
||||
_base_url = "https://www.theaudiodb.com/api/v1/json"
|
||||
_detail_url = "https://www.theaudiodb.com"
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化无状态的 TheAudioDB 模块。"""
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""TheAudioDB 使用环境配置中的 API Key,无独立启用开关。"""
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块;当前实现没有需要释放的持久资源。"""
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 TheAudioDB 艺术家搜索接口连通性。"""
|
||||
result = self._request_json("search.php", {"s": "coldplay"})
|
||||
return (True, "") if result is not None else (False, "TheAudioDB 网络连接失败")
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回模块展示名称。"""
|
||||
return "TheAudioDB"
|
||||
|
||||
@staticmethod
|
||||
def get_music_source() -> str:
|
||||
"""返回多源音乐识别使用的数据源标识。"""
|
||||
return TheAudioDbModule._source
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的媒体识别类型。"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""返回 TheAudioDB 模块子类型。"""
|
||||
return MediaRecognizeType.TheAudioDB
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回音乐识别优先级,位于默认 MusicBrainz 之后。"""
|
||||
return 1
|
||||
|
||||
def search_music(
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
tracks = self._search_tracks(meta)
|
||||
albums = self._search_albums(meta)
|
||||
artists = self._search_artists(meta)
|
||||
return self._interleave_results(
|
||||
tracks,
|
||||
albums,
|
||||
artists,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
|
||||
if source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if mtype == MediaType.MUSIC and mediaid:
|
||||
return self.recognize_music(source, str(mediaid))
|
||||
return None
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
if resolved_media_id:
|
||||
return self.recognize_music(source, str(resolved_media_id))
|
||||
matched = self._select_track(meta, self._search_tracks(meta))
|
||||
if matched:
|
||||
return matched
|
||||
album = self._select_album(meta, self._search_albums(meta))
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""在线程池执行 TheAudioDB 同步识别,避免阻塞事件循环。"""
|
||||
return await run_in_threadpool(
|
||||
self.recognize_media,
|
||||
meta,
|
||||
mtype=mtype,
|
||||
source=source,
|
||||
mediaid=mediaid,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
|
||||
"""按 TheAudioDB 原生 ID 获取单曲详情,未命中时回退到专辑。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("track.php", {"h": media_id})
|
||||
track = self._first_entity(payload, "track", "tracks")
|
||||
if track:
|
||||
return self._track_to_info(track)
|
||||
album = self.music_album(source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("album.php", {"m": media_id})
|
||||
item = self._first_entity(payload, "album", "albums")
|
||||
if not item:
|
||||
return None
|
||||
album = self._album_to_info(item)
|
||||
tracks_payload = self._request_json("track.php", {"m": media_id})
|
||||
album.tracks = [
|
||||
info
|
||||
for track in self._entities(tracks_payload, "track", "tracks")
|
||||
if (info := self._track_to_info(track, album=album))
|
||||
]
|
||||
return album
|
||||
|
||||
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
|
||||
if source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("artist.php", {"i": media_id})
|
||||
item = self._first_entity(payload, "artists", "artist")
|
||||
return self._artist_to_info(item) if item else None
|
||||
|
||||
def music_artist_albums(
|
||||
self,
|
||||
source: str,
|
||||
media_id: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
album_type: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 分页返回专辑列表。"""
|
||||
if source != self._source or not media_id:
|
||||
return []
|
||||
payload = self._request_json("album.php", {"i": media_id})
|
||||
albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
|
||||
if album_type:
|
||||
normalized_type = album_type.casefold()
|
||||
albums = [
|
||||
album for album in albums
|
||||
if (album.album_type or "").casefold() == normalized_type
|
||||
]
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清除 TheAudioDB 请求缓存。"""
|
||||
self._request_json.cache_clear()
|
||||
|
||||
def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]:
|
||||
"""使用曲名和艺术家搜索 TheAudioDB 单曲。"""
|
||||
title = meta.title
|
||||
if not title:
|
||||
return []
|
||||
params = {"t": title}
|
||||
if meta.artists:
|
||||
params["s"] = meta.artists[0]
|
||||
payload = self._request_json("searchtrack.php", params)
|
||||
return [
|
||||
info
|
||||
for item in self._entities(payload, "track", "tracks")
|
||||
if (info := self._track_to_info(item))
|
||||
]
|
||||
|
||||
def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]:
|
||||
"""使用专辑名和艺术家搜索 TheAudioDB 专辑。"""
|
||||
album_name = meta.album or meta.title
|
||||
if not album_name:
|
||||
return []
|
||||
params = {"a": album_name}
|
||||
if meta.artists:
|
||||
params["s"] = meta.artists[0]
|
||||
payload = self._request_json("searchalbum.php", params)
|
||||
return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
|
||||
|
||||
def _search_artists(self, meta: MetaMusic) -> list[MusicArtistInfo]:
|
||||
"""使用艺术家线索搜索 TheAudioDB 艺术家。"""
|
||||
name = meta.artists[0] if meta.artists else meta.title
|
||||
if not name:
|
||||
return []
|
||||
payload = self._request_json("search.php", {"s": name})
|
||||
return [
|
||||
self._artist_to_info(item)
|
||||
for item in self._entities(payload, "artists", "artist")
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _select_track(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: list[MusicInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按曲名和可用艺术家线索选择可信单曲候选。"""
|
||||
for candidate in candidates:
|
||||
if not cls._same_text(meta.title, candidate.title):
|
||||
continue
|
||||
if meta.artists and not any(
|
||||
cls._same_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_album(
|
||||
cls,
|
||||
meta: MetaMusic,
|
||||
candidates: list[MusicAlbumInfo],
|
||||
) -> Optional[MusicAlbumInfo]:
|
||||
"""按专辑名和可用艺术家线索选择可信专辑候选。"""
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not cls._same_text(expected_title, candidate.title):
|
||||
continue
|
||||
if meta.artists and not any(
|
||||
cls._same_text(expected, actual)
|
||||
for expected in meta.artists
|
||||
for actual in candidate.artists
|
||||
):
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _track_to_info(
|
||||
cls,
|
||||
item: dict[str, Any],
|
||||
album: Optional[MusicAlbumInfo] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""将 TheAudioDB 单曲响应转换为标准音乐信息。"""
|
||||
media_id = cls._text(item.get("idTrack") or item.get("id"))
|
||||
title = cls._text(item.get("strTrack") or item.get("name"))
|
||||
if not media_id or not title:
|
||||
return None
|
||||
artist = cls._text(item.get("strArtist"))
|
||||
artist_id = cls._text(item.get("idArtist"))
|
||||
duration_ms = cls._optional_int(item.get("intDuration"))
|
||||
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else list(album.artists if album else []),
|
||||
artist_ids=[artist_id] if artist_id else list(album.artist_ids if album else []),
|
||||
album=cls._text(item.get("strAlbum")) or (album.title if album else None),
|
||||
album_artist=artist or (album.artist if album else None),
|
||||
album_id=cls._text(item.get("idAlbum")) or (album.media_id if album else None),
|
||||
year=album.year if album else None,
|
||||
release_date=album.release_date if album else None,
|
||||
disc_number=cls._optional_int(item.get("intCD")),
|
||||
track_number=cls._optional_int(item.get("intTrackNumber")),
|
||||
duration=duration_ms // 1000 if duration_ms else None,
|
||||
isrc=cls._text(item.get("strISRC")),
|
||||
cover_url=cls._first_text(
|
||||
item,
|
||||
"strTrackThumb",
|
||||
"strTrack3DCase",
|
||||
) or (album.cover_url if album else None),
|
||||
lyrics=cls._text(item.get("strTrackLyrics")),
|
||||
category=" / ".join(genres),
|
||||
genres=genres,
|
||||
names=cls._unique_texts([title, item.get("strTrackAlternate")]),
|
||||
detail_link=f"{cls._detail_url}/track/{media_id}",
|
||||
raw_data={
|
||||
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
|
||||
"musicbrainz_album_id": cls._text(item.get("strMusicBrainzAlbumID")),
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _album_to_info(cls, item: dict[str, Any]) -> MusicAlbumInfo:
|
||||
"""将 TheAudioDB 专辑响应转换为标准专辑信息。"""
|
||||
media_id = cls._text(item.get("idAlbum") or item.get("id"))
|
||||
title = cls._text(item.get("strAlbum") or item.get("name"))
|
||||
artist = cls._text(item.get("strArtist"))
|
||||
artist_id = cls._text(item.get("idArtist"))
|
||||
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
|
||||
release_date = cls._text(item.get("strReleaseDate"))
|
||||
if not release_date:
|
||||
release_date = cls._text(item.get("intYearReleased"))
|
||||
return MusicAlbumInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else [],
|
||||
artist_ids=[artist_id] if artist_id else [],
|
||||
album_type=cls._text(item.get("strReleaseFormat")),
|
||||
release_date=release_date,
|
||||
cover_url=cls._first_text(
|
||||
item,
|
||||
"strAlbumThumbHQ",
|
||||
"strAlbumThumb",
|
||||
"strAlbum3DCase",
|
||||
"strAlbumCDart",
|
||||
),
|
||||
genres=genres,
|
||||
tags=cls._unique_texts([item.get("strMood"), item.get("strStyle")]),
|
||||
rating=cls._optional_float(item.get("intScore")),
|
||||
rating_votes=cls._optional_int(item.get("intScoreVotes")),
|
||||
detail_link=f"{cls._detail_url}/album/{media_id}" if media_id else None,
|
||||
raw_data={"description": cls._localized_text(item, "strDescription")},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _artist_to_info(cls, item: dict[str, Any]) -> MusicArtistInfo:
|
||||
"""将 TheAudioDB 艺术家响应转换为标准艺术家信息。"""
|
||||
media_id = cls._text(item.get("idArtist") or item.get("id"))
|
||||
name = cls._text(item.get("strArtist") or item.get("name"))
|
||||
links = {}
|
||||
website = cls._text(item.get("strWebsite"))
|
||||
if website:
|
||||
links["official homepage"] = website
|
||||
return MusicArtistInfo(
|
||||
source=cls._source,
|
||||
media_id=media_id,
|
||||
name=name,
|
||||
disambiguation=cls._text(item.get("strArtistAlternate")),
|
||||
artist_type=cls._text(item.get("strStyle")),
|
||||
gender=cls._text(item.get("strGender")),
|
||||
country=cls._text(item.get("strCountry")),
|
||||
begin_date=cls._text(item.get("intFormedYear") or item.get("intBornYear")),
|
||||
end_date=cls._text(item.get("intDiedYear") or item.get("strDisbanded")),
|
||||
ended=bool(item.get("intDiedYear") or item.get("strDisbanded")),
|
||||
genres=cls._unique_texts([item.get("strGenre"), item.get("strStyle")]),
|
||||
aliases=cls._split_text(item.get("strArtistAlternate")),
|
||||
image_url=cls._first_text(
|
||||
item,
|
||||
"strArtistThumb",
|
||||
"strArtistFanart",
|
||||
"strArtistWideThumb",
|
||||
"strArtistCutout",
|
||||
),
|
||||
detail_link=f"{cls._detail_url}/artist/{media_id}" if media_id else None,
|
||||
external_links=links,
|
||||
raw_data={
|
||||
"musicbrainz_id": cls._text(item.get("strMusicBrainzID")),
|
||||
"biography": cls._localized_text(item, "strBiography"),
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interleave_results(
|
||||
tracks: list[MusicInfo],
|
||||
albums: list[MusicAlbumInfo],
|
||||
artists: list[MusicArtistInfo],
|
||||
limit: int,
|
||||
) -> list[MusicInfo]:
|
||||
"""交错合并三类搜索结果,避免单一实体占满候选列表。"""
|
||||
groups = [tracks, [item.to_music_info() for item in albums], [item.to_music_info() for item in artists]]
|
||||
results: list[MusicInfo] = []
|
||||
for index in range(max((len(group) for group in groups), default=0)):
|
||||
for group in groups:
|
||||
if index < len(group):
|
||||
results.append(group[index])
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=settings.CONF.theaudiodb, ttl=settings.CONF.meta, skip_none=True)
|
||||
def _request_json(
|
||||
cls,
|
||||
endpoint: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""请求 TheAudioDB V1 JSON 接口并统一处理错误响应。"""
|
||||
api_key = str(settings.THEAUDIODB_API_KEY or "").strip()
|
||||
if not api_key:
|
||||
logger.warning("TheAudioDB API Key 未配置,跳过请求")
|
||||
return None
|
||||
response = RequestUtils(
|
||||
ua=settings.USER_AGENT,
|
||||
proxies=settings.PROXY,
|
||||
timeout=30,
|
||||
).get_res(
|
||||
url=f"{cls._base_url}/{api_key}/{endpoint}",
|
||||
params=params or {},
|
||||
)
|
||||
if not response or response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as err:
|
||||
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _entities(
|
||||
payload: Optional[dict[str, Any]],
|
||||
*keys: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""从兼容 V1/V2 命名的响应字段中提取实体列表。"""
|
||||
if not payload:
|
||||
return []
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _first_entity(
|
||||
cls,
|
||||
payload: Optional[dict[str, Any]],
|
||||
*keys: str,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""返回响应中的首个实体。"""
|
||||
entities = cls._entities(payload, *keys)
|
||||
return entities[0] if entities else None
|
||||
|
||||
@staticmethod
|
||||
def _text(value: Any) -> Optional[str]:
|
||||
"""把外部响应值转换为去空白文本。"""
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or None
|
||||
|
||||
@classmethod
|
||||
def _first_text(cls, item: dict[str, Any], *keys: str) -> Optional[str]:
|
||||
"""按优先级返回外部响应中的首个非空文本。"""
|
||||
return next((text for key in keys if (text := cls._text(item.get(key)))), None)
|
||||
|
||||
@classmethod
|
||||
def _localized_text(cls, item: dict[str, Any], prefix: str) -> Optional[str]:
|
||||
"""优先返回中文说明,不存在时回退到英文说明。"""
|
||||
return cls._first_text(item, f"{prefix}CN", f"{prefix}EN")
|
||||
|
||||
@staticmethod
|
||||
def _optional_int(value: Any) -> Optional[int]:
|
||||
"""将外部响应值安全转换为整数。"""
|
||||
try:
|
||||
return int(value) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _optional_float(value: Any) -> float:
|
||||
"""将外部评分安全转换为浮点数。"""
|
||||
try:
|
||||
return float(value) if value not in (None, "") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _split_text(cls, value: Any) -> list[str]:
|
||||
"""把分号或斜线分隔的外部文本转换为去重列表。"""
|
||||
text = cls._text(value)
|
||||
if not text:
|
||||
return []
|
||||
return cls._unique_texts(text.replace("/", ";").split(";"))
|
||||
|
||||
@classmethod
|
||||
def _unique_texts(cls, values: list[Any]) -> list[str]:
|
||||
"""过滤空值并按大小写无关方式去重。"""
|
||||
results = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
text = cls._text(value)
|
||||
identity = text.casefold() if text else ""
|
||||
if not text or identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(text)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _same_text(left: Optional[str], right: Optional[str]) -> bool:
|
||||
"""使用音乐元数据紧凑文本规则比较标题和艺术家。"""
|
||||
return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right))
|
||||
@@ -476,6 +476,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
@@ -562,6 +565,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
return None
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class DownloaderTorrent(BaseModel):
|
||||
@@ -63,6 +64,7 @@ class TransferTask(BaseModel):
|
||||
meta: Optional[Any] = None
|
||||
mediainfo: Optional[Any] = None
|
||||
media_source: Optional[str] = None
|
||||
mtype: Optional[MediaType] = None
|
||||
target_directory: Optional[TransferDirectoryConf] = None
|
||||
target_storage: Optional[str] = None
|
||||
target_path: Optional[Path] = None
|
||||
|
||||
@@ -423,6 +423,8 @@ class MediaRecognizeType(Enum):
|
||||
AniList = "AniList"
|
||||
# MusicBrainz
|
||||
MusicBrainz = "MusicBrainz"
|
||||
# TheAudioDB
|
||||
TheAudioDB = "TheAudioDB"
|
||||
|
||||
|
||||
# 用户配置Key字典
|
||||
|
||||
@@ -9,6 +9,11 @@ MEDIA_SOURCE_ALIASES = {
|
||||
"douban": "douban",
|
||||
"bangumi": "bangumi",
|
||||
"anilist": "anilist",
|
||||
"musicbrainz": "musicbrainz",
|
||||
"theaudiodb": "theaudiodb",
|
||||
"audio_db": "theaudiodb",
|
||||
"doubanmusic": "doubanmusic",
|
||||
"douban_music": "doubanmusic",
|
||||
}
|
||||
|
||||
MEDIA_SOURCE_PREFIXES = {
|
||||
@@ -16,6 +21,9 @@ MEDIA_SOURCE_PREFIXES = {
|
||||
"douban": "douban",
|
||||
"bangumi": "bangumi",
|
||||
"anilist": "anilist",
|
||||
"musicbrainz": "musicbrainz",
|
||||
"theaudiodb": "theaudiodb",
|
||||
"doubanmusic": "doubanmusic",
|
||||
}
|
||||
|
||||
MEDIA_SOURCE_ID_FIELDS = {
|
||||
@@ -23,8 +31,19 @@ MEDIA_SOURCE_ID_FIELDS = {
|
||||
"douban": ("douban_id", "doubanid"),
|
||||
"bangumi": ("bangumi_id", "bangumiid"),
|
||||
"anilist": ("anilist_id", "anilistid"),
|
||||
"musicbrainz": ("media_id",),
|
||||
"theaudiodb": ("media_id", "theaudiodb_id"),
|
||||
"doubanmusic": ("media_id", "douban_id", "doubanid"),
|
||||
}
|
||||
|
||||
MUSIC_MEDIA_SOURCE_ORDER = ("musicbrainz", "theaudiodb", "doubanmusic")
|
||||
MUSIC_MEDIA_SOURCES = frozenset(MUSIC_MEDIA_SOURCE_ORDER)
|
||||
|
||||
|
||||
def is_music_media_source(source: Optional[str]) -> bool:
|
||||
"""判断单个请求级来源是否为内置音乐元数据源。"""
|
||||
return normalize_media_source(source) in MUSIC_MEDIA_SOURCES
|
||||
|
||||
|
||||
def normalize_media_source(source: Optional[str]) -> Optional[str]:
|
||||
"""规范化媒体数据源名称,兼容外部使用的 ``tmdb`` 前缀。"""
|
||||
|
||||
Reference in New Issue
Block a user