diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 8cb09a552..c4b954400 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1080,39 +1080,44 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): except (TypeError, ValueError): return None, None - file_tags = AudioMetadataHelper.read(file_path) if file_path.exists() else None + file_tags = ( + AudioMetadataHelper.read(file_path) + if file_path.exists() + else MetaMusic( + org_string=file_path.name, + title=file_path.stem, + audio_format=file_path.suffix.lstrip(".").upper() or None, + ) + ) file_meta = deepcopy(saved_meta) file_meta.org_string = file_path.name - if file_tags: - # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), - # 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。 - if file_tags.title: - file_meta.title = file_tags.title - for field_name in ( - "artists", - "album", - "album_artist", - "year", - "disc_number", - "track_number", - "total_discs", - "total_tracks", - "version", - "isrc", - ): - if getattr(file_tags, field_name, None): - setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name))) - for field_name in ( - "audio_format", - "bit_depth", - "sample_rate", - "bitrate", - "duration", - ): - if getattr(file_tags, field_name, None): - setattr(file_meta, field_name, getattr(file_tags, field_name)) - elif not file_meta.audio_format: - file_meta.audio_format = file_path.suffix.lstrip(".").upper() or None + # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), + # 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。 + if file_tags.title: + file_meta.title = file_tags.title + for field_name in ( + "artists", + "album", + "album_artist", + "year", + "disc_number", + "track_number", + "total_discs", + "total_tracks", + "version", + "isrc", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name))) + for field_name in ( + "audio_format", + "bit_depth", + "sample_rate", + "bitrate", + "duration", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, getattr(file_tags, field_name)) file_meta.media_source = saved_info.source or saved_meta.media_source file_meta.media_id = saved_info.media_id or saved_meta.media_id diff --git a/app/helper/message.py b/app/helper/message.py index 9e8c9494a..bbdce3745 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -87,28 +87,42 @@ class TemplateContextBuilder: 将 MediaInfo 中的标题、季年份、海报等业务字段就地写入 ``context``。 会读取 ``context`` 中由 ``_add_episode_details`` 先填好的 ``season`` / - ``year`` / ``title_year`` 占位,保证电视剧场景下季/年优先沿用 meta 解析值。 + ``year`` / ``title_year`` 占位,保证电视剧场景下季/年优先沿用 meta 解析值; + 音乐场景保留文件标签解析出的曲目级字段,仅用识别结果补齐专辑级字段。 """ if not mediainfo: return if isinstance(mediainfo, MusicInfo): + # 专辑识别结果会被同一批次的所有曲目复用,不能覆盖每个文件自己的曲名和曲序。 + title = context.get("title") or cls.__convert_invalid_characters(mediainfo.title) + artists = context.get("artists") or [ + cls.__convert_invalid_characters(item) for item in mediainfo.artists + ] + artist = context.get("artist") or cls.__convert_invalid_characters(mediainfo.artist) + album = context.get("album") or cls.__convert_invalid_characters(mediainfo.album) + album_artist = context.get("album_artist") or cls.__convert_invalid_characters( + mediainfo.album_artist + ) + year = context.get("year") or mediainfo.year + disc_number = context.get("disc_number") or mediainfo.disc_number + track_number = context.get("track_number") or mediainfo.track_number context.update({ "type": mediainfo.type.value, - "title": cls.__convert_invalid_characters(mediainfo.title), - "name": cls.__convert_invalid_characters(mediainfo.title), - "artists": [cls.__convert_invalid_characters(item) for item in mediainfo.artists], - "artist": cls.__convert_invalid_characters(mediainfo.artist), - "album": cls.__convert_invalid_characters(mediainfo.album), - "album_artist": cls.__convert_invalid_characters(mediainfo.album_artist), - "year": mediainfo.year or context.get("year"), - "title_year": mediainfo.title_year or context.get("title_year"), - "disc_number": mediainfo.disc_number, - "track_number": mediainfo.track_number, - "track": f"{mediainfo.track_number:02d}" if mediainfo.track_number else None, - "total_tracks": mediainfo.total_tracks, - "duration": mediainfo.duration, - "isrc": mediainfo.isrc, - "version": mediainfo.version, + "title": title, + "name": context.get("name") or title, + "artists": artists, + "artist": artist, + "album": album, + "album_artist": album_artist, + "year": year, + "title_year": f"{title} ({year})" if title and year else title, + "disc_number": disc_number, + "track_number": track_number, + "track": f"{track_number:02d}" if track_number else None, + "total_tracks": context.get("total_tracks") or mediainfo.total_tracks, + "duration": context.get("duration") or mediainfo.duration, + "isrc": context.get("isrc") or mediainfo.isrc, + "version": context.get("version") or mediainfo.version, "category": mediainfo.category, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 99becd04f..07458fa21 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -1,6 +1,8 @@ from pathlib import Path from types import SimpleNamespace +from jinja2 import Template + from app.chain.music import MusicChain from app.chain.transfer import JobManager, TransferChain from app.core.config import settings @@ -46,6 +48,43 @@ def test_music_rename_context_contains_audio_fields(): assert context["fileExt"] == ".flac" +def test_music_rename_prefers_track_meta_over_album_media(): + """专辑整理时应使用每个文件的曲名和曲序,不能把专辑名写成所有目标文件名。""" + meta = MetaMusic( + org_string="10. 明天晴天.m4a", + title="明天晴天", + artists=["孙燕姿"], + album="完美的一天", + album_artist="孙燕姿", + year=2005, + track_number=10, + total_tracks=11, + ) + album = MusicInfo( + source="musicbrainz", + media_id="album-1", + music_type="album", + title="完美的一天", + artists=["孙燕姿"], + album="完美的一天", + album_artist="孙燕姿", + year=2005, + total_tracks=11, + ) + + context = TemplateHelper().builder.build( + meta=meta, + mediainfo=album, + file_extension=".m4a", + include_raw_objects=False, + ) + rendered = Template(settings.MUSIC_RENAME_FORMAT).render(context) + + assert context["title"] == "明天晴天" + assert context["track"] == "10" + assert rendered == "孙燕姿/完美的一天 (2005)/10 - 明天晴天.m4a" + + def test_music_rename_format_is_independent_from_movie_format(): """音乐应使用独立重命名模板且保持影视模板不变。""" assert settings.RENAME_FORMAT(MediaType.MUSIC) == settings.MUSIC_RENAME_FORMAT @@ -124,6 +163,53 @@ def test_restore_music_context_uses_file_title_over_subscription_title(tmp_path, assert restored_meta.title == "流浪地图" +def test_restore_music_context_uses_filename_when_source_is_not_locally_accessible(): + """远端音频无法直接读取标签时也应按文件名区分曲目,避免整张专辑重名。""" + meta, info = _music_context() + meta.artists = ["孙燕姿"] + meta.title = "完美的一天" + meta.album = "完美的一天" + meta.album_artist = "孙燕姿" + meta.year = 2005 + meta.track_number = None + meta.total_tracks = None + info.music_type = "album" + info.artists = ["孙燕姿"] + info.title = "完美的一天" + info.album = "完美的一天" + info.album_artist = "孙燕姿" + info.year = 2005 + info.track_number = None + info.total_tracks = None + history = SimpleNamespace( + note={ + "music": { + "version": 1, + "meta": meta.to_dict(), + "media": info.to_dict(), + } + } + ) + + restored_meta, restored_info = TransferChain._restore_music_download_context( + history, + Path("/remote/10. 明天晴天.m4a"), + ) + + assert restored_meta is not None + assert restored_info is not None + assert restored_meta.title == "10. 明天晴天" + assert restored_info.title == "10. 明天晴天" + context = TemplateHelper().builder.build( + meta=restored_meta, + mediainfo=restored_info, + file_extension=".m4a", + include_raw_objects=False, + ) + rendered = Template(settings.MUSIC_RENAME_FORMAT).render(context) + assert rendered == "孙燕姿/完美的一天 (2005)/10. 明天晴天.m4a" + + def test_job_manager_serializes_music_queue_models(): """整理队列应使用音乐专属 Schema 序列化任务。""" meta, info = _music_context()