From a94dac60a90116c1b5f963bfda467aa782418cdd Mon Sep 17 00:00:00 2001 From: JontyLee <31982854+JontyLee@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:32:19 +0800 Subject: [PATCH] fix(music): fall back to tags when cue index is broken for split albums (#6978) --- app/application/audio.py | 39 ++++++++++++++++- docs/mcp-api.md | 12 ++++-- tests/test_music_cue.py | 90 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/app/application/audio.py b/app/application/audio.py index 51be84e0f..3d9bc1482 100644 --- a/app/application/audio.py +++ b/app/application/audio.py @@ -214,6 +214,39 @@ def _cue_references_existing_audio(path: Path, names: Any) -> bool: return False +def _cue_split_audio_files(path: Path, text: str) -> list[str]: + """列出 CUE 中只声明单条音轨、且文件确实存在于同目录的 FILE 引用名。 + + FILE 块只有一条音轨说明该文件本身就是一整支曲目,曲目边界来自文件本身;块内 + 有两条以上音轨说明该文件是整轨镜像,索引才是唯一的曲目来源。跨目录引用无法 + 核对目录归属,保守返回空列表交由阻断处理。 + """ + singles: list[str] = [] + current: Optional[str] = None + tracks = 0 + for line in text.splitlines(): + reference = re.match(r'\s*FILE\s+(?:"([^"\r\n]+)"|(\S+))', line, re.IGNORECASE) + if reference: + if current is not None and tracks == 1: + singles.append(current) + current = reference.group(1) or reference.group(2) + tracks = 0 + continue + if current is not None and re.match(r"\s*TRACK\s+\d{1,3}\s+AUDIO", line, re.IGNORECASE): + tracks += 1 + if current is not None and tracks == 1: + singles.append(current) + names: list[str] = [] + for name in singles: + try: + resolved = _cue_reference_name(name) + except ValueError: + return [] + if resolved not in names and (path.parent / resolved).is_file(): + names.append(resolved) + return names + + def _cue_candidates(path: Path) -> list[Path]: """只扫描音频同目录的有限 CUE,不遍历全集或其它目录。""" candidates = [] @@ -234,7 +267,7 @@ def _cue_reference_name(name: str) -> str: def _find_audio_cue(path: Path) -> Optional[tuple[Path, MusicCueSheet]]: - """以 FILE 引用定位关联 CUE,冲突或同名损坏索引不能退回首曲指纹识别。""" + """以 FILE 引用定位关联 CUE,冲突或整轨损坏索引不能退回首曲指纹识别。""" matches: list[tuple[Path, MusicCueSheet]] = [] for candidate in _cue_candidates(path): try: @@ -246,6 +279,10 @@ def _find_audio_cue(path: Path) -> Optional[tuple[Path, MusicCueSheet]]: try: sheet = parse_music_cue(text) except ValueError as error: + split_files = _cue_split_audio_files(path, text) + if len(split_files) > 1 and path.name in split_files: + logger.info(f"CUE 索引结构损坏但专辑为物理分轨,按各文件自带标签整理:{candidate}") + continue mentions = candidate.stem.casefold() == path.stem.casefold() or _cue_mentions_audio(text, path) if mentions and _cue_references_existing_audio(path, _cue_file_references(text)): raise ValueError(f"关联 CUE 无法解析:{candidate.name} - {error}") from error diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6439861c7..ab35a70ee 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -651,8 +651,10 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 `remote` 或 `manual` 等来源,缺失表示来源未记录;该说明不等于远端身份已验证,也不用于绕过整理准入。 `MUSIC_CUE_ENABLE` 默认开启,可在“设置 → 系统 → 高级设置 → 媒体 → 音乐 CUE 识别”关闭。 -已分轨专辑附带错误 CUE 时,关闭后重新预览或重新提交整理,系统只按音频标签和文件名识别, -不读取、校验或自动归档 CUE。全局开关作为自动整理与手动整理的默认值。 +已分轨专辑附带结构损坏的 CUE 时,只要 CUE 为目录中多个真实音频分别建立了单轨 `FILE` 块, +系统自动按该文件的标签和文件名识别,无需关闭开关;整轨镜像仍需要关闭开关后重新预览或 +重新提交整理,才能只按音频标签和文件名识别。两种情况下系统都不读取、校验或自动归档损坏 CUE。 +全局开关作为自动整理与手动整理的默认值。 手动整理弹窗提供“本次识别 CUE”,默认采用系统设置,可直接覆盖本次预览、立即整理和队列整理, 不修改全局设置。`POST /api/v1/transfer/manual` 接受 `music_cue_enable: boolean | null`, 省略或 `null` 继承全局值;`false` 忽略 CUE,`true` 启用 CUE。 @@ -664,8 +666,10 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 一个音频文件包含多个逻辑音轨,`music_type=album`,不使用开头的 Recording 指纹替代整张专辑。 整轨归档会保留音频及 `cue_filename` 的原名,只规范专辑目录,避免破坏 `FILE` 引用;不自动切轨。 `cue_tracks` 保留逻辑曲目及每秒 75 帧的索引。`tracks_cue` 的分轨索引仅辅助元数据, -原 CUE 不随改名自动复制。绝对/跨目录引用、冲突索引、超出音频时长等问题通过 -`organization_error` 返回,并在实际文件操作前阻止整理;原始文件内容不被修改。 +原 CUE 不随改名自动复制。绝对/跨目录引用、整轨的冲突索引、超出音频时长等问题通过 +`organization_error` 返回,并在实际文件操作前阻止整理;索引损坏但 CUE 已把目录中多个 +真实音频分别声明为单轨 `FILE` 时,曲目边界来自文件本身,按该文件标签继续整理并保留日志。 +原始文件内容不被修改。 音乐刮削补写标签或内嵌封面时,如果本地目标是硬链接或软链接,会先写入独立副本, 全部成功后原子替换目标;源文件字节与权限保持不变。实际发生写入后,目标成为普通文件, diff --git a/tests/test_music_cue.py b/tests/test_music_cue.py index 74a682999..c3064d602 100644 --- a/tests/test_music_cue.py +++ b/tests/test_music_cue.py @@ -40,6 +40,60 @@ FILE "image.flac" WAVE INDEX 01 00:01:00 ''' +# 每只分轨各有 FILE 块,但同一文件名重复声明 INDEX 01,触发“同一文件的时间索引重复或逆序”。 +SPLIT_BROKEN_CUE = '''REM DATE 2003 +PERFORMER "测试歌手" +TITLE "专辑示例" +FILE "01 - 第一首.flac" FLAC + TRACK 01 AUDIO + TITLE "第一首" + INDEX 01 00:00:00 +FILE "02 - 第二首.flac" FLAC + TRACK 02 AUDIO + TITLE "第二首" + INDEX 01 00:00:00 +FILE "02 - 第二首.flac" FLAC + TRACK 03 AUDIO + TITLE "第二首" + INDEX 01 00:00:00 +''' + +# 两碟整轨镜像:每个 FILE 块都声明多条音轨,坏索引无法退化为逐文件识别。 +DISC_BROKEN_CUE = '''REM DATE 2003 +PERFORMER "测试歌手" +TITLE "专辑示例" +FILE "disc1.flac" FLAC + TRACK 01 AUDIO + TITLE "第一首" + INDEX 01 00:00:00 + TRACK 02 AUDIO + TITLE "第二首" + INDEX 01 00:01:00 +FILE "disc2.flac" FLAC + TRACK 03 AUDIO + TITLE "第三首" + INDEX 01 00:00:00 + TRACK 04 AUDIO + TITLE "第四首" + INDEX 01 00:00:00 +''' + +_SPLIT_TRACKS = (("01 - 第一首.flac", "第一首", 1), ("02 - 第二首.flac", "第二首", 2)) + + +def _split_album(tmp_path, *, text, tracks=_SPLIT_TRACKS, cue_name="album.cue"): + """创建分轨专辑目录:每只 FLAC 自带曲目标签,另放一份指定内容的 CUE。""" + source = tmp_path / "source" + source.mkdir() + for name, title, number in tracks: + audio = source / name + shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", audio) + tags = FLAC(audio) + tags.update(title=[title], artist=["测试歌手"], album=["专辑示例"], tracknumber=[str(number)]) + tags.save() + (source / cue_name).write_text(text, encoding="utf-8") + return source + def _image_pair(tmp_path, *, encoding="utf-8", text=CUE, cue_name="album.cue"): """创建三秒实际整轨 FLAC 和两个逻辑曲目的 CUE。""" @@ -376,6 +430,42 @@ def test_broken_index_with_different_stem_is_not_ignored(tmp_path, text): assert meta.music_layout == "cue_invalid" +def test_broken_index_in_split_album_falls_back_to_tags(tmp_path): + """分轨专辑的坏索引只丢逻辑切点,每只文件按自带标签整理,不能一条坏索引阻断全专。""" + source = _split_album(tmp_path, text=SPLIT_BROKEN_CUE) + + for name, title, number in _SPLIT_TRACKS: + meta = AudioMetadataHelper.read(source / name) + + assert not meta.organization_error + assert meta.music_layout != "cue_invalid" + assert not meta.cue_filename + assert meta.title == title + assert meta.track_number == number + + +def test_broken_index_in_multi_disc_image_still_blocks(tmp_path): + """多碟整轨的每个 FILE 块都含多条音轨,索引是唯一曲目来源,损坏必须继续阻断。""" + source = _split_album(tmp_path, text=DISC_BROKEN_CUE, + tracks=(("disc1.flac", "第一首", 1), ("disc2.flac", "第三首", 3))) + + for name in ("disc1.flac", "disc2.flac"): + meta = AudioMetadataHelper.read(source / name) + + assert meta.organization_error + assert meta.music_layout == "cue_invalid" + + +def test_broken_index_with_single_existing_track_still_blocks(tmp_path): + """分轨专辑只剩一只已下载文件时无法与整轨镜像区分,保持可见错误而不静默退回标签。""" + source = _split_album(tmp_path, text=SPLIT_BROKEN_CUE, tracks=(_SPLIT_TRACKS[0],)) + + meta = AudioMetadataHelper.read(source / _SPLIT_TRACKS[0][0]) + + assert meta.organization_error + assert meta.music_layout == "cue_invalid" + + @pytest.mark.parametrize("mtype", [None, MediaType.MUSIC]) @pytest.mark.parametrize("invalid", [False, True]) @pytest.mark.parametrize("cue_enabled", [False, True])