diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index 6d220414a..1591b18c2 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -119,9 +119,15 @@ class RecognizeMediaTool(MoviePilotTool): ), "path": path, }, ensure_ascii=False) - metainfo, mediainfo = await music_chain.async_recognize_by_path(path) - context = Context(meta_info=metainfo, media_info=mediainfo) - return self._format_context_result(context, "音频文件") + # 影视与音乐共用统一路径识别入口,音频后缀自动路由到音乐识别链 + context = await MediaChain().async_recognize_by_path(path) + if context: + return self._format_context_result(context, "音频文件") + return json.dumps({ + "success": False, + "message": f"无法识别音乐信息: {path}", + "path": path, + }, ensure_ascii=False) if title: metainfo = music_chain.parse_query(title) if artist: diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 27e81fc23..fbdc22419 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -162,14 +162,8 @@ async def recognize_file( _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ - 根据文件路径识别媒体信息 + 根据文件路径识别媒体信息,影视与音乐统一走媒体链路径识别入口 """ - if MusicChain.is_audio_path(path) or source == "musicbrainz": - meta_info, media_info = await MusicChain().async_recognize_by_path( - path=path, - source=source or "musicbrainz", - ) - return Context(meta_info=meta_info, media_info=media_info).to_dict() # 识别媒体信息 context = await MediaChain().async_recognize_by_path(path, source=source) if context: @@ -290,7 +284,7 @@ def scrape( is_music = ( type_name == MediaType.MUSIC or media_source == "musicbrainz" - or MusicChain.is_audio_path(fileitem.path) + or MediaChain.is_audio_path(fileitem.path) ) if is_music: if type_name not in (None, MediaType.MUSIC): diff --git a/app/chain/media.py b/app/chain/media.py index 111ba97e0..7c2145c06 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -7,6 +7,8 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory from threading import Lock from typing import TYPE_CHECKING, List, Optional, Tuple, Union +from fastapi.concurrency import run_in_threadpool + from app import schemas from app.chain import ChainBase from app.chain.storage import StorageChain @@ -997,6 +999,117 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): name = None return name, artist, album, year + @classmethod + def is_audio_path(cls, path: Union[str, Path]) -> bool: + """判断路径是否指向系统支持的音频文件。""" + return Path(path).suffix.lower() in settings.RMT_AUDIOEXT + + @classmethod + def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic: + """读取本地音频标签,标签缺失时用文件名和目录线索补齐。""" + 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 + ) + # WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索 + return meta.apply_path_context(file_path) + + @classmethod + def _music_info_from_path_meta(cls, meta: MetaMusic) -> MusicInfo: + """把音频标签转换为文件管理可展示的最小音乐信息。""" + return MusicInfo( + source=meta.media_source, + media_id=meta.media_id, + title=meta.title, + artists=list(meta.artists), + album=meta.album, + album_artist=meta.album_artist, + year=meta.year, + disc_number=meta.disc_number, + track_number=meta.track_number, + total_tracks=meta.total_tracks, + duration=meta.duration, + isrc=meta.isrc, + version=meta.version, + audio_format=meta.audio_format, + audio_lossless=meta.audio_lossless, + bit_depth=meta.bit_depth, + sample_rate=meta.sample_rate, + bitrate=meta.bitrate, + names=[name for name in (meta.title, meta.album) if name], + ) + + @staticmethod + def _merge_music_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo: + """将本地文件的实际音频参数合并到远端音乐身份识别结果。""" + for key in ("audio_format", "audio_lossless", "bit_depth", "sample_rate", "bitrate"): + value = getattr(meta, key, None) + if value is not None: + setattr(info, key, value) + return info + + @staticmethod + def _music_album_dir_fallback(path: Union[str, Path]) -> Optional[MusicInfo]: + """单曲识别无远端身份时,查找所在目录专辑匹配中属于当前文件的结果。""" + file_path = Path(path) + if not file_path.exists() or not file_path.is_file(): + return None + try: + # 目录级专辑匹配属于音乐域能力,延迟导入避免双向依赖。 + from app.chain.music import MusicChain + + matched = MusicChain().recognize_album_directory(file_path.parent) + except Exception as err: + logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}") + return None + return matched.get(str(file_path.resolve())) + + def recognize_music_by_path( + self, + path: Union[str, Path], + source: str = "musicbrainz", + ) -> Tuple[MetaMusic, MusicInfo]: + """同步根据音频标签和文件名识别音乐,并保留离线最小结果。""" + meta = self.read_path_meta(path) + # 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底 + info = self.recognize_media(meta=meta, source=source) + result = self._merge_music_audio_quality( + info or self._music_info_from_path_meta(meta), meta + ) + if not result.source: + # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 + matched = self._music_album_dir_fallback(path) + if matched: + result = self._merge_music_audio_quality(matched, meta) + return meta, result + + async def async_recognize_music_by_path( + self, + path: Union[str, Path], + source: str = "musicbrainz", + ) -> Tuple[MetaMusic, MusicInfo]: + """根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。""" + # Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。 + meta = await run_in_threadpool(self.read_path_meta, path) + # 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底 + info = await self.async_recognize_media(meta=meta, source=source) + result = self._merge_music_audio_quality( + info or self._music_info_from_path_meta(meta), meta + ) + if not result.source: + # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 + matched = await run_in_threadpool(self._music_album_dir_fallback, path) + if matched: + result = self._merge_music_audio_quality(matched, meta) + return meta, result + + def _is_music_path_request(self, path: str, source: Optional[str]) -> bool: + """路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。""" + return self.is_audio_path(path) or source == "musicbrainz" + def recognize_by_path( self, path: str, @@ -1005,7 +1118,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): obtain_images: bool = False, ) -> Optional[Context]: """ - 根据文件路径识别媒体信息 + 根据文件路径识别媒体信息,影视与音乐统一入口 :param path: 文件路径 :param source: 请求级识别数据源 @@ -1014,6 +1127,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :return: 识别上下文 """ logger.info(f"开始识别媒体信息,文件:{path} ...") + # 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充 + if self._is_music_path_request(path, source): + music_meta, music_info = self.recognize_music_by_path( + path, source=source or "musicbrainz" + ) + return Context(meta_info=music_meta, media_info=music_info) file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) @@ -1883,9 +2002,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if mediainfo: return None - # 延迟导入避免 MediaChain 与 MusicChain 在模块加载阶段形成双向依赖。 - from app.chain.music import MusicChain - _, recognized = MusicChain().recognize_by_path(local_path, source="musicbrainz") + _, recognized = cls.recognize_music_by_path(local_path, source="musicbrainz") return recognized def _write_music_metadata( @@ -2598,7 +2715,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): obtain_images: bool = False, ) -> Optional[Context]: """ - 根据文件路径识别媒体信息(异步版本) + 根据文件路径识别媒体信息,影视与音乐统一入口(异步版本) :param path: 文件路径 :param source: 请求级识别数据源 @@ -2607,6 +2724,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :return: 识别上下文 """ logger.info(f"开始识别媒体信息,文件:{path} ...") + # 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充 + if self._is_music_path_request(path, source): + music_meta, music_info = await self.async_recognize_music_by_path( + path, source=source or "musicbrainz" + ) + return Context(meta_info=music_meta, media_info=music_info) file_path = Path(path) # 元数据 file_meta = MetaInfoPath(file_path) diff --git a/app/chain/music.py b/app/chain/music.py index feb994f7e..729c5c031 100644 --- a/app/chain/music.py +++ b/app/chain/music.py @@ -1,10 +1,9 @@ import re from pathlib import Path -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Optional, Union from fastapi.concurrency import run_in_threadpool -from app import schemas from app.chain import ChainBase from app.core.config import settings from app.core.context import ( @@ -21,7 +20,7 @@ from app.log import logger class MusicChain(ChainBase): - """音乐元数据搜索、识别与站点搜索参数编排链。""" + """音乐元数据搜索、探索与站点搜索参数编排链;媒体识别统一入口见 MediaChain。""" # 专辑目录匹配结果缓存:{目录路径: (音频文件数, 匹配结果)},避免逐文件整理时重复请求远端 _album_dir_cache: dict[str, tuple[int, dict[str, MusicInfo]]] = {} @@ -304,71 +303,8 @@ class MusicChain(ChainBase): @staticmethod def _normalize_match_text(value: Optional[str]) -> str: """移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。""" - return MetaMusic._compact_text(value) + return MetaMusic.compact_text(value) - @classmethod - def is_audio_path(cls, path: str | Path) -> bool: - """判断路径是否指向系统支持的音频文件。""" - return Path(path).suffix.lower() in settings.RMT_AUDIOEXT - - @classmethod - def read_path_meta(cls, path: str | Path) -> MetaMusic: - """读取本地音频标签,标签缺失时用文件名和目录线索补齐。""" - file_path = Path(path) - if file_path.exists() and file_path.is_file(): - meta = AudioMetadataHelper.read(file_path) - else: - meta = cls.parse_query(file_path.stem) - # WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索 - return meta.apply_path_context(file_path) - - async def async_recognize_by_path( - self, - path: str | Path, - source: str = "musicbrainz", - ) -> tuple[MetaMusic, MusicInfo]: - """根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。""" - # Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。 - meta = await run_in_threadpool(self.read_path_meta, path) - # 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底 - info = await self.async_recognize_media(meta=meta, source=source) - result = self._merge_audio_quality(info or self._info_from_meta(meta), meta) - if not result.source: - # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 - matched = await run_in_threadpool(self._album_dir_fallback, path) - if matched: - result = self._merge_audio_quality(matched, meta) - return meta, result - - def recognize_by_path( - self, - path: str | Path, - source: str = "musicbrainz", - ) -> tuple[MetaMusic, MusicInfo]: - """同步根据音频标签和文件名识别音乐,并保留离线最小结果。""" - meta = self.read_path_meta(path) - # 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兑底 - info = self.recognize_media(meta=meta, source=source) - result = self._merge_audio_quality(info or self._info_from_meta(meta), meta) - if not result.source: - # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 - matched = self._album_dir_fallback(path) - if matched: - result = self._merge_audio_quality(matched, meta) - return meta, result - - def _album_dir_fallback(self, path: str | Path) -> Optional[MusicInfo]: - """单曲识别无远端身份时,查找所在目录专辑匹配中属于当前文件的结果。""" - file_path = Path(path) - if not file_path.exists() or not file_path.is_file(): - return None - try: - matched = self.recognize_album_directory(file_path.parent) - except Exception as err: - logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}") - return None - return matched.get(str(file_path.resolve())) - def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]: """按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。 @@ -391,17 +327,17 @@ class MusicChain(ChainBase): self._album_dir_cache.clear() self._album_dir_cache[cache_key] = (len(files), matched) return matched - + async def async_recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]: """目录级批量识别的异步版本,本地文件读取移出事件循环。""" return await run_in_threadpool(self.recognize_album_directory, path) - + @classmethod def _directory_audio_files(cls, dir_path: Path) -> list[Path]: """收集目录及其一级子目录(如 CD1/CD2)内的音频文件。""" audio_exts = settings.RMT_AUDIOEXT files: list[Path] = [] - + def collect(current: Path) -> None: try: entries = sorted(current.iterdir()) @@ -412,7 +348,7 @@ class MusicChain(ChainBase): continue if entry.is_file() and entry.suffix.lower() in audio_exts: files.append(entry) - + collect(dir_path) try: subdirs = sorted(entry for entry in dir_path.iterdir() @@ -422,13 +358,27 @@ class MusicChain(ChainBase): for subdir in subdirs: collect(subdir) return files - + + @classmethod + def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic: + """读取本地音频标签,标签缺失时用文件名和目录线索补齐。""" + 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 + ) + # WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索 + return meta.apply_path_context(file_path) + def _match_album_directory( self, dir_path: Path, files: list[Path], ) -> dict[str, MusicInfo]: """执行目录级专辑匹配,并把专辑曲目对位到具体音频文件。""" + # 音频标签读取归口 MediaChain,延迟导入避免模块加载阶段双向依赖。 metas = [self.read_path_meta(file) for file in files] album_meta = self._album_meta_from_context(dir_path, metas) if not (album_meta.album or album_meta.title or album_meta.artists): @@ -446,7 +396,7 @@ class MusicChain(ChainBase): for file, info in self._align_album_tracks(files, metas, album.tracks).items(): matched[str(file.resolve())] = info return matched - + @classmethod def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic: """汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。""" @@ -465,7 +415,8 @@ class MusicChain(ChainBase): majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None # 多数文件共享同一专辑标签才可信,避免杂集目录的个别错误标签带偏搜索 album = majority_album if majority_album and album_votes[majority_album] >= max(2, len(metas) // 2) else None - artist = majority_artist if majority_artist and artist_votes[majority_artist] >= max(2, len(metas) // 2) else None + artist = majority_artist if majority_artist and artist_votes[majority_artist] >= max(2, + len(metas) // 2) else None return MetaMusic( org_string=dir_path.name, title=album or dir_info.get("album") or dir_path.name, @@ -474,7 +425,7 @@ class MusicChain(ChainBase): album_artist=artist or dir_info.get("artist"), year=dir_info.get("year"), ) - + @classmethod def _align_album_tracks( cls, @@ -537,40 +488,6 @@ class MusicChain(ChainBase): media_id=info.media_id, ) - @classmethod - def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo: - """把音频标签转换为文件管理可展示的最小音乐信息。""" - return MusicInfo( - source=meta.media_source, - media_id=meta.media_id, - title=meta.title, - artists=list(meta.artists), - album=meta.album, - album_artist=meta.album_artist, - year=meta.year, - disc_number=meta.disc_number, - track_number=meta.track_number, - total_tracks=meta.total_tracks, - duration=meta.duration, - isrc=meta.isrc, - version=meta.version, - audio_format=meta.audio_format, - audio_lossless=meta.audio_lossless, - bit_depth=meta.bit_depth, - sample_rate=meta.sample_rate, - bitrate=meta.bitrate, - names=[name for name in (meta.title, meta.album) if name], - ) - - @staticmethod - def _merge_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo: - """将本地文件的实际音频参数合并到远端音乐身份识别结果。""" - for key in ("audio_format", "audio_lossless", "bit_depth", "sample_rate", "bitrate"): - value = getattr(meta, key, None) - if value is not None: - setattr(info, key, value) - return info - @classmethod def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]: """构造跨来源稳定的候选去重键。""" diff --git a/app/chain/transfer.py b/app/chain/transfer.py index de2067640..ea70a8df0 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1197,6 +1197,40 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_info.listen_count = saved_info.listen_count return file_meta, file_info + @staticmethod + def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool: + """ + 判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。 + """ + if history.type == MediaType.MUSIC.value: + return True + return src_path.suffix.lower() in settings.RMT_AUDIOEXT + + def _recognize_music_retry_media( + self, + history: TransferHistory, + src_path: Path, + ) -> Optional[MusicInfo]: + """ + 重新整理重试时恢复音乐信息。 + + 优先按历史记录中的 MusicBrainz 身份恢复;单音频文件回退按音频标签与文件名识别; + 音乐专辑目录返回 None,交由整理链按音频后缀逐文件解析识别。 + """ + if history.media_source and history.media_id: + retry_info = self.recognize_media( + mtype=MediaType.MUSIC, + source=history.media_source, + mediaid=history.media_id, + ) + if retry_info: + return retry_info + if src_path.is_file(): + # 音频走统一路径识别入口,自动路由到音乐识别链 + recognize_context = MediaChain().recognize_by_path(str(src_path)) + return recognize_context.media_info if recognize_context else None + return None + def __is_allowed_file(self, fileitem: FileItem) -> bool: """ 判断是否允许的扩展名 @@ -2511,6 +2545,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash( torrent.hash ) + # 下载记录中的媒体类型作为整理类型来源,无下载记录时留空由文件后缀兜底 + mtype: Optional[MediaType] = None if downloadhis: # 类型 try: @@ -2551,9 +2587,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): extension=file_path.suffix.lstrip("."), ), mediainfo=mediainfo, - downloader=torrent.downloader, - download_hash=torrent.hash, - ) + mtype=mtype, + downloader=torrent.downloader, + download_hash=torrent.hash, + ) if progress_callback: progress_callback( value=index / total_num * 100, @@ -3209,14 +3246,31 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return built_meta return _apply_meta_overrides(built_meta, source_path) + def _has_reliable_video_source() -> bool: + """ + 是否存在可靠的影视类型来源;存在时音频按附加音轨解析, + 避免影视场景的音频文件误入音乐识别。 + """ + if mtype is not None: + return mtype != MediaType.MUSIC + # 预载媒体信息为非音乐时,整批整理视为影视上下文 + return mediainfo is not None and not isinstance(mediainfo, MusicInfo) + def _build_path_meta( source_path: Path, custom_word_list: Optional[List[str]] = None, + force_video: Optional[bool] = False, ) -> Optional[MetaBase]: """ 从文件路径识别媒体信息,用于判断附加文件是否属于当前主视频。 + :param force_video: 强制按视频解析,附加文件归属匹配专用,避免音乐判定干扰归属比较 """ - if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts: + # 音频后缀且无可靠影视类型来源时按音乐解析,走 MusicBrainz 识别链 + if ( + not force_video + and source_path.suffix.lower() in self._audio_exts + and not _has_reliable_video_source() + ): path_meta = AudioMetadataHelper.read(source_path) else: # 影视场景附加音轨(如评论音轨)强制按视频解析,保留季集归属 @@ -3475,9 +3529,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): custom_words_key = tuple(custom_word_list or []) cache_key = (extra_path.as_posix(), custom_words_key) if cache_key not in extra_meta_cache: + # 归属匹配专用视频解析:此处目的是判断附加文件是否跟随主视频, + # 若按音乐解析会导致影视目录内的音频无法与主视频比较归属 extra_meta_cache[cache_key] = _build_path_meta( extra_path, custom_word_list=list(custom_words_key) or None, + force_video=True, ) return extra_meta_cache[cache_key] @@ -3950,7 +4007,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return # 类型 type_str = id_strs[1] if len(id_strs) > 1 else None - if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]: + if not type_str or type_str not in [ + MediaType.MOVIE.value, + MediaType.TV.value, + MediaType.MUSIC.value, + ]: args_error() return state, errmsg = self.__re_transfer( @@ -4016,6 +4077,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 查询媒体信息 if mtype and mediaid: media_source, source_media_id = parse_media_key(mediaid) + if mtype == MediaType.MUSIC and not media_source: + # 音乐原生 ID 未带来源前缀时默认按 MusicBrainz ID 处理 + media_source, source_media_id = "musicbrainz", str(mediaid) if media_source and source_media_id: mediainfo = self.recognize_media( mtype=mtype, @@ -4030,9 +4094,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): doubanid=mediaid if not str(mediaid).isdigit() else None, episode_group=history.episode_group, ) - if mediainfo: + if mediainfo and not isinstance(mediainfo, MusicInfo): # 更新媒体图片 self.obtain_images(mediainfo=mediainfo) + elif mtype == MediaType.MUSIC or self._is_music_retry_source(history, src_path): + # 音乐重新整理走音乐识别链,避免默认影视识别误入 TMDB + mtype = MediaType.MUSIC + mediainfo = self._recognize_music_retry_media(history, src_path) else: recognize_context = MediaChain().recognize_by_path( str(src_path), @@ -4040,10 +4108,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): obtain_images=True, ) mediainfo = recognize_context.media_info if recognize_context else None - if not mediainfo: - return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}" + # 音乐专辑目录允许无预识别信息,由整理链按音频后缀逐文件解析识别 + if not mediainfo and not (mtype == MediaType.MUSIC and src_path.is_dir()): + return False, f"未识别到媒体信息,类型:{mtype.value if mtype else None},id:{mediaid}" # 重新执行整理 - logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}") + if mediainfo: + logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}") # 删除旧的已整理文件 if history.dest_fileitem: @@ -4056,6 +4126,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): state, errmsg = self.do_transfer( fileitem=FileItem(**history.src_fileitem), mediainfo=mediainfo, + mtype=mtype, download_hash=history.download_hash, force=True, background=False, diff --git a/app/core/meta/metamusic.py b/app/core/meta/metamusic.py index 91fb0040f..7711c6a05 100644 --- a/app/core/meta/metamusic.py +++ b/app/core/meta/metamusic.py @@ -713,10 +713,10 @@ class MetaMusic(MetaBase): match = _MUSIC_ARTIST_SUFFIX_RE.search(value) if not match: return value - suffix = cls._compact_text(match.group("suffix")) + suffix = cls.compact_text(match.group("suffix")) if not suffix: return value - if any(suffix == cls._compact_text(artist) for artist in artists): + if any(suffix == cls.compact_text(artist) for artist in artists): return value[:match.start()].strip() return value @@ -848,7 +848,7 @@ class MetaMusic(MetaBase): return None, str(value or ""), None @staticmethod - def _compact_text(value: Any) -> str: + def compact_text(value: Any) -> str: """移除大小写、空白与标点,生成比对使用的紧凑文本。""" return _MUSIC_COMPACT_RE.sub("", str(value or "").casefold()) diff --git a/tests/test_media_scrape_endpoint.py b/tests/test_media_scrape_endpoint.py index cbda5058b..b09d44077 100644 --- a/tests/test_media_scrape_endpoint.py +++ b/tests/test_media_scrape_endpoint.py @@ -84,12 +84,12 @@ def test_scrape_rejects_media_id_without_source() -> None: def test_recognize_file_routes_audio_to_music_chain() -> None: - """文件管理识别音频文件时应返回音乐专属上下文。""" - music_chain = Mock() - music_chain.async_recognize_by_path = AsyncMock( - return_value=( - MetaMusic(title="晴天", artists=["周杰伦"]), - MusicInfo( + """文件管理识别音频文件时应经统一路径识别入口返回音乐专属上下文。""" + chain = Mock() + chain.async_recognize_by_path = AsyncMock( + return_value=Context( + meta_info=MetaMusic(title="晴天", artists=["周杰伦"]), + media_info=MusicInfo( source="musicbrainz", media_id="977e6978-139d-425c-bb98-6b0c62d1e45e", title="晴天", @@ -100,14 +100,13 @@ def test_recognize_file_routes_audio_to_music_chain() -> None: import asyncio - with patch("app.api.endpoints.media.MusicChain", return_value=music_chain): + with patch("app.api.endpoints.media.MediaChain", return_value=chain): result = asyncio.run(recognize_file(path="/music/晴天.flac", _=Mock())) assert result["meta_info"]["type"] == "音乐" assert result["media_info"]["title"] == "晴天" - music_chain.async_recognize_by_path.assert_awaited_once_with( - path="/music/晴天.flac", - source="musicbrainz", + chain.async_recognize_by_path.assert_awaited_once_with( + "/music/晴天.flac", source=None ) diff --git a/tests/test_music_album_match.py b/tests/test_music_album_match.py index 23624b05f..67092b377 100644 --- a/tests/test_music_album_match.py +++ b/tests/test_music_album_match.py @@ -177,7 +177,7 @@ def test_recognize_album_directory_skips_single_file(tmp_path, music_chain, monk assert music_chain.recognize_album_directory(album_dir) == {} -def test_recognize_by_path_falls_back_to_album_match(tmp_path, music_chain, monkeypatch): +def test_recognize_music_by_path_falls_back_to_album_match(tmp_path, music_chain, monkeypatch): """单曲识别无远端身份时应用目录级匹配结果兜底。""" album_dir = tmp_path / "周杰伦 - 七里香 (2004)" album_dir.mkdir() @@ -201,7 +201,7 @@ def test_recognize_by_path_falls_back_to_album_match(tmp_path, music_chain, monk lambda path: {str(file.resolve()): matched_info}, ) - meta, info = music_chain.recognize_by_path(file) + meta, info = music_chain.recognize_music_by_path(file) assert info.media_id == "rec-1" assert info.title == "我的地盘" diff --git a/tests/test_music_chain.py b/tests/test_music_chain.py index 4fc739eca..bf1a0e5f3 100644 --- a/tests/test_music_chain.py +++ b/tests/test_music_chain.py @@ -315,7 +315,7 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch): import asyncio recognized_meta, recognized_info = asyncio.run( - chain.async_recognize_by_path(audio_path) + chain.async_recognize_music_by_path(audio_path) ) assert recognized_meta is meta diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 21e16b416..531bd64a7 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -63,18 +63,63 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa assert result is expected -def test_media_chain_recognize_by_path_routes_audio_file_to_module(monkeypatch): - """音频文件路径应经 MetaInfoPath 构造 MetaMusic 并路由到统一模块识别入口。""" - expected = _music_info() +def test_media_chain_recognize_by_path_routes_audio_file_to_music_chain(monkeypatch): + """音频文件路径应经统一入口分发到 MediaChain 的音乐路径识别实现。""" + expected_meta = MetaMusic(title="晴天", artists=["周杰伦"]) + expected_info = _music_info() + recognize_music = Mock(return_value=(expected_meta, expected_info)) + monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize_music) + + context = MediaChain().recognize_by_path("/music/周杰伦 - 晴天.flac") + + recognize_music.assert_called_once() + assert recognize_music.call_args.args[0] == "/music/周杰伦 - 晴天.flac" + assert context.meta_info is expected_meta + assert context.media_info is expected_info + + +def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain(monkeypatch): + """显式指定 MusicBrainz 数据源的路径识别也应分发到音乐实现。""" + expected_meta = MetaMusic(title="晴天") + expected_info = _music_info() + recognize_music = Mock(return_value=(expected_meta, expected_info)) + monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize_music) + + context = MediaChain().recognize_by_path("/downloads/晴天", source="musicbrainz") + + recognize_music.assert_called_once() + assert recognize_music.call_args.kwargs["source"] == "musicbrainz" + assert context.media_info is expected_info + + +def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypatch): + """本地音频识别应使用内嵌标签补全艺术家、专辑并保留音频质量参数。""" + from unittest.mock import AsyncMock + + from app.helper.audio import AudioMetadataHelper + + audio_path = tmp_path / "02. 眼泪成诗.m4a" + audio_path.write_bytes(b"audio") + meta = MetaMusic( + title="眼泪成诗", + artists=["孙燕姿"], + album="完美的一天", + track_number=2, + duration=221, + ) + info = _music_info() chain = MediaChain() - monkeypatch.setattr(chain, "recognize_media", Mock(return_value=expected)) + recognize = AsyncMock(return_value=info) + monkeypatch.setattr(AudioMetadataHelper, "read", lambda path: meta) + monkeypatch.setattr(chain, "async_recognize_media", recognize) - context = chain.recognize_by_path("/music/周杰伦 - 晴天.flac") + recognized_meta, recognized_info = asyncio.run( + chain.async_recognize_music_by_path(audio_path) + ) - routed_meta = chain.recognize_media.call_args.kwargs["meta"] - assert isinstance(routed_meta, MetaMusic) - assert context.media_info is expected - assert isinstance(context.meta_info, MetaMusic) + assert recognized_meta is meta + assert recognized_info is info + recognize.assert_awaited_once_with(meta=meta, source="musicbrainz") def test_musicbrainz_module_recognize_media_ignores_non_music():