diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index 1591b18c2..b0076edc7 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -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, "音乐标题") diff --git a/app/agent/tools/impl/scrape_metadata.py b/app/agent/tools/impl/scrape_metadata.py index 146a65c91..d676da56c 100644 --- a/app/agent/tools/impl/scrape_metadata.py +++ b/app/agent/tools/impl/scrape_metadata.py @@ -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, diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index e37a8b21e..d2b5ca2ca 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -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]: diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index fbdc22419..96b0bc9d4 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -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) diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index b6e22cbb7..72a013bee 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -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)})$")] diff --git a/app/chain/__init__.py b/app/chain/__init__.py index bb9dd08fc..25fc57deb 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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( diff --git a/app/chain/media.py b/app/chain/media.py index 7c2145c06..21dcfc263 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -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) diff --git a/app/chain/music.py b/app/chain/music.py index 729c5c031..510ff9e48 100644 --- a/app/chain/music.py +++ b/app/chain/music.py @@ -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) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index ea70a8df0..0c44ba398 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -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, diff --git a/app/core/config.py b/app/core/config.py index 3af5287fe..b8f776c7b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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, diff --git a/app/core/context.py b/app/core/context.py index 4cdab1cfb..c96b51cc9 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -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: diff --git a/app/core/meta/__init__.py b/app/core/meta/__init__.py index b9242d9db..570eaae9e 100644 --- a/app/core/meta/__init__.py +++ b/app/core/meta/__init__.py @@ -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, +) diff --git a/app/core/meta/metamusic.py b/app/core/meta/metamusic.py index 7711c6a05..8a9aeb4b3 100644 --- a/app/core/meta/metamusic.py +++ b/app/core/meta/metamusic.py @@ -1,13 +1,15 @@ import re +from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional +from threading import RLock +from typing import Any, Callable, Optional from app.core.meta.metabase import MetaBase from app.schemas.types import MediaType _AUDIO_FORMAT_PATTERN = re.compile( - r"(?DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|" + r"(?DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|" r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA)(?![A-Z])", re.IGNORECASE, ) @@ -31,6 +33,7 @@ _AUDIO_FORMAT_ALIASES = { "M4A": "AAC", "DSF": "DSD", "DFF": "DSD", + "SACD": "DSD", } _LOSSLESS_AUDIO_FORMATS = frozenset({"DSD", "FLAC", "ALAC", "APE", "WAV", "AIFF", "PCM"}) _LOSSY_AUDIO_FORMATS = frozenset({"MP3", "AAC", "OGG", "OPUS", "WMA"}) @@ -184,7 +187,7 @@ def _optional_int(value: Any) -> Optional[int]: # 资源标题中的音质规格与发行标记(格式、位深采样、年份括号、发行实体标记), # 拆分艺术家/曲名前需先剔除,否则「曲名 - FLAC [16B-44.1kHz]」会被误拆成艺术家与曲名 _MUSIC_FORMAT_TOKEN_ALT = ( - r"DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|CUE|WAV|WAVE|AIFF?|PCM|" + r"DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|CUE|WAV|WAVE|AIFF?|PCM|" r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB" ) _MUSIC_VIDEO_TOKEN_ALT = ( @@ -198,23 +201,31 @@ _MUSIC_VIDEO_TOKEN_ALT = ( ) _MUSIC_QUALITY_TOKEN_RE = re.compile( r"\[[^\]]*\]|\((?:19|20)\d{2}\)|" - rf"\b(?:{_MUSIC_FORMAT_TOKEN_ALT})\b|" + rf"(?[A-Za-z0-9]+)[\-–—−-]+)" r"(?P[^\s\-–—−-]+(?:\s+[^\s\-–—−-]+)*)\s*$" ) +# 规格词后的发布组尾巴:FLAC 2.0-ADE、FLAC 分轨-nbarock、FLAC 2.0-LIVE@ADE。 +# 必须存在已知规格词才剥离,避免把普通的「标题-艺术家」误当发布组。 +_MUSIC_RELEASE_GROUP_RE = re.compile( + rf"(?(?:19|20)\d{{2}})\s*[-–—−-]\s*" + rf"(?:{_MUSIC_FORMAT_TOKEN_ALT})(?:\s*(?:分[轨軌]|整[轨軌]|原抓|自抓|自扫|自掃))*" + r"(?:\s*[-–—−-]\s*[^\s\-–—−-]+){0,4}\s*$", + re.IGNORECASE, +) +# 圆括号中的联合位深声明(24/48bit)及规格剔除后留下的空括号。 +_MUSIC_PAREN_SPEC_RE = re.compile( + r"[\((]\s*\d{1,3}\s*/\s*\d{1,3}\s*-?\s*bits?\s*[\))]", + re.IGNORECASE, +) +_MUSIC_EMPTY_BRACKET_RE = re.compile(r"[\((\[]\s*(?:[/+,\-]\s*)*[\))\]]") +# 尾部花括号通常是唱片目录号或发布标记,仅在末尾剔除,保护正文中的花括号文本。 +_MUSIC_TRAILING_CATALOG_RE = re.compile(r"\s*\{[A-Za-z0-9][^{}]{0,40}\}\s*$") # 年份括号:(2000)(2000)【2000】形式的发行年份,作为候选消歧线索 -_MUSIC_YEAR_RE = re.compile(r"[\((【]((?:19|20)\d{2})[\))】]") +_MUSIC_YEAR_RE = re.compile(r"[\(\[(【]((?:19|20)\d{2})[\)\])】]") # 标题尾部独立年份:「xxx音乐会 2018」「Funky Jazz Saxophone 2024」「系列-2007」, # 提取为发行年份线索并从曲名剥离,避免年份文本进入检索式造成零命中 _MUSIC_TRAILING_YEAR_RE = re.compile(r"(?[^))]*[《》][^) _MUSIC_ALBUM_MARKER_RE = re.compile( r"^\s*(?P[^《》]+?)\s*《(?P[^《》]+)》\s*(?P.*)$" ) +_MUSIC_TRAILING_CJK_ALIAS_RE = re.compile( + r"\s+[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]" + r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af·・.'’\s]{0,60}$" +) _MUSIC_ALBUM_DISC_RE = re.compile(r"[\s\-–—−-]*(?:cd|disc)\s*(\d{1,2})$", re.IGNORECASE) # 曲名尾部重复的艺术家署名(如「名人名曲-毛阿敏」「xxx - 许茹芸」) _MUSIC_ARTIST_SUFFIX_RE = re.compile(r"[\-–—−-]\s*(?P[^\-–—−-]+?)\s*$") @@ -300,6 +353,52 @@ _MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P(?:19|20)\d{2})\s*[)\]]") _MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)") _MUSIC_SPACES_RE = re.compile(r"\s+") _MUSIC_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE) +# 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。 +# 这里按 token 分类后再清理,避免用一个跨越整行的大正则吞掉年份后的演出名称。 +_MUSIC_SCENE_RESOLUTION_RE = re.compile( + r"^(?:(?:480|576|720|1080|2160)[pi]|[248]k)$", + re.IGNORECASE, +) +_MUSIC_SCENE_SOURCE_RE = re.compile( + r"^(?:uhd|blu[-.]?ray|bdrip|remux|web[-.]?dl|webrip|hdtv|uhdtv|" + r"hd[-.]?dvd|dvd|dvdrip|2cd\+blu[-.]?ray)$", + re.IGNORECASE, +) +_MUSIC_SCENE_VIDEO_RE = re.compile( + r"^(?:x26[45](?:[._-]?(?:8|10|12)bits?)?|h[.]?26[45]|avc|hevc|" + r"mpeg[-.]?2|vc[-.]?1|prores|av1)$", + re.IGNORECASE, +) +_MUSIC_SCENE_EFFECT_RE = re.compile( + r"^(?:sdr|hdr(?:10[+]?)?|hdrvivid|dovi|dv|dolbyvision|3d|repack|hlg|hq)$", + re.IGNORECASE, +) +_MUSIC_SCENE_AUDIO_RE = re.compile( + r"^(?:dts(?:-hd)?(?:ma|hra)?|truehd|atmos|ddp|dd[+]?|eac3|ac3|" + r"lpcm|aac|flac|pcm|opus|vorbis)(?:[257][.]1|2[.]0)?$", + re.IGNORECASE, +) +_MUSIC_SCENE_AUDIO_AUX_RE = re.compile(r"^(?:ma|hra)(?:[257][.]1|2[.]0)?$", re.IGNORECASE) +_MUSIC_SCENE_CHANNEL_RE = re.compile(r"^(?:1[.]0|2[.]0|[257][.]1)(?:ch(?:annels?)?)?$", re.IGNORECASE) +_MUSIC_SCENE_BIT_RE = re.compile(r"^(?:8|10|12|16|20|24|32)[-_.]?bits?$", re.IGNORECASE) +_MUSIC_SCENE_FPS_RE = re.compile(r"^[0-9]{2,3}fps$", re.IGNORECASE) +_MUSIC_SCENE_AUDIO_COUNT_RE = re.compile(r"^[0-9]{1,2}audios?$", re.IGNORECASE) +_MUSIC_SCENE_YEAR_TOKEN_RE = re.compile(r"^(?:19|20)[0-9]{2}$") +_MUSIC_SCENE_DATE_TOKEN_RE = re.compile(r"^(?P[0-9]{2})(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])$") +_MUSIC_SCENE_YEAR_RANGE_TOKEN_RE = re.compile( + r"^(?P(?:19|20)[0-9]{2})[-–—~~](?P(?:(?:19|20)[0-9]{2}|[0-9]{2}))$" +) +_MUSIC_SCENE_RELEASE_GROUP_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9@._-]{1,20}$") +_MUSIC_SCENE_PUNCTUATED_TECH_RE = re.compile( + r"([,;])(?=(?:blu[-.]?ray|web[-.]?dl|hdtv|remux|avc|hevc|x26[45]|h[.]?26[45]))", + re.IGNORECASE, +) +_MUSIC_SCENE_PLATFORM_TOKENS = frozenset({ + "AMZN", "BAHA", "CR", "FRIDAY", "HMAX", "IQ", "IT", "LINETV", + "MYTVSUPER", "NF", "OTOTOY", +}) +_MUSIC_SCENE_LOCALE_TOKENS = frozenset({"GERMAN", "ITA", "JPN"}) +_MUSIC_LATIN_HYPHEN_NON_ARTIST_SUFFIXES = frozenset({"cd", "disc", "part", "type", "vol", "volume"}) # 日文资源标题大量使用全角字符(WOWOW、50th、全角空格与括号), # 归一为半角后才能与 MusicBrainz 条目及内置模式匹配 _FULLWIDTH_EXCLAMATION = 0xFF01 @@ -307,10 +406,8 @@ _FULLWIDTH_TILDE = 0xFF5E _HALFWIDTH_OFFSET = 0xFEE0 _FULLWIDTH_MAP = { 0x3000: " ", # 全角空格 - 0x300C: "[", 0x300D: "]", # 「」 - 0x300E: "[", 0x300F: "]", # 『』 0x3010: "[", 0x3011: "]", # 【】 - # 《》多为专辑书名号(歌手《专辑名》),不属于格式注释,保留原样由专辑结构规则处理 + # 日文引号「」/『』与《》承载作品名,不属于格式注释,保留原样交给命名模式。 0xFF08: "(", 0xFF09: ")", # () 0xFF3B: "[", 0xFF3D: "]", # [] 0xFF5B: "{", 0xFF5D: "}", # {} @@ -376,6 +473,147 @@ def _restore_letter_abbrev(value: str) -> str: ) +@dataclass(frozen=True) +class MusicNameContext: + """音乐命名公共清理后的解析上下文,供命名模式和解析器共享。""" + + raw: str + normalized: str + text: str + artists: tuple[str, ...] + year: Optional[int] = None + comment: Optional[str] = None + + +@dataclass(frozen=True) +class _MusicSceneTokenResult: + """音乐视频场景 token 清理结果,仅供 metamusic 内置模式使用。""" + + text: str + year: Optional[int] + categories: frozenset[str] + + +@dataclass(frozen=True) +class MusicNamePattern: + """可动态注册的音乐命名模式。""" + + name: str + matcher: Callable[[MusicNameContext], Optional[Any]] + priority: int = 0 + + +@dataclass(frozen=True) +class MusicNamePatternMatch: + """第一层命名模式匹配结果。""" + + pattern_name: str + payload: Any + + +@dataclass(frozen=True) +class MusicNameParseResult: + """第二层解析器提取出的音乐命名字段。""" + + title: Optional[str] + artists: Optional[list[str]] = None + album: Optional[str] = None + year: Optional[int] = None + disc_number: Optional[int] = None + + +@dataclass(frozen=True) +class MusicNameParser: + """可动态注册、按命名模式选择的音乐解析器。""" + + name: str + patterns: tuple[str, ...] + handler: Callable[ + [MusicNameContext, MusicNamePatternMatch], Optional[MusicNameParseResult] + ] + priority: int = 0 + + +class MusicNameRegistry: + """音乐命名模式与解析器的两层动态注册中心。""" + + _patterns: dict[str, MusicNamePattern] = {} + _parsers: dict[str, MusicNameParser] = {} + _lock = RLock() + + @classmethod + def register_pattern(cls, pattern: MusicNamePattern, replace: bool = False) -> None: + """注册命名模式,同名模式仅在 ``replace=True`` 时替换。""" + with cls._lock: + if pattern.name in cls._patterns and not replace: + raise ValueError(f"音乐命名模式已存在:{pattern.name}") + cls._patterns[pattern.name] = pattern + + @classmethod + def unregister_pattern(cls, name: str) -> bool: + """按名称注销命名模式,返回是否实际移除。""" + with cls._lock: + return cls._patterns.pop(name, None) is not None + + @classmethod + def register_parser(cls, parser: MusicNameParser, replace: bool = False) -> None: + """注册解析器,同名解析器仅在 ``replace=True`` 时替换。""" + with cls._lock: + if parser.name in cls._parsers and not replace: + raise ValueError(f"音乐命名解析器已存在:{parser.name}") + cls._parsers[parser.name] = parser + + @classmethod + def unregister_parser(cls, name: str) -> bool: + """按名称注销解析器,返回是否实际移除。""" + with cls._lock: + return cls._parsers.pop(name, None) is not None + + @classmethod + def get_patterns(cls) -> tuple[MusicNamePattern, ...]: + """按优先级返回当前已注册的命名模式快照。""" + with cls._lock: + return tuple(sorted(cls._patterns.values(), key=lambda item: item.priority, reverse=True)) + + @classmethod + def get_parsers(cls) -> tuple[MusicNameParser, ...]: + """按优先级返回当前已注册的解析器快照。""" + with cls._lock: + return tuple(sorted(cls._parsers.values(), key=lambda item: item.priority, reverse=True)) + + @classmethod + def match_pattern(cls, context: MusicNameContext) -> Optional[MusicNamePatternMatch]: + """执行第一层命名模式匹配,返回首个命中的模式及载荷。""" + for pattern in cls.get_patterns(): + payload = pattern.matcher(context) + if payload is not None: + return MusicNamePatternMatch(pattern_name=pattern.name, payload=payload) + return None + + @classmethod + def match_parser(cls, matched: MusicNamePatternMatch) -> Optional[MusicNameParser]: + """执行第二层解析器匹配,选择支持该模式且优先级最高的解析器。""" + return next( + ( + parser + for parser in cls.get_parsers() + if matched.pattern_name in parser.patterns or "*" in parser.patterns + ), + None, + ) + + @classmethod + def parse(cls, context: MusicNameContext) -> Optional[MusicNameParseResult]: + """依次匹配命名模式和解析器,并返回结构化音乐字段。""" + matched = cls.match_pattern(context) + if not matched: + return None + parser = cls.match_parser(matched) + if not parser: + return None + return parser.handler(context, matched) + + class MetaMusic(MetaBase): """音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。""" @@ -483,121 +721,81 @@ class MetaMusic(MetaBase): def apply_title(self, value: Any) -> None: """解析种子/文件名标题字符串,提取艺术家、曲名、年份并补充音质参数。 - 与影视 MetaVideo 在构造时解析标题一致,这里是音乐分支的识别核心: - 先剔除音质规格与发行标记,再拆分艺术家与曲名,最后提取曲序前缀。 + 公共层先完成字符归一、音质与干扰信息剔除;随后由注册中心依次匹配 + 命名模式和对应解析器,最后统一回填结构化字段并提取曲序前缀。 """ raw = str(value or "") self.apply_audio_quality(raw) - normalized = self._normalize_text(raw) - if not normalized: + context = self._prepare_name_context( + raw=raw, + artists=self.artists, + year=self.year, + ) + if not context.normalized: return - # 年份括号在规格剔除前提取,作为发行年份线索参与候选消歧 + parsed = MusicNameRegistry.parse(context) + if not parsed: + if not context.text: + self.title = None + if self.year is None: + self.year = context.year + return + self._apply_name_result(context, parsed) + self._apply_track_prefix() + + @classmethod + def _prepare_name_context( + cls, + raw: str, + artists: list[str], + year: Optional[int], + ) -> MusicNameContext: + """统一归一命名文本并剔除音质、视频、日期等干扰信息。""" + normalized = cls._normalize_text(raw) + parsed_year = year years = _MUSIC_YEAR_RE.findall(normalized) - if years and not self.year: - # 多个年份括号时取最后一个,资源标题中年份通常位于末位 - self.year = int(years[-1]) - # 音质标记先剔除再拆分,避免规格文本独占曲名位置; - # 尾部纯规格段(含发布组标签)先整段剔除,保护「曲名 (注释)」不被拆散 - cleaned = self._strip_spec_segments(normalized) - cleaned = self._strip_quality_tokens(cleaned) - # 广播/发行日期前缀与年份区间在规格剔除后处理,避免误伤曲名中的短数字序列 - cleaned, range_year = self._strip_date_prefix(cleaned) - if range_year and not self.year: - self.year = range_year - # 曲名尾部括号注释先提取:注释内嵌套《》会干扰专辑书名号判定; - # 注释是曲名的版本说明,最终拼回曲名供展示与弱匹配 + if years and parsed_year is None: + # 多个年份括号时末位通常才是资源的发行年份。 + parsed_year = int(years[-1]) + clean_source, release_year = cls._strip_audio_release_tail(normalized) + if release_year and parsed_year is None: + parsed_year = release_year + cleaned = cls._strip_quality_tokens(cls._strip_spec_segments(clean_source)) + cleaned, range_year = cls._strip_date_prefix(cleaned) + if range_year and parsed_year is None: + parsed_year = range_year comment = None comment_match = _MUSIC_TITLE_COMMENT_RE.search(cleaned) if comment_match: + # 含书名号的版本注释会干扰专辑模式,先移出并在解析后统一拼回。 comment = comment_match.group("comment").strip() cleaned = cleaned[: comment_match.start()].strip() - # 规格剔除后曲名侧无剩余文本时,尾部悬空分隔符仍是艺术家署名结构 - # (「周杰伦 - 合集 2000-2022 - FLAC 16bit 44 1khz」剔除后仅剩「周杰伦 -」) - dangling = re.fullmatch(r"(?P.+?)\s+[\-–—−-]+", cleaned) if cleaned else None - if dangling and not self.artists: - self.artists = self._split_artists(dangling.group("artist")) - self.title = None - self._apply_track_prefix() - return - # CJK「歌手《专辑名》」书名号命名优先于连字符拆分,提取艺术家与专辑实体 - marker = None if self.artists else _MUSIC_ALBUM_MARKER_RE.match(cleaned) - if marker: - # 书名号前的艺术家段可能是「曲名-歌手」无空格连字符写法(为你盛开-许巍), - # 反向拆分后首段是曲名线索,比专辑名更接近单曲检索目标 - song_hint: Optional[str] = None - hyphen_artists, head_title = self._split_cjk_hyphen(marker.group("artist")) - if hyphen_artists: - self.artists = hyphen_artists - song_hint = self._clean_tail(head_title) - else: - self.artists = self._split_artists(marker.group("artist")) - album = self._normalize_text(marker.group("album")) - disc_match = _MUSIC_ALBUM_DISC_RE.search(album) - if disc_match: - # 专辑名尾部 -CD2 是碟号线索不是专辑名内容 - self.disc_number = self.disc_number or int(disc_match.group(1)) - album = album[: disc_match.start()].strip() - self.album = album - rest = marker.group("rest").strip(" \t-–—−-_《》.") - # 书名号后仅剩年份时作为发行年份线索 - if rest and re.fullmatch(r"(19|20)\d{2}", rest): - if not self.year: - self.year = int(rest) - rest = "" - # 书名号后仅剩碟号(CD2/Disc1)时提取为碟号线索 - rest_disc = re.fullmatch(r"(?:cd|disc|disk)\s*(\d{1,2})", rest, re.IGNORECASE) if rest else None - if rest_disc: - self.disc_number = self.disc_number or int(rest_disc.group(1)) - rest = "" - # 曲名回退依次用剩余文本、曲名线索与专辑名供检索 - self._finalize_title(rest or song_hint or album) - if comment: - # 书名号结构下注释属于专辑后的补充说明,拼回曲名 - self.title = f"{self.title} ({comment})" if self.title else comment - self._apply_track_prefix() - return - match = _MUSIC_ARTIST_TITLE_RE.match(cleaned) - if match and not self.artists: - self.artists = self._split_artists(match.group("artist")) - self._finalize_title( - self._strip_artist_suffix( - self._clean_tail(match.group("title")), self.artists)) - elif cleaned: - # 场景命名的 VA-Title 无空格连字符写法,主拆分不适用,按别名前缀单独拆分 - alias_match = None if self.artists else _MUSIC_ALIAS_PREFIX_RE.match(cleaned) - if alias_match: - # 别名前缀均为合辑署名写法,未收录变体统一归一为 Various Artists - self.artists = [_MUSIC_ARTIST_ALIASES.get( - alias_match.group("alias").casefold(), "Various Artists")] - self._finalize_title(self._clean_tail(alias_match.group("title"))) - else: - # CJK 标题常见「专辑名-歌手」无空格连字符写法,主拆分未命中时兜底反向拆分 - artists, title = self._split_cjk_hyphen(cleaned) - if not artists: - # 拉丁「艺术家-专辑」无空格连字符写法(Gene Clark-White Light) - artists, title = self._split_latin_hyphen(cleaned) - if artists: - self.artists = artists - self._finalize_title(self._clean_tail(title)) - else: - # 「艺术家 年份 专辑」三明治结构(Leehom Wang 2010 The 18 Martial Arts), - # 其他拆分均无艺术家线索时按中部年份拆分,年份提取为发行线索 - artists, title, inner_year = self._split_year_sandwich(cleaned) - if artists: - self.artists = artists - if inner_year and not self.year: - self.year = inner_year - self._finalize_title(self._clean_tail(title)) - else: - title = self._clean_tail(cleaned) - # 无艺术家线索时剥离 CJK 标题尾部的「曲名-歌手」署名,候选比对阶段负责验证身份 - self._finalize_title(self._strip_cjk_artist_suffix(title)) - else: - self.title = None - # 括号注释属于曲名的版本/出处说明,剥离的规格文本之后拼回展示 - if comment and self.title: - self.title = f"{self.title} ({comment})" - self._apply_track_prefix() + return MusicNameContext( + raw=raw, + normalized=normalized, + text=cleaned, + artists=tuple(artists), + year=parsed_year, + comment=comment, + ) + + def _apply_name_result( + self, + context: MusicNameContext, + parsed: MusicNameParseResult, + ) -> None: + """把解析器结果回填到当前对象,并保留调用方已有的高可信字段。""" + self.title = parsed.title + if context.comment and self.title: + self.title = f"{self.title} ({context.comment})" + if parsed.artists is not None: + self.artists = list(parsed.artists) + if parsed.album is not None: + self.album = parsed.album + if self.year is None: + self.year = parsed.year or context.year + if self.disc_number is None: + self.disc_number = parsed.disc_number @classmethod def _pop_trailing_year(cls, value: str) -> tuple[str, Optional[int]]: @@ -611,12 +809,11 @@ class MetaMusic(MetaBase): return value, None return head.strip(), int(match.group(1)) - def _finalize_title(self, value: str) -> None: - """设置曲名并提取尾部独立年份作为发行年份线索。""" - title, year = self._pop_trailing_year(value) - if year and not self.year: - self.year = year - self.title = title + @classmethod + def _parse_title_year(cls, value: str) -> tuple[str, Optional[int]]: + """返回剥离尾部独立年份后的曲名及发行年份线索。""" + title, year = cls._pop_trailing_year(value) + return title, year @staticmethod def _normalize_text(value: Any) -> str: @@ -628,6 +825,138 @@ class MetaMusic(MetaBase): text = _restore_letter_abbrev(text) return _MUSIC_SPACES_RE.sub(" ", text).strip() + @classmethod + def _scene_token_category(cls, token: str, allow_release_suffix: bool = True) -> Optional[str]: + """识别音乐视频场景 token 类别,不在这里决定是否删除。""" + value = str(token or "").strip(" \t[](){};,\"") + if not value: + return None + if _MUSIC_SCENE_YEAR_TOKEN_RE.fullmatch(value): + return "year" + if _MUSIC_SCENE_DATE_TOKEN_RE.fullmatch(value): + return "date" + if _MUSIC_SCENE_YEAR_RANGE_TOKEN_RE.fullmatch(value): + return "year_range" + patterns = ( + ("resolution", _MUSIC_SCENE_RESOLUTION_RE), + ("source", _MUSIC_SCENE_SOURCE_RE), + ("video", _MUSIC_SCENE_VIDEO_RE), + ("effect", _MUSIC_SCENE_EFFECT_RE), + ("audio", _MUSIC_SCENE_AUDIO_RE), + ("audio_aux", _MUSIC_SCENE_AUDIO_AUX_RE), + ("channel", _MUSIC_SCENE_CHANNEL_RE), + ("bit", _MUSIC_SCENE_BIT_RE), + ("fps", _MUSIC_SCENE_FPS_RE), + ("audio_count", _MUSIC_SCENE_AUDIO_COUNT_RE), + ) + for category, pattern in patterns: + if pattern.fullmatch(value): + return category + upper_value = value.upper() + if upper_value in _MUSIC_SCENE_PLATFORM_TOKENS or value == "iT": + return "platform" + if upper_value in _MUSIC_SCENE_LOCALE_TOKENS: + return "locale" + if allow_release_suffix: + # 技术 token 与发布组常粘连为 H.265-CHORTLE、x264@JJL; + # 完整 token 未命中时只剥离最后一段,再验证左侧确为技术字段。 + for separator_char in ("-", "@"): + head, separator, tail = value.rpartition(separator_char) + if not separator or not _MUSIC_SCENE_RELEASE_GROUP_RE.fullmatch(tail): + continue + head_category = cls._scene_token_category(head, allow_release_suffix=False) + if head_category in { + "resolution", "source", "video", "effect", "audio", + "audio_aux", "channel", "bit", "fps", "audio_count", + }: + return head_category + return None + + @classmethod + def _parse_music_scene_tokens(cls, value: str) -> Optional[_MusicSceneTokenResult]: + """按音乐语义清理影视场景 token,强特征不足时不接管标题。""" + normalized = cls._normalize_text(value) + # 分类标签有时紧贴规格(Type-A,D,Blu-ray),只在已知技术词前补空格。 + normalized = _MUSIC_SCENE_PUNCTUATED_TECH_RE.sub(r"\1 ", normalized) + tokens = normalized.split() + if not tokens: + return None + categories = [cls._scene_token_category(token) for token in tokens] + + # 场景点分归一可能把 H.265 拆成 H 265,组合识别后同时标记两个 token。 + for index in range(len(tokens) - 1): + if tokens[index].upper() == "H" and tokens[index + 1] in {"264", "265"}: + categories[index] = categories[index + 1] = "video" + + # MA/HRA 与声道数字本身可能是作品名称,只在紧邻音频编码时作为规格清理。 + for index, category in enumerate(categories): + if category != "audio_aux": + continue + neighbors = categories[max(0, index - 1): index] + categories[index + 1: index + 2] + categories[index] = "audio" if "audio" in neighbors else None + for index, category in enumerate(categories): + if category != "channel": + continue + nearby = categories[max(0, index - 2): index] + categories[index + 1: index + 3] + if "audio" not in nearby: + categories[index] = None + + category_set = {category for category in categories if category} + primary_count = len(category_set.intersection({"resolution", "source", "video"})) + strong_signature = primary_count >= 2 or ( + "audio" in category_set + and bool(category_set.intersection({"resolution", "source"})) + ) + if not strong_signature: + return None + + parsed_year = None + kept_tokens: list[str] = [] + standalone_year_count = categories.count("year") + removable = { + "resolution", "source", "video", "effect", "audio", "channel", + "bit", "fps", "audio_count", "platform", "locale", + } + for token, category in zip(tokens, categories): + if category == "year": + # 连续双年份常是演出名称的一部分(Live At Montreux 1999 2022), + # 只有唯一的独立年份才作为发行线索提取。 + if standalone_year_count > 1: + kept_tokens.append(token) + continue + parsed_year = int(token.strip("[](){};,\"")) + continue + if category == "date": + date_match = _MUSIC_SCENE_DATE_TOKEN_RE.fullmatch(token.strip("[](){};,\"")) + if date_match: + short_year = int(date_match.group("year")) + parsed_year = 2000 + short_year if short_year < 70 else 1900 + short_year + continue + if category == "year_range": + match = _MUSIC_SCENE_YEAR_RANGE_TOKEN_RE.fullmatch( + token.strip("[](){};,\"") + ) + if match: + end_year = match.group("end") + parsed_year = int( + end_year if len(end_year) == 4 else f"{match.group('begin')[:2]}{end_year}" + ) + continue + if category in removable: + continue + kept_tokens.append(token) + + cleaned = cls._clean_tail(" ".join(kept_tokens)).rstrip(" ,;") + cleaned = re.sub(r"\s+([,;:!?])", r"\1", cleaned) + cleaned = cls._normalize_text(cleaned) + if not cleaned: + return None + return _MusicSceneTokenResult( + text=cleaned, + year=parsed_year, + categories=frozenset(category_set), + ) + @classmethod def _split_artists(cls, value: str) -> list[str]: """拆分多艺术家字段(如 章子怡 & 周深),保留顺序供检索与候选比对使用。""" @@ -681,6 +1010,15 @@ class MetaMusic(MetaBase): return text text = text[: segment_match.start()].rstrip() + @staticmethod + def _strip_audio_release_tail(value: str) -> tuple[str, Optional[int]]: + """剥离年份开头的音频格式发布尾链,并返回发行年份。""" + text = str(value or "").strip() + match = _MUSIC_AUDIO_RELEASE_TAIL_RE.search(text) + if not match: + return text, None + return text[:match.start()].rstrip(" \t-–—−-"), int(match.group("year")) + @classmethod def _is_spec_residue(cls, residue: str, has_prefix: bool) -> bool: """判定规格词替换后的残留是否为发布组标签而非自然语言曲名。 @@ -699,9 +1037,14 @@ class MetaMusic(MetaBase): @classmethod def _strip_quality_tokens(cls, value: str) -> str: """剥离音频格式、视频编码、规格参数与年份括号,保留有效的艺术家与曲名文本。""" - raw = str(value or "") + raw = _MUSIC_RIP_NOTE_RE.sub(" ", str(value or "")) + raw = _MUSIC_PAREN_SPEC_RE.sub(" ", raw) + raw = _MUSIC_RELEASE_GROUP_RE.sub(" ", raw) text = _MUSIC_QUALITY_TOKEN_RE.sub(" ", raw) + text = _MUSIC_RIP_METHOD_RE.sub(" ", text) text = _MUSIC_VIDEO_TOKEN_RE.sub(" ", text) + text = _MUSIC_TRAILING_CATALOG_RE.sub(" ", text) + text = _MUSIC_EMPTY_BRACKET_RE.sub(" ", text) # 规格剥离后可能残留悬空分隔符(含 APE+CUE 类格式联合写法残留的加号),统一修剪 return cls._normalize_text(re.sub(r"^[\s\-–—−-/+]+|[\s\-–—−-/+]+$", "", text)) @@ -734,7 +1077,13 @@ class MetaMusic(MetaBase): return value head = value[:match.start()].strip() suffix = match.group("suffix").strip() - if head and suffix and cls._contains_cjk(head) and cls._contains_cjk(suffix): + artist_token = suffix.split(" ", 1)[0].strip(" ,,、;;") + if ( + head + and artist_token + and cls._contains_cjk(head) + and cls._contains_cjk(artist_token) + ): return head return value @@ -791,7 +1140,10 @@ class MetaMusic(MetaBase): if head and tail and cls._contains_cjk(head) and cls._contains_cjk(tail): # 分隔符后跟随多个词时只取首词为艺术家(「为你盛开——许巍 巡回演唱会」) artist_text = tail.split(" ", 1)[0] + if not cls._contains_cjk(artist_text): + return None, text # 艺术家段常见「xx作品全集」合集修饰,剥离后才能与条目署名比对 + artist_text = artist_text.strip(" ,,、;;") artist = _MUSIC_COLLECTION_SUFFIX_RE.sub("", artist_text).strip() or artist_text return [artist], head return None, text @@ -812,7 +1164,25 @@ class MetaMusic(MetaBase): return None, text head = head.strip(" \t-–—−-") tail = tail.strip(" \t-–—−-") - if head and tail and " " in head and " " in tail: + head_suffix_raw = head.rsplit(" ", 1)[-1] + head_suffix = head_suffix_raw.casefold() + tail_prefix = tail.split(" ", 1)[0] + all_caps_compound = ( + 1 < len(head_suffix_raw) <= 5 + and 1 < len(tail_prefix) <= 5 + and head_suffix_raw.isalpha() + and tail_prefix.isalpha() + and head_suffix_raw.isupper() + and tail_prefix.isupper() + ) + if ( + head + and tail + and " " in head + and " " in tail + and head_suffix not in _MUSIC_LATIN_HYPHEN_NON_ARTIST_SUFFIXES + and not all_caps_compound + ): return cls._split_artists(head), tail return None, text @@ -945,28 +1315,50 @@ class MetaMusic(MetaBase): def apply_path_context(self, path: "str | Path") -> "MetaMusic": """用文件名和目录线索回填音乐元数据中缺失的字段。 - 仅补充空字段,音频标签中已读取到的内容不会被目录猜测覆盖; - 标题来自文件名兜底(等于文件主干名)时视为缺失,允许用解析结果替换。 + 文件名先走与种子标题相同的动态模式注册中心,再按字段补充音频标签的空缺; + 目录线索优先级最低。标题等于文件主干名时视为读取标签后的文件名兜底, + 允许用完整模式的清理结果替换,真实标签中的非空字段始终保留。 """ file_path = Path(path) stem = file_path.stem title_from_name = not self.title or self.title == stem - # 文件名前缀:曲序、碟号、曲名 - track_number, disc_number, parsed_title = self.split_track_prefix(stem) - if self.track_number is None and track_number is not None: - self.track_number = track_number - if self.disc_number is None and disc_number is not None: - self.disc_number = disc_number - if title_from_name: - base_title = parsed_title or stem - if not self.artists: - # `歌手 - 曲名` 文件名在无艺术家标签时继续拆分 - artist, title = self.split_artist_title(base_title) - if artist: - self.artists = [artist] - base_title = title - self.title = base_title + # 曲序/碟号前缀是文件路径的强结构,先于通用艺术家-标题模式剥离, + # 避免「01 - One More Time」把 01 误判为艺术家。 + track_number, disc_number, filename_title = self.split_track_prefix(stem) + # 文件名解析使用独立对象,防止 apply_title 覆盖真实音频标签;这里只合并空字段。 + filename_meta = MetaMusic( + org_string=file_path.name, + title=filename_title or stem, + audio_format=file_path.suffix.lstrip(".").upper() or None, + parse_title=True, + ) + if filename_meta.track_number is None: + filename_meta.track_number = track_number + if filename_meta.disc_number is None: + filename_meta.disc_number = disc_number + if title_from_name and filename_meta.title: + self.title = filename_meta.title + for field_name in ( + "artists", + "album", + "year", + "disc_number", + "track_number", + "total_discs", + "total_tracks", + "version", + "isrc", + "audio_format", + "audio_lossless", + "bit_depth", + "sample_rate", + "bitrate", + ): + current_value = getattr(self, field_name, None) + parsed_value = getattr(filename_meta, field_name, None) + if current_value in (None, "", []) and parsed_value not in (None, "", []): + setattr(self, field_name, parsed_value) # 目录结构:父目录可能是碟片目录,专辑目录再往上一级 parent = file_path.parent @@ -1066,3 +1458,326 @@ class MetaMusic(MetaBase): media_source=data.get("media_source"), media_id=data.get("media_id"), ) + + +def _build_name_result( + context: MusicNameContext, + value: str, + artists: Optional[list[str]] = None, + album: Optional[str] = None, + year: Optional[int] = None, + disc_number: Optional[int] = None, +) -> MusicNameParseResult: + """统一剥离曲名尾部年份并构造解析结果。""" + title, title_year = MetaMusic._parse_title_year(value) + return MusicNameParseResult( + title=title, + artists=artists, + album=album, + year=context.year or year or title_year, + disc_number=disc_number, + ) + + +def _match_dangling_artist(context: MusicNameContext) -> Optional[Any]: + """匹配规格剥离后仅剩艺术家和悬空分隔符的命名。""" + if context.artists or not context.text: + return None + return re.fullmatch(r"(?P.+?)\s+[\-–—−-]+", context.text) + + +def _parse_dangling_artist( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析仅包含艺术家的悬空分隔符命名。""" + return MusicNameParseResult( + title=None, + artists=MetaMusic._split_artists(matched.payload.group("artist")), + year=context.year, + ) + + +def _match_music_video_scene(context: MusicNameContext) -> Optional[Any]: + """匹配具有强影视规格组合的音乐视频或演唱会场景命名。""" + return MetaMusic._parse_music_scene_tokens(context.normalized) + + +def _parse_music_video_scene( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """清理场景 token 后重新进入音乐模式,避免采用影视媒体类型和标题截断规则。""" + scene: _MusicSceneTokenResult = matched.payload + scene_context = MusicNameContext( + raw=scene.text, + normalized=scene.text, + text=scene.text, + artists=context.artists, + year=context.year or scene.year, + ) + parsed = MusicNameRegistry.parse(scene_context) + if parsed: + return parsed + return MusicNameParseResult( + title=scene.text, + year=scene_context.year, + ) + + +def _match_album_marker(context: MusicNameContext) -> Optional[Any]: + """匹配 CJK 书名号专辑命名。""" + if context.artists: + return None + return _MUSIC_ALBUM_MARKER_RE.match(context.text) + + +def _parse_album_marker( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析 CJK 书名号命名中的艺术家、专辑、碟号和标题。""" + marker = matched.payload + song_hint: Optional[str] = None + bilingual_prefix = False + artist_prefix = marker.group("artist") + artists, head_title = MetaMusic._split_cjk_hyphen(artist_prefix) + if artists: + song_hint = MetaMusic._clean_tail(head_title) + else: + # 中英双语原声常写成「English Artist - English Album 中文艺人 - 《中文片名》」。 + # 优先保留首个标准 artist-title 结构,并只在英文标题后确有 CJK 别名时剥离别名。 + standard_prefix = _MUSIC_ARTIST_TITLE_RE.match(artist_prefix) + if standard_prefix: + candidate_artist = standard_prefix.group("artist") + candidate_title = MetaMusic._clean_tail(standard_prefix.group("title")) + alias_match = _MUSIC_TRAILING_CJK_ALIAS_RE.search(candidate_title) + if ( + alias_match + and not MetaMusic._contains_cjk(candidate_artist) + and re.search(r"[A-Za-z]", candidate_title[:alias_match.start()]) + ): + artists = MetaMusic._split_artists(candidate_artist) + song_hint = candidate_title[:alias_match.start()].strip() + bilingual_prefix = True + else: + artists = MetaMusic._split_artists(artist_prefix) + else: + artists = MetaMusic._split_artists(artist_prefix) + album = MetaMusic._normalize_text(marker.group("album")) + disc_number = None + disc_match = _MUSIC_ALBUM_DISC_RE.search(album) + if disc_match: + disc_number = int(disc_match.group(1)) + album = album[:disc_match.start()].strip() + rest = marker.group("rest").strip(" \t-–—−-_《》.") + rest, rest_year = MetaMusic._parse_title_year(rest) + parsed_year = context.year or rest_year + if bilingual_prefix: + # 书名号后的「电影原声带」是中文发行类型说明,不应覆盖英文专辑标题。 + rest = "" + if rest and re.fullmatch(r"(?:19|20)\d{2}", rest): + parsed_year = parsed_year or int(rest) + rest = "" + rest_disc = ( + re.fullmatch(r"(?:cd|disc|disk)\s*(\d{1,2})", rest, re.IGNORECASE) + if rest + else None + ) + if rest_disc: + disc_number = disc_number or int(rest_disc.group(1)) + rest = "" + return _build_name_result( + context=context, + value=rest or song_hint or album, + artists=artists, + album=album, + year=parsed_year, + disc_number=disc_number, + ) + + +def _match_artist_title(context: MusicNameContext) -> Optional[Any]: + """匹配带空格分隔符的标准艺术家和标题命名。""" + if context.artists: + return None + return _MUSIC_ARTIST_TITLE_RE.match(context.text) + + +def _parse_artist_title( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析标准艺术家和标题命名。""" + artists = MetaMusic._split_artists(matched.payload.group("artist")) + title = MetaMusic._strip_artist_suffix( + MetaMusic._clean_tail(matched.payload.group("title")), + artists, + ) + return _build_name_result(context=context, value=title, artists=artists) + + +def _match_alias_prefix(context: MusicNameContext) -> Optional[Any]: + """匹配 VA 等合辑别名的无空格前缀命名。""" + if context.artists: + return None + return _MUSIC_ALIAS_PREFIX_RE.match(context.text) + + +def _parse_alias_prefix( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析 VA 等合辑别名前缀并归一艺术家名称。""" + alias = matched.payload.group("alias").casefold() + artists = [_MUSIC_ARTIST_ALIASES.get(alias, "Various Artists")] + return _build_name_result( + context=context, + value=MetaMusic._clean_tail(matched.payload.group("title")), + artists=artists, + ) + + +def _match_cjk_artist_title_rip(context: MusicNameContext) -> Optional[Any]: + """匹配带抓轨或 SACD 尾标的 CJK「艺术家-标题」命名,避免反拆。""" + release_signature = _MUSIC_RIP_SIGNATURE_RE.search(context.normalized) or re.search( + r"SACD\s*$", context.normalized, re.IGNORECASE + ) + if context.artists or not release_signature: + return None + artist, separator, title = context.text.partition("-") + artist = artist.strip(" \t-–—−-") + title = title.strip(" \t-–—−-") + if ( + separator + and artist + and title + and MetaMusic._contains_cjk(artist) + and MetaMusic._contains_cjk(title) + ): + return artist, title + return None + + +def _parse_cjk_artist_title_rip( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析带抓轨或 SACD 尾标的 CJK 艺术家和标题。""" + artist, title = matched.payload + return _build_name_result( + context=context, + value=MetaMusic._clean_tail(title), + artists=MetaMusic._split_artists(artist), + ) + + +def _match_cjk_hyphen(context: MusicNameContext) -> Optional[Any]: + """匹配 CJK 无空格连字符命名。""" + artists, title = MetaMusic._split_cjk_hyphen(context.text) + return (artists, title) if artists else None + + +def _parse_cjk_hyphen( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析 CJK 无空格连字符命名。""" + artists, title = matched.payload + return _build_name_result( + context=context, + value=MetaMusic._clean_tail(title), + artists=artists, + ) + + +def _match_latin_hyphen(context: MusicNameContext) -> Optional[Any]: + """匹配拉丁多词艺术家和专辑的无空格连字符命名。""" + artists, title = MetaMusic._split_latin_hyphen(context.text) + return (artists, title) if artists else None + + +def _parse_latin_hyphen( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析拉丁无空格连字符命名。""" + artists, title = matched.payload + return _build_name_result( + context=context, + value=MetaMusic._clean_tail(title), + artists=artists, + ) + + +def _match_year_sandwich(context: MusicNameContext) -> Optional[Any]: + """匹配艺术家、年份、标题三段式命名。""" + artists, title, year = MetaMusic._split_year_sandwich(context.text) + return (artists, title, year) if artists else None + + +def _parse_year_sandwich( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析艺术家、年份、标题三段式命名。""" + artists, title, year = matched.payload + return _build_name_result( + context=context, + value=MetaMusic._clean_tail(title), + artists=artists, + year=year, + ) + + +def _match_fallback(context: MusicNameContext) -> Optional[Any]: + """匹配未命中结构化模式的非空音乐标题。""" + return context.text or None + + +def _parse_fallback( + context: MusicNameContext, + matched: MusicNamePatternMatch, +) -> MusicNameParseResult: + """解析无结构标题并保留调用方已有艺术家字段。""" + title = MetaMusic._strip_cjk_artist_suffix(MetaMusic._clean_tail(matched.payload)) + return _build_name_result(context=context, value=title) + + +def _register_default_name_components() -> None: + """注册内置命名模式及其解析器,扩展可用更高优先级覆盖选择。""" + patterns = ( + MusicNamePattern("dangling_artist", _match_dangling_artist, 800), + MusicNamePattern("music_video_scene", _match_music_video_scene, 750), + MusicNamePattern("album_marker", _match_album_marker, 700), + MusicNamePattern("artist_title", _match_artist_title, 600), + MusicNamePattern("alias_prefix", _match_alias_prefix, 500), + MusicNamePattern("cjk_artist_title_rip", _match_cjk_artist_title_rip, 450), + MusicNamePattern("cjk_hyphen", _match_cjk_hyphen, 400), + MusicNamePattern("latin_hyphen", _match_latin_hyphen, 300), + MusicNamePattern("year_sandwich", _match_year_sandwich, 200), + MusicNamePattern("fallback", _match_fallback, -100), + ) + parsers = ( + MusicNameParser("dangling_artist", ("dangling_artist",), _parse_dangling_artist), + MusicNameParser("music_video_scene", ("music_video_scene",), _parse_music_video_scene), + MusicNameParser("album_marker", ("album_marker",), _parse_album_marker), + MusicNameParser("artist_title", ("artist_title",), _parse_artist_title), + MusicNameParser("alias_prefix", ("alias_prefix",), _parse_alias_prefix), + MusicNameParser( + "cjk_artist_title_rip", + ("cjk_artist_title_rip",), + _parse_cjk_artist_title_rip, + ), + MusicNameParser("cjk_hyphen", ("cjk_hyphen",), _parse_cjk_hyphen), + MusicNameParser("latin_hyphen", ("latin_hyphen",), _parse_latin_hyphen), + MusicNameParser("year_sandwich", ("year_sandwich",), _parse_year_sandwich), + MusicNameParser("fallback", ("fallback",), _parse_fallback), + ) + for pattern in patterns: + MusicNameRegistry.register_pattern(pattern) + for parser in parsers: + MusicNameRegistry.register_parser(parser) + + +_register_default_name_components() diff --git a/app/helper/audio.py b/app/helper/audio.py index e54cdf032..bbcda3138 100644 --- a/app/helper/audio.py +++ b/app/helper/audio.py @@ -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( diff --git a/app/locales/en-US.json b/app/locales/en-US.json index e6f949cc7..ba539bbea 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -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", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 5ce219e86..04c8e2dc2 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -126,6 +126,7 @@ "指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源", "媒体ID格式无效": "媒體ID格式無效", "MusicBrainz 只能用于音乐刮削": "MusicBrainz 只能用於音樂刮削", + "音乐元数据源只能用于音乐刮削": "音樂中繼資料來源只能用於音樂刮削", "刮削失败,无法识别音乐信息": "刮削失敗,無法識別音樂資訊", "刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊", "刮削路径不存在": "刮削路徑不存在", diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index c0e990b30..a4e7f4bb2 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -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 = ( diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index c98db9b49..877da6b52 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -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" ): diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 734b30399..f2850dbec 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -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, diff --git a/app/modules/douban/apiv2.py b/app/modules/douban/apiv2.py index 69d7019be..3ecfcee2b 100644 --- a/app/modules/douban/apiv2.py +++ b/app/modules/douban/apiv2.py @@ -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')): """ diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 907fc8a97..712642c23 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -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 diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py new file mode 100644 index 000000000..67595acf7 --- /dev/null +++ b/app/modules/theaudiodb/__init__.py @@ -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)) diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 0b7fbdf6b..c3c2299b9 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -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 diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index ffc471bf3..e113fafcf 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -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 diff --git a/app/schemas/types.py b/app/schemas/types.py index b5f7b76f6..a63fd3d5b 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -423,6 +423,8 @@ class MediaRecognizeType(Enum): AniList = "AniList" # MusicBrainz MusicBrainz = "MusicBrainz" + # TheAudioDB + TheAudioDB = "TheAudioDB" # 用户配置Key字典 diff --git a/app/utils/media.py b/app/utils/media.py index ce914e215..d55dfa959 100644 --- a/app/utils/media.py +++ b/app/utils/media.py @@ -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`` 前缀。""" diff --git a/tests/test_agent_music_tools.py b/tests/test_agent_music_tools.py index 1e649e42f..be1274a1d 100644 --- a/tests/test_agent_music_tools.py +++ b/tests/test_agent_music_tools.py @@ -15,6 +15,7 @@ from app.agent.tools.impl.query_library_exists import QueryLibraryExistsTool from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool from app.agent.tools.impl.query_subscribe_shares import QuerySubscribeSharesTool from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTool +from app.agent.tools.impl.recognize_media import RecognizeMediaTool from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool from app.agent.tools.impl.search_media import SearchMediaTool from app.core.context import ( @@ -60,6 +61,33 @@ def _album() -> MusicInfo: ) +def test_recognize_music_title_uses_media_chain_automatic_sources(): + """Agent 音乐标题识别应进入 MediaChain 自动多源流程,不再固定 MusicBrainz。""" + expected = _recording() + recognize = AsyncMock(return_value=expected) + tool = RecognizeMediaTool(session_id="session-1", user_id="10001") + + with patch( + "app.agent.tools.impl.recognize_media.MediaChain.async_recognize_by_meta", + new=recognize, + ): + result = asyncio.run( + tool.run( + title="晴天", + media_type="music", + artist="周杰伦", + album="叶惠美", + ) + ) + + payload = json.loads(result) + assert payload["media_info"]["media_source"] == "musicbrainz" + recognized_meta = recognize.await_args.args[0] + assert recognized_meta.artists == ["周杰伦"] + assert recognized_meta.album == "叶惠美" + assert "source" not in recognize.await_args.kwargs + + def test_search_media_filters_music_entities_and_returns_stable_identity(): """音乐搜索应区分单曲和专辑,并返回后续工具可复用的来源 ID。""" async_search = AsyncMock(return_value=[_recording(), _album()]) diff --git a/tests/test_audio_metadata.py b/tests/test_audio_metadata.py index 79f6e4fc4..8a0dfba2d 100644 --- a/tests/test_audio_metadata.py +++ b/tests/test_audio_metadata.py @@ -112,6 +112,49 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch): assert meta.audio_format == "MP3" +def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, monkeypatch): + """标签不可读时应直接使用完整动态模式解析复杂音乐文件名。""" + audio_path = tmp_path / ( + "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac" + ) + audio_path.write_bytes(b"fake-flac") + monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None) + + meta = AudioMetadataHelper.read(audio_path) + + assert meta.artists == ["S.H.E"] + assert meta.title == "S.H.E十七音乐会" + assert meta.year == 2018 + assert meta.audio_format == "FLAC" + + +def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields( + tmp_path, + monkeypatch, +): + """真实标签优先,缺失的艺术家和年份由完整文件名解析补齐。""" + audio_path = tmp_path / "Daft Punk - Get Lucky 2013 FLAC.flac" + audio_path.write_bytes(b"fake-flac") + audio = SimpleNamespace( + tags={"title": ["Tagged Title"]}, + info=SimpleNamespace( + length=369.4, + bitrate=1411200, + bits_per_sample=16, + sample_rate=44100, + ), + ) + monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + meta = AudioMetadataHelper.read(audio_path) + + assert meta.title == "Tagged Title" + assert meta.artists == ["Daft Punk"] + assert meta.year == 2013 + assert meta.duration == 369 + assert meta.sample_rate == 44100 + + def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch): """音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。""" class FakeAudio: diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index dcd1a6067..85ab778f3 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -515,7 +515,7 @@ def test_chain_recognize_media_reports_music_share_result(): meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003) music = _music_info() - with patch.object(chain, "run_module", return_value=music), patch( + with patch("app.chain.music.MusicChain.recognize_best", return_value=music), patch( "app.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( @@ -534,11 +534,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): meta = MetaMusic(title="晴天", artists=["周杰伦"]) music = _music_info() - with patch.object( - chain, - "run_module", - side_effect=[None, music], - ) as run_module, patch( + with patch( + "app.chain.music.MusicChain.recognize_best", + return_value=None, + ) as recognize_best, patch( + "app.chain.music.MusicChain.recognize_from_source", + return_value=music, + ) as recognize_source, patch( "app.chain.MoviePilotServerHelper.query_recognize_share", return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"}, ), patch( @@ -563,11 +565,13 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): result = chain.recognize_media(meta=meta, cache=False) assert result is music - assert run_module.call_count == 2 - second_call = run_module.call_args_list[1] - assert second_call.kwargs["mtype"] == MediaType.MUSIC - assert second_call.kwargs["source"] == "musicbrainz" - assert second_call.kwargs["mediaid"] == "recording-1" + recognize_best.assert_called_once_with(meta=meta, cache=False) + recognize_source.assert_called_once_with( + source="musicbrainz", + meta=meta, + mediaid="recording-1", + cache=False, + ) def test_chain_recognize_media_skips_music_report_for_fallback_result(): @@ -576,7 +580,7 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result(): meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) - with patch.object(chain, "run_module", return_value=fallback), patch( + with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), patch( "app.chain.MoviePilotServerHelper.recognize_report" ) as report_mock, patch( "app.chain.settings.MEDIA_RECOGNIZE_SHARE", True diff --git a/tests/test_media_scrape_endpoint.py b/tests/test_media_scrape_endpoint.py index d7bc7d1db..3f0c700c8 100644 --- a/tests/test_media_scrape_endpoint.py +++ b/tests/test_media_scrape_endpoint.py @@ -146,4 +146,28 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None: fileitem=fileitem, mediainfo=info, overwrite=True, + source="musicbrainz", + ) + + +def test_scrape_music_without_source_keeps_automatic_recognition() -> None: + """未选择音乐源时刮削入口应传递空来源,让底层比较全部识别源。""" + fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file") + media_chain = Mock() + media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") + + with patch("app.api.endpoints.media.MediaChain", return_value=media_chain): + result = scrape( + fileitem=fileitem, + storage="local", + type_name=MediaType.MUSIC, + _=Mock(), + ) + + assert result.success is True + media_chain.scrape_music_metadata.assert_called_once_with( + fileitem=fileitem, + mediainfo=None, + overwrite=True, + source=None, ) diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index 6514dd0a1..e94994964 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -1,4 +1,15 @@ -from app.core.meta import MetaMusic +from typing import Optional + +import pytest + +from app.core.meta import ( + MetaMusic, + MusicNameContext, + MusicNameParseResult, + MusicNameParser, + MusicNamePattern, + MusicNameRegistry, +) def parse_title(title: str) -> MetaMusic: @@ -6,6 +17,279 @@ def parse_title(title: str) -> MetaMusic: return MetaMusic(org_string=title, title=title, parse_title=True) +def test_music_name_registry_supports_dynamic_pattern_and_parser(): + """外部程序可独立注册命名模式和解析器,并在使用后完整注销。""" + pattern_name = "test_program" + parser_name = "test_program_parser" + + def match_program(context: MusicNameContext): + """匹配测试程序的双冒号命名。""" + if not context.text.startswith("PROGRAM::"): + return None + parts = context.text.split("::") + return parts if len(parts) == 3 else None + + def parse_program(context, matched): + """把测试程序命名解析为艺术家和标题。""" + _prefix, artist, title = matched.payload + return MusicNameParseResult( + title=title, + artists=[artist], + year=context.year, + ) + + MusicNameRegistry.register_pattern( + MusicNamePattern(pattern_name, match_program, priority=1000) + ) + MusicNameRegistry.register_parser( + MusicNameParser(parser_name, (pattern_name,), parse_program, priority=1000) + ) + try: + context = MusicNameContext( + raw="PROGRAM::周杰伦::晴天", + normalized="PROGRAM::周杰伦::晴天", + text="PROGRAM::周杰伦::晴天", + artists=(), + ) + matched = MusicNameRegistry.match_pattern(context) + parser = MusicNameRegistry.match_parser(matched) + + assert matched.pattern_name == pattern_name + assert parser.name == parser_name + + # FLAC 由公共层剔除,扩展解析器只需处理自身命名结构。 + meta = parse_title("PROGRAM::周杰伦::晴天 FLAC") + assert meta.artists == ["周杰伦"] + assert meta.title == "晴天" + assert meta.audio_format == "FLAC" + finally: + MusicNameRegistry.unregister_parser(parser_name) + MusicNameRegistry.unregister_pattern(pattern_name) + + assert parser_name not in {parser.name for parser in MusicNameRegistry.get_parsers()} + assert pattern_name not in {pattern.name for pattern in MusicNameRegistry.get_patterns()} + + +@pytest.mark.parametrize( + ("raw", "artists", "title", "year", "audio_format"), + [ + ( + "The Beatles - Vinyl Collection【2020】【CD】【FLAC分轨】", + ["The Beatles"], + "Vinyl Collection", + 2020, + "FLAC", + ), + ( + "Primeval - Forged In Earth【2026】【WEB】【FLAC分轨】(24/48bit)", + ["Primeval"], + "Forged In Earth", + 2026, + "FLAC", + ), + ( + "Professor Green - Alive Till I'm Dead 2010-FLAC 分轨-nbarock", + ["Professor Green"], + "Alive Till I'm Dead", + 2010, + "FLAC", + ), + ( + "Togenashi Togeari 5th One Man Live Moments of Sound 2025 " + "1080p BluRay x265 10bit FLAC 2.0-ADE", + [], + "Togenashi Togeari 5th One Man Live Moments of Sound", + 2025, + "FLAC", + ), + ( + "田震 - 田震 (1996) FLAC {HRS-004-2}", + ["田震"], + "田震", + 1996, + "FLAC", + ), + ( + "[2022.02.23] 中恵光城 - SELENiTE -Mitsuki Nakae Works Best Album- " + "[CD][FLAC+CUE+LOG+BK][KDSD-01049]", + ["中恵光城"], + "SELENiTE -Mitsuki Nakae Works Best Album", + None, + "FLAC", + ), + ( + "[260123] 映画「超かぐや姫!」劇中曲「超かぐや姫! 」 " + "[48kHz/24bit][FLAC]", + [], + "映画「超かぐや姫!」劇中曲「超かぐや姫! 」", + None, + "FLAC", + ), + ( + "[Audio-4U] 茶太 — Chata 1.0 (flac)", + ["茶太"], + "Chata 1.0", + None, + "FLAC", + ), + ], +) +def test_apply_title_real_site_music_samples( + raw: str, + artists: list[str], + title: str, + year: Optional[int], + audio_format: str, +): + """真实站点音乐种子标题应剔除公共干扰并保留有效命名字段。""" + meta = parse_title(raw) + + assert meta.artists == artists + assert meta.title == title + assert meta.year == year + assert meta.audio_format == audio_format + + +@pytest.mark.parametrize( + ("raw", "artists", "title", "year"), + [ + ( + "Aimer-Aimer Hall Tour 2022 ''Walpurgisnacht'' Live at " + "TOKYO GARDEN THEATER Blu-ray 1080p AVC LPCM 2.0", + [], + "Aimer-Aimer Hall Tour ''Walpurgisnacht'' Live at TOKYO GARDEN THEATER", + 2022, + ), + ( + "MANATSU NO ZENKOKU TOUR 2021 FINAL! IN TOKYO DOME " + "Blu-ray 1080p AVC LPCM 2.0", + [], + "MANATSU NO ZENKOKU TOUR FINAL! IN TOKYO DOME", + 2021, + ), + ( + "Rainie Yang - Ban Shu Xuan Yan 2008 DVD 480i MPEG-2 MPEG-2", + ["Rainie Yang"], + "Ban Shu Xuan Yan", + 2008, + ), + ( + "SARD UNDERGROUND LIVE TOUR 2025 FANTASY " + "Blu-ray 1080p AVC LPCM2.0", + [], + "SARD UNDERGROUND LIVE TOUR FANTASY", + 2025, + ), + ( + "Kylie: Tension Tour Live 2026 2160p NF WEB-DL " + "DDP 5.1 H.265-CHORTLE", + [], + "Kylie: Tension Tour Live", + 2026, + ), + ( + "Nogizaka46 2021 'Kimi ni Shikarareta' Type-A, B, C, D," + "Blu-ray 1080p AVC", + [], + "Nogizaka46 'Kimi ni Shikarareta' Type-A, B, C, D", + 2021, + ), + ( + "SBS Korea Pop Music Festival in Summer 2026 " + "1080p AAC 2.0 x264@JJL", + [], + "SBS Korea Pop Music Festival in Summer", + 2026, + ), + ( + "RTHK31 China Philharmonic Orchestra Concert Series - " + "23rd Anniversary Concert 260704 1080i HDTV H264-NGBRTHK31", + ["RTHK31 China Philharmonic Orchestra Concert Series"], + "23rd Anniversary Concert", + 2026, + ), + ], +) +def test_music_video_scene_pattern_uses_music_specific_token_parser( + raw: str, + artists: list[str], + title: str, + year: int, +): + """影视式音乐资源应按音乐 token 语义清理,且保留年份后的演出名称。""" + context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None) + matched = MusicNameRegistry.match_pattern(context) + meta = parse_title(raw) + + assert matched.pattern_name == "music_video_scene" + assert meta.artists == artists + assert meta.title == title + assert meta.year == year + + +@pytest.mark.parametrize( + "raw", + [ + "Daft Punk - Random Access Memories 2013 FLAC", + "[Audio-4U] 茶太 — Chata 1.0 (flac)", + ], +) +def test_music_video_scene_pattern_requires_combined_video_signature(raw: str): + """普通音频标题只有格式或版本数字时,不得误入音乐视频场景模式。""" + context = MetaMusic._prepare_name_context(raw=raw, artists=[], year=None) + matched = MusicNameRegistry.match_pattern(context) + + assert matched.pattern_name != "music_video_scene" + + +@pytest.mark.parametrize( + ("raw", "artists", "title", "year", "audio_format"), + [ + ( + "The Bug Club - On the Intricate Inner Workings of the System " + "2025-FLAC 分軌-Redacted", + ["The Bug Club"], + "On the Intricate Inner Workings of the System", + 2025, + "FLAC", + ), + ( + "李宇春 - 皇后与梦想 - 2006-FLAC分轨-OpenCD-九月萌", + ["李宇春"], + "皇后与梦想", + 2006, + "FLAC", + ), + ("西班牙幻想曲SACD", [], "西班牙幻想曲", None, "DSD"), + ("無字天碟 Indefinable(WAV+CUE原抓)", [], "無字天碟 Indefinable", None, "WAV"), + ("刘星-无所事事(WAV+CUE原抓)", ["刘星"], "无所事事", None, "WAV"), + ("喜多郎-古事记SACD", ["喜多郎"], "古事记", None, "DSD"), + ("巫启贤太傻(黄金版)WAV分轨原抓", [], "巫启贤太傻(黄金版)", None, "WAV"), + ( + "王若琳 - The Adult Storybook 2009 SACD", + ["王若琳"], + "The Adult Storybook", + 2009, + "DSD", + ), + ], +) +def test_common_audio_release_noise_is_removed( + raw: str, + artists: list[str], + title: str, + year: Optional[int], + audio_format: str, +): + """真实音频发布尾链只提供格式和年份,不应污染艺术家或标题。""" + meta = parse_title(raw) + + assert meta.artists == artists + assert meta.title == title + assert meta.year == year + assert meta.audio_format == audio_format + + def test_strip_track_prefix_handles_dot_separator(): """曲序前缀 01. 应剥离并返回曲名。""" track, disc, title = MetaMusic.split_track_prefix("01.晴天") @@ -135,6 +419,48 @@ def test_apply_path_context_keeps_existing_tags(tmp_path): assert meta.year == 2004 +def test_apply_path_context_uses_full_dynamic_filename_parser(tmp_path): + """无标签文件名应进入完整动态模式,清理音乐视频场景规格。""" + audio_file = tmp_path / ( + "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac" + ) + audio_file.write_bytes(b"fake-flac") + meta = MetaMusic( + org_string=audio_file.name, + title=audio_file.stem, + audio_format="FLAC", + ) + + meta.apply_path_context(audio_file) + + assert meta.artists == ["S.H.E"] + assert meta.title == "S.H.E十七音乐会" + assert meta.year == 2018 + assert meta.audio_format == "FLAC" + + +def test_apply_path_context_only_fills_missing_tag_fields(tmp_path): + """部分标签存在时保留标签值,只从完整文件名解析补充空字段。""" + audio_file = tmp_path / "周杰伦 - 文件名曲目 2018 FLAC.flac" + audio_file.write_bytes(b"fake-flac") + meta = MetaMusic( + org_string=audio_file.name, + title="标签曲名", + artists=[], + album="标签专辑", + year=2020, + ) + + meta.apply_path_context(audio_file) + + assert meta.title == "标签曲名" + assert meta.artists == ["周杰伦"] + assert meta.album == "标签专辑" + assert meta.year == 2020 + assert meta.audio_format == "FLAC" + assert meta.audio_lossless is True + + def test_apply_title_splits_artist_and_track(): """标准「歌手 - 曲名」种子标题应拆分艺术家与曲名。""" meta = parse_title("周杰伦 - 晴天") @@ -180,6 +506,10 @@ def test_apply_title_splits_latin_hyphen_artist_album(): assert meta.year == 1971 # 左侧单词(Heize-Undo)与右侧发布组标签不触发拆分 assert parse_title("Heize-Undo.2022.FLAC").artists == [] + # 全大写复合词属于艺术家名本身,不能从 KUNG-FU 中间拆开。 + compound = parse_title("ASIAN KUNG-FU GENERATION Discography (2003-2026) [FLAC]") + assert compound.artists == [] + assert compound.title == "ASIAN KUNG-FU GENERATION Discography (2003-2026)" def test_apply_title_splits_various_artists_prefix(): @@ -348,6 +678,19 @@ def test_apply_title_album_marker(): assert meta.year == 2006 +def test_apply_title_bilingual_album_marker_prefix(): + """双语原声命名应保留英文艺术家/标题,不把整段前缀当成艺术家。""" + meta = parse_title( + "Max Richter - Ad Astra Original Motion Picture Soundtrack " + "马克斯·里希特 - 《星际探索》电影原声带 2019 FLAC-SeedPool" + ) + + assert meta.artists == ["Max Richter"] + assert meta.title == "Ad Astra Original Motion Picture Soundtrack" + assert meta.album == "星际探索" + assert meta.year == 2019 + + def test_apply_title_strips_cue_and_plus(): """APE+CUE 类格式联合写法应剔除,残留加号不阻断标题提取。""" meta = parse_title("世界著名古典大师名版收藏(15)RCA发烧古典系列-2007-FLAC-APE+CUE") @@ -365,6 +708,17 @@ def test_apply_title_cjk_hyphen_artist_suffix(): assert meta.title == "因为有你" +def test_apply_title_does_not_split_ascii_hyphen_inside_cjk_title(): + """CJK 标题中的 A-on 等拉丁复合词不能生成虚假的艺术家。""" + meta = parse_title( + "[250226] 重戦機エルガイム A-on STORE 連動特典" + "「重戦機エルガイム(カセット版復刻CD)」 [FLAC+CUE]" + ) + + assert meta.artists == [] + assert meta.title.startswith("重戦機エルガイム A-on STORE") + + def test_apply_title_double_em_dash_split(): """双破折号分隔的「主题——歌手」写法应拆分艺术家。""" meta = parse_title("为你盛开——许巍 无尽光芒巡回演唱会 2025") @@ -455,3 +809,11 @@ def test_apply_title_collection_with_space_sample_rate(): assert meta.title is None assert meta.year == 2022 assert meta.audio_format == "FLAC" + + +@pytest.mark.parametrize("title", ["孙楠 - 楠得精选 2001", "[合集] 缘之空音乐合集 [FLAC]"]) +def test_apply_title_keeps_collection_words_inside_work_name(title: str): + """合集/精选嵌在作品名中时是有效文字,只清理独立发行标签。""" + meta = parse_title(title) + + assert "精选" in (meta.title or "") or "合集" in (meta.title or "") diff --git a/tests/test_music_chain.py b/tests/test_music_chain.py index fcd7c4677..746d4764c 100644 --- a/tests/test_music_chain.py +++ b/tests/test_music_chain.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from app.chain.media import MediaChain from app.chain.music import MusicChain @@ -6,6 +6,7 @@ from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.core.meta import MetaMusic from app.helper.audio import AudioMetadataHelper from app.modules.musicbrainz import MusicBrainzModule +from app.schemas.types import MediaType def test_parse_query_supports_artist_title_format(): @@ -321,7 +322,167 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch): assert recognized_meta is meta assert recognized_info is info - recognize.assert_awaited_once_with(meta=meta, source="musicbrainz") + recognize.assert_awaited_once_with(meta=meta, source=None) + + +def test_recognize_best_compares_all_sources_and_prefers_stronger_evidence(monkeypatch): + """自动识别应查询全部来源,并让专辑、时长和曲序证据更完整的候选胜出。""" + chain = MusicChain() + meta = MetaMusic( + title="Yellow", + artists=["Coldplay"], + album="Parachutes", + duration=269, + track_number=5, + ) + candidates = { + "musicbrainz": MusicInfo( + source="musicbrainz", + media_id="mb-1", + title="Yellow", + artists=["Coldplay"], + album="Greatest Hits", + duration=240, + track_number=1, + ), + "theaudiodb": MusicInfo( + source="theaudiodb", + media_id="adb-1", + title="Yellow", + artists=["Coldplay"], + album="Parachutes", + duration=269, + track_number=5, + ), + "doubanmusic": MusicInfo( + source="doubanmusic", + media_id="db-1:5", + title="Yellow", + artists=["Coldplay"], + album="Parachutes", + ), + } + requested = [] + + def fake_recognize_source(_meta, source, _cache): + """按来源返回候选并记录实际查询顺序。""" + requested.append(source) + return candidates[source] + + monkeypatch.setattr(chain, "_recognize_from_source", fake_recognize_source) + + result = chain.recognize_best(meta) + + assert requested == ["musicbrainz", "theaudiodb", "doubanmusic"] + assert result is candidates["theaudiodb"] + + +def test_recognize_from_source_selects_only_declared_music_module(monkeypatch): + """单源识别只允许调用声明该音乐来源的模块,忽略同接口影视模块。""" + chain = MusicChain() + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + expected = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + ) + video_module = Mock(spec=["recognize_media"]) + music_module = Mock(spec=["get_music_source", "recognize_media"]) + music_module.get_music_source.return_value = "musicbrainz" + music_module.recognize_media.return_value = expected + monkeypatch.setattr( + chain.modulemanager, + "get_running_modules", + Mock(return_value=[video_module, music_module]), + ) + + result = chain.recognize_from_source( + source="musicbrainz", + meta=meta, + cache=True, + ) + + assert result is expected + video_module.recognize_media.assert_not_called() + music_module.recognize_media.assert_called_once_with( + meta=meta, + mtype=MediaType.MUSIC, + source="musicbrainz", + mediaid=None, + cache=True, + ) + + +def test_recognize_best_uses_source_order_only_for_equal_scores(monkeypatch): + """候选证据完全相同时应按 MusicBrainz、TheAudioDB、豆瓣音乐顺序稳定选择。""" + chain = MusicChain() + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + candidates = { + source: MusicInfo( + source=source, + media_id=f"{source}-1", + title="晴天", + artists=["周杰伦"], + ) + for source in ("musicbrainz", "theaudiodb", "doubanmusic") + } + monkeypatch.setattr( + chain, + "_recognize_from_source", + lambda _meta, source, _cache: candidates[source], + ) + + assert chain.recognize_best(meta) is candidates["musicbrainz"] + + +def test_async_recognize_best_queries_sources_concurrently(monkeypatch): + """异步自动识别应并发查询各来源,而不是串行等待三个远端请求。""" + import asyncio + + chain = MusicChain() + active = 0 + max_active = 0 + + async def fake_async_recognize_source(_meta, source, _cache): + """记录同时执行的来源数并返回同分候选。""" + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.01) + active -= 1 + return MusicInfo( + source=source, + media_id=f"{source}-1", + title="晴天", + artists=["周杰伦"], + ) + + monkeypatch.setattr( + chain, + "_async_recognize_from_source", + fake_async_recognize_source, + ) + + result = asyncio.run( + chain.async_recognize_best(MetaMusic(title="晴天", artists=["周杰伦"])) + ) + + assert max_active == 3 + assert result and result.source == "musicbrainz" + + +def test_recognize_candidate_rejects_wrong_artist_even_when_title_matches(): + """已知艺术家时,同名异人的候选不能仅凭曲名命中。""" + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + candidate = MusicInfo( + source="theaudiodb", + media_id="wrong-1", + title="晴天", + artists=["其他歌手"], + ) + + assert MusicChain._recognize_candidate_score(meta, candidate) is None def test_async_chart_forwards_album_entity(monkeypatch): diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index dd3b3aa74..7c097e693 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -80,6 +80,33 @@ def test_media_search_routes_music_queries_with_query_kwarg(): media_chain.return_value.async_search.assert_not_called() +def test_media_search_forwards_explicit_music_source(): + """统一音乐搜索应把显式选择的可扩展音乐源转发给 MusicChain。""" + chain = Mock() + chain.async_search = AsyncMock(return_value=[]) + + with ( + patch("app.api.endpoints.media.MusicChain", return_value=chain), + patch.object(media_endpoints, "MediaChain"), + ): + result = asyncio.run( + media_endpoints.search( + title="Coldplay", + type="music", + count=20, + source="theaudiodb", + _=Mock(), + ) + ) + + assert result == [] + chain.async_search.assert_awaited_once_with( + query="Coldplay", + limit=20, + source="theaudiodb", + ) + + def test_recognize_music_returns_detail(): """音乐识别接口应按来源和 ID 经统一识别入口返回详情。""" from app.chain.media import MediaChain diff --git a/tests/test_music_metadata_sources.py b/tests/test_music_metadata_sources.py new file mode 100644 index 000000000..15933f947 --- /dev/null +++ b/tests/test_music_metadata_sources.py @@ -0,0 +1,278 @@ +"""TheAudioDB 与豆瓣音乐识别源的标准化和路由测试。""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.chain.media import MediaChain +from app.chain.music import MusicChain +from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo +from app.core.meta import MetaMusic +from app.modules.douban import DoubanModule +from app.modules.theaudiodb import TheAudioDbModule +from app.schemas.types import MediaRecognizeType, MediaType + + +def test_theaudiodb_module_maps_track_and_album(monkeypatch): + """TheAudioDB 原生响应应保留来源 ID,并换算毫秒时长。""" + module = TheAudioDbModule() + request = Mock(side_effect=[ + { + "track": [{ + "idTrack": "32793500", + "strTrack": "Yellow", + "strArtist": "Coldplay", + "idArtist": "111239", + "idAlbum": "2109619", + "strAlbum": "Parachutes", + "intTrackNumber": "5", + "intDuration": "269000", + "strTrackThumb": "https://www.theaudiodb.com/images/track.jpg", + }] + }, + {"album": []}, + {"artists": []}, + ]) + monkeypatch.setattr(module, "_request_json", request) + + results = module.search_music( + MetaMusic(title="Yellow", artists=["Coldplay"]), + source="theaudiodb", + ) + + assert results and len(results) == 1 + assert results[0].source == "theaudiodb" + assert results[0].media_id == "32793500" + assert results[0].album_id == "2109619" + assert results[0].duration == 269 + assert module.get_subtype() == MediaRecognizeType.TheAudioDB + + +def test_theaudiodb_module_ignores_other_sources(monkeypatch): + """显式选择其它来源时 TheAudioDB 不得发起请求或占用识别结果。""" + module = TheAudioDbModule() + request = Mock() + monkeypatch.setattr(module, "_request_json", request) + + searched = module.search_music(MetaMusic(title="Yellow"), source="musicbrainz") + recognized = module.recognize_media( + meta=MetaMusic(title="Yellow"), + source="doubanmusic", + ) + + assert searched is None + assert recognized is None + request.assert_not_called() + + +def test_douban_music_search_and_album_mapping(monkeypatch): + """豆瓣模块应把音乐条目映射为专辑,并生成可用于曲目识别的复合 ID。""" + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.music_search.return_value = { + "items": [{ + "target_type": "music", + "target": { + "id": "1401853", + "title": "范特西", + "artists": [{"name": "周杰伦"}], + "year": "2001", + }, + }] + } + module.doubanapi.music_detail.return_value = { + "id": "1401853", + "title": "范特西", + "singer": [{"id": "1050015", "name": "周杰伦"}], + "pubdate": ["2001-09-14"], + "media": ["CD"], + "publisher": ["阿尔发音乐"], + "songs": [ + { + "title": "爱在西元前", + "track_number": 1, + "artist_names": ["周杰伦"], + "duration": 221, + }, + { + "title": "爸我回来了", + "track_number": 2, + "artist_names": ["周杰伦"], + "cover_url": "https://img.example/track.jpg", + }, + ], + "rating": {"average": "9.4", "numRaters": "12345"}, + } + + results = module.search_music( + MetaMusic(title="范特西", artists=["周杰伦"]), + source="doubanmusic", + ) + album = module.music_album("doubanmusic", "1401853") + + assert results and results[0].source == "doubanmusic" + assert results[0].music_type == MUSIC_ENTITY_ALBUM + assert album and album.source == "doubanmusic" + assert album.year == 2001 + assert album.artists == ["周杰伦"] + assert album.album_type == "CD" + assert [track.media_id for track in album.tracks] == ["1401853:1", "1401853:2"] + assert album.tracks[0].title == "爱在西元前" + assert album.tracks[0].duration == 221 + assert album.tracks[1].cover_url == "https://img.example/track.jpg" + + +def test_douban_music_recognize_expands_album_to_matching_track(monkeypatch): + """自动文件识别有专辑线索时,豆瓣应返回专辑内音轨而不是专辑实体。""" + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.music_search.return_value = { + "items": [{ + "target_type": "music", + "target": { + "id": "1401853", + "title": "范特西", + "artists": [{"name": "周杰伦"}], + }, + }] + } + module.doubanapi.music_detail.return_value = { + "id": "1401853", + "title": "范特西", + "singer": [{"name": "周杰伦"}], + "songs": [ + {"title": "爱在西元前", "track_number": 1}, + {"title": "爸我回来了", "track_number": 2}, + ], + } + + result = module.recognize_media( + meta=MetaMusic( + title="爸我回来了", + artists=["周杰伦"], + album="范特西", + track_number=2, + ), + source="doubanmusic", + ) + + assert result and result.music_type == "recording" + assert result.media_id == "1401853:2" + assert result.album == "范特西" + + +def test_douban_music_mapping_keeps_legacy_attrs_tracks(): + """豆瓣旧响应中的 attrs.singer 与 attrs.tracks 仍应保持兼容。""" + album = DoubanModule._douban_music_to_album({ + "id": "1401853", + "title": "范特西", + "attrs": { + "singer": ["周杰伦"], + "tracks": ["01. 爱在西元前", "02. 爸我回来了"], + }, + }) + + assert album and album.artists == ["周杰伦"] + assert [track.title for track in album.tracks] == ["爱在西元前", "爸我回来了"] + + +@pytest.mark.asyncio +async def test_douban_music_async_recognize_maps_real_songs(monkeypatch): + """异步豆瓣自动识别应从真实 songs 字段返回具体音轨。""" + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.async_music_search = AsyncMock(return_value={ + "items": [{ + "target_type": "music", + "target": {"id": "1401853", "title": "范特西"}, + }], + }) + module.doubanapi.async_music_detail = AsyncMock(return_value={ + "id": "1401853", + "title": "范特西", + "singer": [{"name": "周杰伦"}], + "songs": [ + {"title": "爱在西元前", "track_number": 1}, + {"title": "爸我回来了", "track_number": 2}, + ], + }) + + result = await module.async_recognize_media( + meta=MetaMusic( + title="爱在西元前", + artists=["周杰伦"], + album="范特西", + ), + source="doubanmusic", + ) + + assert result and result.media_id == "1401853:1" + assert result.title == "爱在西元前" + assert result.artists == ["周杰伦"] + + +@pytest.mark.asyncio +async def test_douban_recognize_media_routes_only_douban_music(monkeypatch): + """豆瓣音乐使用独立 source,不能与影视豆瓣入口或其它音乐源串线。""" + module = DoubanModule() + expected = MusicInfo(source="doubanmusic", media_id="1401853", title="范特西") + recognize_music = Mock(return_value=expected) + recognize_video = Mock() + async_recognize_music = AsyncMock(return_value=expected) + monkeypatch.setattr(module, "_recognize_music_media", recognize_music) + monkeypatch.setattr(module, "_recognize_media_core", recognize_video) + monkeypatch.setattr(module, "_async_recognize_music_media", async_recognize_music) + + recognized = module.recognize_media( + meta=MetaMusic(title="范特西"), + source="doubanmusic", + mediaid="1401853", + ) + ignored = module.recognize_media( + meta=MetaMusic(title="范特西"), + source="theaudiodb", + ) + async_recognized = await module.async_recognize_media( + mtype=MediaType.MUSIC, + source="doubanmusic", + mediaid="1401853", + ) + + assert recognized is expected + assert ignored is None + assert async_recognized is expected + recognize_video.assert_not_called() + + +@pytest.mark.asyncio +async def test_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch): + """音乐搜索默认 MusicBrainz,手动选择时原样转发其它音乐源。""" + chain = MusicChain() + run_module = Mock(return_value=[]) + async_run_module = AsyncMock(return_value=[]) + monkeypatch.setattr(chain, "run_module", run_module) + monkeypatch.setattr(chain, "async_run_module", async_run_module) + + chain.search("Yellow") + await chain.async_search("范特西", source="doubanmusic") + + assert run_module.call_args.kwargs["source"] == "musicbrainz" + assert async_run_module.await_args.kwargs["source"] == "doubanmusic" + + +def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch): + """无显式 ID 的音乐刮削应使用用户选择的来源识别本地音频。""" + path = tmp_path / "Yellow.flac" + path.write_bytes(b"audio") + expected = MusicInfo(source="theaudiodb", media_id="32793500", title="Yellow") + recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected)) + monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize) + + result = MediaChain._resolve_music_scrape_info( + path, + mediainfo=None, + source="theaudiodb", + ) + + assert result is expected + recognize.assert_called_once_with(path, source="theaudiodb") diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index a68309937..8690bfdc7 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -292,7 +292,7 @@ def test_chain_recognize_media_music_plugin_supplement(): ) event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()}) - with patch.object(chain, "run_module", return_value=fallback), \ + with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), \ patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event), \ patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 531bd64a7..dff40cb20 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -8,11 +8,15 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch +from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.music import MusicChain from app.core.context import MusicInfo from app.core.meta import MetaMusic +from app.modules.anilist import AniListModule +from app.modules.bangumi import BangumiModule from app.modules.musicbrainz import MusicBrainzModule +from app.modules.themoviedb import TheMovieDbModule from app.schemas.types import MediaType @@ -35,12 +39,15 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch): chain = MediaChain() monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected)) - result = chain.recognize_by_meta(meta, source="musicbrainz") + result = chain.recognize_by_meta( + meta, source="musicbrainz", mtype=MediaType.MUSIC + ) # 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数 chain.recognize_media.assert_called_once() call_kwargs = chain.recognize_media.call_args.kwargs assert call_kwargs["meta"] is meta + assert call_kwargs["mtype"] == MediaType.MUSIC assert call_kwargs["source"] == "musicbrainz" assert result is expected @@ -53,12 +60,15 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa monkeypatch.setattr(chain, "async_recognize_media", AsyncMock(return_value=expected)) async def runner(): - return await chain.async_recognize_by_meta(meta, source="musicbrainz") + return await chain.async_recognize_by_meta( + meta, source="musicbrainz", mtype=MediaType.MUSIC + ) result = asyncio.run(runner()) chain.async_recognize_media.assert_awaited_once() call_kwargs = chain.async_recognize_media.await_args.kwargs assert call_kwargs["meta"] is meta + assert call_kwargs["mtype"] == MediaType.MUSIC assert call_kwargs["source"] == "musicbrainz" assert result is expected @@ -119,7 +129,7 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa assert recognized_meta is meta assert recognized_info is info - recognize.assert_awaited_once_with(meta=meta, source="musicbrainz") + recognize.assert_awaited_once_with(meta=meta, source=None) def test_musicbrainz_module_recognize_media_ignores_non_music(): @@ -130,6 +140,133 @@ def test_musicbrainz_module_recognize_media_ignores_non_music(): assert result is None +def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatch): + """显式音乐类型和来源应只调用对应音乐模块,不遍历通用影视模块。""" + expected = _music_info() + chain = ChainBase() + recognize_source = Mock(return_value=expected) + generic_dispatch = Mock() + monkeypatch.setattr(MusicChain, "recognize_from_source", recognize_source) + monkeypatch.setattr(chain, "run_module", generic_dispatch) + monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False)) + + with patch( + "app.helper.server.MoviePilotServerHelper.report_recognize_share" + ): + result = chain.recognize_media( + mtype=MediaType.MUSIC, + source="musicbrainz", + mediaid="recording-1", + ) + + assert result is expected + recognize_source.assert_called_once_with( + source="musicbrainz", + meta=None, + mediaid="recording-1", + cache=True, + ) + generic_dispatch.assert_not_called() + + +def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatch): + """音乐状态即使携带错误影视来源,也不得调用 TMDB 等通用识别模块。""" + chain = ChainBase() + generic_dispatch = Mock() + async_generic_dispatch = AsyncMock() + monkeypatch.setattr(chain, "run_module", generic_dispatch) + monkeypatch.setattr(chain, "async_run_module", async_generic_dispatch) + monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False)) + + sync_result = chain.recognize_media( + mtype=MediaType.MUSIC, + source="themoviedb", + mediaid="123", + ) + async_result = asyncio.run(chain.async_recognize_media( + mtype=MediaType.MUSIC, + source="themoviedb", + mediaid="123", + )) + + assert sync_result is None + assert async_result is None + generic_dispatch.assert_not_called() + async_generic_dispatch.assert_not_awaited() + + +def test_themoviedb_module_recognize_media_ignores_music(monkeypatch): + """音乐模块未响应时 TMDB 的同步和异步入口均不得接管音乐请求。""" + module = TheMovieDbModule() + tmdb = Mock() + monkeypatch.setattr(module, "tmdb", tmdb) + + by_meta = module.recognize_media(meta=MetaMusic(title="晴天")) + by_type = module.recognize_media(mtype=MediaType.MUSIC, tmdbid=123) + async_result = asyncio.run( + module.async_recognize_media(meta=MetaMusic(title="晴天")) + ) + + assert by_meta is None + assert by_type is None + assert async_result is None + assert tmdb.mock_calls == [] + + +def test_bangumi_module_recognize_media_ignores_music(monkeypatch): + """Bangumi 的同步和异步入口不得把音乐请求识别为动画影视。""" + module = BangumiModule() + api = Mock() + monkeypatch.setattr(module, "bangumiapi", api) + + by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="bangumi") + by_type = module.recognize_media(mtype=MediaType.MUSIC, bangumiid=123) + async_result = asyncio.run( + module.async_recognize_media(meta=MetaMusic(title="晴天"), source="bangumi") + ) + + assert by_meta is None + assert by_type is None + assert async_result is None + assert api.mock_calls == [] + + +def test_anilist_module_recognize_media_ignores_music(monkeypatch): + """AniList 的同步和异步入口不得把音乐请求识别为动画影视。""" + module = AniListModule() + api = Mock() + monkeypatch.setattr(module, "anilist_api", api) + + by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="anilist") + by_type = module.recognize_media(mtype=MediaType.MUSIC, anilistid=123) + async_result = asyncio.run( + module.async_recognize_media(meta=MetaMusic(title="晴天"), source="anilist") + ) + + assert by_meta is None + assert by_type is None + assert async_result is None + assert api.mock_calls == [] + + +def test_chain_obtain_images_skips_music_modules(monkeypatch): + """音乐封面来自音乐元数据链,同步和异步补图入口均不得调用影视模块。""" + chain = MediaChain() + run_module = Mock() + async_run_module = AsyncMock() + monkeypatch.setattr(chain, "run_module", run_module) + monkeypatch.setattr(chain, "async_run_module", async_run_module) + music = _music_info() + + result = chain.obtain_images(music) + async_result = asyncio.run(chain.async_obtain_images(music)) + + assert result is music + assert async_result is music + run_module.assert_not_called() + async_run_module.assert_not_awaited() + + def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(monkeypatch): """meta 携带 source+media_id 时应走详情分支,不再触发搜索。""" module = MusicBrainzModule() @@ -202,12 +339,11 @@ def test_musicbrainz_module_async_recognize_media(monkeypatch): def test_chain_recognize_media_returns_musicinfo_and_reports_share(): - """ChainBase.recognize_media 收到 MusicInfo 结果应与影视统一上报共享识别。""" + """自动多源识别最终选出的 MusicInfo 应只上报一次共享识别。""" expected = _music_info() chain = MediaChain() - chain.run_module = Mock(return_value=expected) - with patch( - "app.helper.server.MoviePilotServerHelper.report_recognize_share" + with patch.object(MusicChain, "recognize_best", return_value=expected), patch( + "app.helper.server.MoviePilotServerHelper.report_recognize_share" ) as report_mock: result = chain.recognize_media(meta=MetaMusic(title="晴天")) @@ -217,11 +353,14 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share(): def test_chain_async_recognize_media_returns_musicinfo_and_reports_share(): - """异步 ChainBase 收到 MusicInfo 结果应与影视统一上报共享识别。""" + """异步多源识别最终选出的 MusicInfo 应只上报一次共享识别。""" expected = _music_info() chain = MediaChain() - chain.async_run_module = AsyncMock(return_value=expected) - with patch( + with patch.object( + MusicChain, + "async_recognize_best", + AsyncMock(return_value=expected), + ), patch( "app.helper.server.MoviePilotServerHelper.async_report_recognize_share", AsyncMock(), ) as report_mock: diff --git a/tests/test_music_scrape.py b/tests/test_music_scrape.py index 0dbd88b41..9ca35cdf6 100644 --- a/tests/test_music_scrape.py +++ b/tests/test_music_scrape.py @@ -132,7 +132,7 @@ def test_recording_identity_rejects_multi_track_directory_scrape() -> None: ) assert success is False - assert message == "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑" + assert message == "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑" def test_default_scraping_config_enables_missing_only_music_lyrics() -> None: diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 7a960ee8c..76ec0dd39 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -1,8 +1,10 @@ from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock from jinja2 import Template +from app.chain.media import MediaChain from app.chain.music import MusicChain from app.chain.transfer import JobManager, TransferChain from app.core.config import settings @@ -10,7 +12,8 @@ from app.core.meta import MetaMusic from app.core.context import MusicInfo from app.helper.message import TemplateHelper from app.schemas.file import FileItem -from app.schemas.transfer import TransferTask +from app.schemas.system import TransferDirectoryConf +from app.schemas.transfer import TransferInfo, TransferTask, TransferTorrent from app.schemas.types import MediaType @@ -277,6 +280,7 @@ def test_job_manager_serializes_music_queue_models(): ), meta=meta, mediainfo=info, + mtype=MediaType.MUSIC, ) manager = JobManager() @@ -285,3 +289,154 @@ def test_job_manager_serializes_music_queue_models(): assert job.media.type == "音乐" assert job.media.album == "Random Access Memories" assert job.tasks[0].meta.type == "音乐" + assert task.mtype == MediaType.MUSIC + + +def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch): + """无下载身份的音频应先走音乐识别,远端失败后再使用本地标签兜底。""" + audio_path = tmp_path / "周杰伦 - 晴天.flac" + audio_path.write_bytes(b"fake-flac") + source_item = FileItem( + storage="local", + path=audio_path.as_posix(), + name=audio_path.name, + basename=audio_path.stem, + type="file", + extension="flac", + size=audio_path.stat().st_size, + ) + target_item = FileItem( + storage="local", + path=(tmp_path / "library" / audio_path.name).as_posix(), + name=audio_path.name, + basename=audio_path.stem, + type="file", + extension="flac", + ) + recognized = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + ) + recognize = Mock(return_value=recognized) + chain = TransferChain() + monkeypatch.setattr(MediaChain, "recognize_by_meta", recognize) + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + Mock(side_effect=lambda *args, **kwargs: [(source_item, False)]), + ) + monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None)) + monkeypatch.setattr( + chain, + "transfer", + Mock( + return_value=TransferInfo( + success=True, + fileitem=source_item, + target_item=target_item, + ) + ), + ) + + state, preview = chain.do_transfer( + fileitem=source_item, + target_directory=TransferDirectoryConf( + library_path=(tmp_path / "library").as_posix(), + library_storage="local", + ), + mtype=MediaType.MUSIC, + force=True, + preview=True, + ) + + assert state is True + recognize.assert_called_once() + assert isinstance(recognize.call_args.args[0], MetaMusic) + assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC + assert preview["items"][0]["type"] == MediaType.MUSIC.value + + recognize.reset_mock() + recognize.return_value = None + state, preview = chain.do_transfer( + fileitem=source_item, + target_directory=TransferDirectoryConf( + library_path=(tmp_path / "library").as_posix(), + library_storage="local", + ), + mtype=MediaType.MUSIC, + force=True, + preview=True, + ) + + assert state is True + recognize.assert_called_once() + assert isinstance(recognize.call_args.args[0], MetaMusic) + assert recognize.call_args.kwargs["mtype"] == MediaType.MUSIC + assert preview["items"][0]["type"] == MediaType.MUSIC.value + + +def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch): + """下载器自动整理应把下载历史中的音乐类型传入文件规划,且不调用影视补图模块。""" + audio_path = tmp_path / "晴天.flac" + audio_path.write_bytes(b"fake-flac") + recognized = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + ) + history = SimpleNamespace( + type=MediaType.MUSIC.value, + tmdbid=None, + doubanid=None, + bangumiid=None, + anilistid=None, + media_source="musicbrainz", + media_id="recording-1", + episode_group=None, + media_category=None, + ) + chain = TransferChain() + run_module = Mock() + monkeypatch.setattr( + "app.chain.transfer.DirectoryHelper.get_download_dirs", + lambda _: [ + SimpleNamespace( + monitor_type="downloader", + storage="local", + download_path=tmp_path.as_posix(), + ) + ], + ) + monkeypatch.setattr( + "app.chain.transfer.DownloadHistoryOper.get_by_hash", + lambda _, download_hash: history, + ) + monkeypatch.setattr( + chain, + "list_torrents", + Mock( + return_value=[ + TransferTorrent( + downloader="qbittorrent", + hash="hash-1", + path=audio_path, + ) + ] + ), + ) + monkeypatch.setattr(chain, "recognize_media", Mock(return_value=recognized)) + monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, ""))) + monkeypatch.setattr(chain, "run_module", run_module) + + state = chain.process() + + assert state is True + assert chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC + assert chain.do_transfer.call_args.kwargs["mtype"] == MediaType.MUSIC + assert chain.do_transfer.call_args.kwargs["mediainfo"] is recognized + run_module.assert_not_called()