mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 06:15:14 +08:00
fix(music): fall back to tags when cue index is broken for split albums (#6978)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` 时,曲目边界来自文件本身,按该文件标签继续整理并保留日志。
|
||||
原始文件内容不被修改。
|
||||
|
||||
音乐刮削补写标签或内嵌封面时,如果本地目标是硬链接或软链接,会先写入独立副本,
|
||||
全部成功后原子替换目标;源文件字节与权限保持不变。实际发生写入后,目标成为普通文件,
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user