diff --git a/app/application/audio.py b/app/application/audio.py index 843fc8f98..481516287 100644 --- a/app/application/audio.py +++ b/app/application/audio.py @@ -174,6 +174,28 @@ def _cue_mentions_audio(text: str, path: Path) -> bool: return False +def _cue_file_references(text: str) -> list[str]: + """从原始 CUE 文本提取 FILE 引用名,供解析失败时判断引用是否仍然存在。""" + references = [] + for line in text.splitlines(): + match = re.match(r'\s*FILE\s+(?:"([^"\r\n]+)"|(\S+))', line, re.IGNORECASE) + if match: + references.append(match.group(1) or match.group(2)) + return references + + +def _cue_references_existing_audio(path: Path, names: Any) -> bool: + """CUE 至少引用一个目录中真实存在的音频才可采信;跨目录引用保守视为存在。""" + for name in names: + try: + reference = _cue_reference_name(name) + except ValueError: + return True + if (path.parent / reference).is_file(): + return True + return False + + def _cue_candidates(path: Path) -> list[Path]: """只扫描音频同目录的有限 CUE,不遍历全集或其它目录。""" candidates = [] @@ -206,10 +228,14 @@ def _find_audio_cue(path: Path) -> Optional[tuple[Path, MusicCueSheet]]: try: sheet = parse_music_cue(text) except ValueError as error: - if candidate.stem.casefold() == path.stem.casefold() or _cue_mentions_audio(text, path): + 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 continue references = {track.file_name for track in sheet.tracks} + if not _cue_references_existing_audio(path, references): + logger.info(f"CUE 引用的音频文件均已不存在,按陈旧索引忽略:{candidate}") + continue related = [name for name in references if PurePosixPath(name.replace("\\", "/")).stem.casefold() == path.stem.casefold()] if not related: continue diff --git a/tests/test_music_cue.py b/tests/test_music_cue.py index e2f98f34b..ebf839ca9 100644 --- a/tests/test_music_cue.py +++ b/tests/test_music_cue.py @@ -122,9 +122,9 @@ def test_image_cue_never_runs_recording_fingerprint(tmp_path, monkeypatch, async async_fingerprint.assert_not_called() -@pytest.mark.parametrize("bad_reference", ["../image.flac", "image.wav", "IMAGE.flac", "C:\\music\\image.flac"]) +@pytest.mark.parametrize("bad_reference", ["../image.flac", "C:\\music\\image.flac"]) def test_cue_reference_errors_block_even_without_category_folders(tmp_path, bad_reference): - """跨目录、错误后缀和大小写等引用不能靠关闭分类目录绕过检查。""" + """跨目录引用不能靠关闭分类目录绕过检查;同目录引用交给存在性判定。""" audio, _ = _image_pair(tmp_path, text=CUE.replace('"image.flac"', f'"{bad_reference}"')) meta = AudioMetadataHelper.read(audio) task = TransferTask(fileitem=make_fileitem(str(audio)), meta=meta, mediainfo=MusicInfo.from_meta(meta), @@ -135,6 +135,58 @@ def test_cue_reference_errors_block_even_without_category_folders(tmp_path, bad_ assert meta.organization_error +@pytest.mark.parametrize("stale_reference", ["image.wav"]) +def test_cue_reference_missing_from_directory_falls_back_to_tags(tmp_path, stale_reference): + """引用文件已不存在的陈旧索引(转制未更新扩展名等)不得阻断整理,回退标签。""" + audio, _ = _image_pair(tmp_path, text=CUE.replace('"image.flac"', f'"{stale_reference}"')) + tags = FLAC(audio) + tags.update(title=["独立单曲"], artist=["测试歌手"], album=["专辑示例"], tracknumber=["2"]) + tags.save() + + meta = AudioMetadataHelper.read(audio) + + assert not meta.organization_error + assert meta.music_layout != "cue_invalid" + assert meta.title == "独立单曲" + assert meta.track_number == 2 + + +def test_stale_wav_cue_does_not_block_flac_split_album(tmp_path): + """WAV 时代留档 CUE 全部引用不存在的 .wav 时,FLAC 分轨按自带标签正常整理。""" + source = tmp_path / "source" + source.mkdir() + for name in ("01 - 以父之名.flac", "02 - 懦夫.flac"): + shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", source / name) + (source / "album.cue").write_text('''PERFORMER "周杰倫" +TITLE "葉惠美" +FILE "01 - 以父之名.wav" WAVE + TRACK 01 AUDIO + TITLE "以父之名" + INDEX 01 00:00:00 +FILE "02 - 懦夫.wav" WAVE + TRACK 02 AUDIO + TITLE "懦夫" + INDEX 01 00:00:00 +''') + + meta = AudioMetadataHelper.read(source / "01 - 以父之名.flac") + + assert not meta.organization_error + assert meta.music_layout != "cue_invalid" + assert not meta.cue_tracks + + +def test_broken_index_without_existing_references_is_ignored(tmp_path): + """结构损坏且引用文件全部不存在的 CUE 同样按陈旧索引忽略,不再阻断。""" + audio, _ = _image_pair(tmp_path, text=CUE.replace('"image.flac"', '"image.wav"') + .replace(" INDEX 01 00:01:00\n", "")) + + meta = AudioMetadataHelper.read(audio) + + assert not meta.organization_error + assert meta.music_layout != "cue_invalid" + + def test_single_audio_collects_nonmatching_stem_cue_companion(tmp_path, monkeypatch): """FILE 关联有效时,CUE 文件名无需与音频同主干,且应在音频后加入归档。""" audio, cue = _image_pair(tmp_path, cue_name="album-index.cue")