feat(music): 支持多源识别与元数据刮削

This commit is contained in:
jxxghp
2026-08-11 23:33:14 +08:00
parent 1197ece12b
commit 3f1b0cdb05
38 changed files with 3764 additions and 284 deletions

View File

@@ -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, "音乐标题")

View File

@@ -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,

View File

@@ -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]:

View File

@@ -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:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它现有来源使用数字 ID。"""
"""按媒体数据源校验原生 IDMusicBrainz 使用 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)

View File

@@ -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)})$")]

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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",

View File

@@ -126,6 +126,7 @@
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
"媒体ID格式无效": "媒體ID格式無效",
"MusicBrainz 只能用于音乐刮削": "MusicBrainz 只能用於音樂刮削",
"音乐元数据源只能用于音乐刮削": "音樂中繼資料來源只能用於音樂刮削",
"刮削失败,无法识别音乐信息": "刮削失敗,無法識別音樂資訊",
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
"刮削路径不存在": "刮削路徑不存在",

View File

@@ -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 = (

View File

@@ -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"
):

View File

@@ -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 当前音乐详情使用 songstracks/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,

View File

@@ -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')):
"""

View File

@@ -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

View 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))

View File

@@ -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

View File

@@ -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

View File

@@ -423,6 +423,8 @@ class MediaRecognizeType(Enum):
AniList = "AniList"
# MusicBrainz
MusicBrainz = "MusicBrainz"
# TheAudioDB
TheAudioDB = "TheAudioDB"
# 用户配置Key字典

View File

@@ -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`` 前缀。"""