feat(music): group physical releases and preserve tagged release types

This commit is contained in:
jxxghp
2026-09-30 01:50:04 +08:00
parent 3ebd2c0153
commit b2f82a49d0
11 changed files with 608 additions and 143 deletions

View File

@@ -3,14 +3,20 @@ from typing import Any, Optional, Union
from uuid import UUID
from mutagen import File as MutagenFile
from mutagen import MutagenError
from mutagen.aiff import AIFF
from mutagen.apev2 import APEBinaryValue, APETextValue
from mutagen.dsdiff import DSDIFF
from mutagen.dsf import DSF
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC, ID3, SYLT, USLT
from mutagen.monkeysaudio import MonkeysAudio
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4, MP4Cover, MP4Tags
from mutagen.wave import WAVE
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
from app.domain.meta.metamusic import MetaMusic, parse_music_release_types
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
@@ -27,28 +33,38 @@ def _read_mp4_tags(tags: MP4Tags) -> dict[str, list[str]]:
values[key] = [f"{current}/{total}"]
normalized = {key.casefold(): value for key, value in tags.items()}
for name in ("MusicBrainz Track Id", "MusicBrainz Album Id", "MusicBrainz Release Group Id",
"MusicBrainz Release Track Id", "ISRC", "ORIGINALDATE", "ORIGINALYEAR", "VERSION", "SUBTITLE"):
"MusicBrainz Release Track Id", "MusicBrainz Album Type", "ISRC", "ORIGINALDATE",
"ORIGINALYEAR", "VERSION", "SUBTITLE", "RELEASETYPE"):
raw = normalized.get(f"----:com.apple.itunes:{name.casefold()}", [])
values[name.casefold().replace(" ", "_")] = [
value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
for value in raw
]
if "cpil" in tags:
values["compilation"] = ["1" if tags["cpil"] else "0"]
return values
def _mark_audio_evidence(meta: MetaMusic, info: Any) -> MetaMusic:
def _mark_audio_evidence(meta: MetaMusic, audio: Any) -> MetaMusic:
"""记录实际标签和流参数的来源,名称推测不能获得标签级可信度。"""
tag_fields = (
"title", "artists", "album", "album_artist", "year", "original_year", "release_year",
"disc_number", "track_number", "total_discs", "total_tracks", "version", "isrc",
"media_source", "media_id", "musicbrainz_release_id", "musicbrainz_release_group_id",
"musicbrainz_release_track_id",
"musicbrainz_release_track_id", "album_type", "secondary_types",
)
meta.field_sources = {key: "tag" for key in tag_fields if getattr(meta, key) not in (None, "", [])}
for key in ("bit_depth", "sample_rate", "bitrate", "duration"):
if getattr(meta, key) is not None:
meta.field_sources[key] = "stream"
if meta.audio_format:
info = getattr(audio, "info", None)
detected = next((name for kind, name in (
(FLAC, "FLAC"), (MP3, "MP3"), (WAVE, "WAV"), (AIFF, "AIFF"),
(MonkeysAudio, "APE"), (DSF, "DSD"), (DSDIFF, "DSD"),
) if isinstance(audio, kind)), None)
if detected:
meta.apply_audio_quality(detected, overwrite=True, evidence_source="stream")
elif meta.audio_format:
source = "stream" if getattr(info, "codec", None) or getattr(info, "codec_description", None) else "filename"
meta.field_sources.update(audio_format=source, audio_lossless=source)
return meta
@@ -87,7 +103,12 @@ class AudioMetadataHelper:
def read_tags(cls, path: Path) -> Optional[MetaMusic]:
"""只读取本地音频标签和流参数,不使用文件名或目录补齐。"""
try:
audio = MutagenFile(path, easy=False)
try:
audio = MutagenFile(path, easy=False)
except MutagenError:
# 错误后缀可能让 Mutagen 选错解析器;重试时保留流式读取,仅去掉扩展名提示。
with path.open("rb") as stream:
audio = MutagenFile(fileobj=stream, filename="audio", easy=False)
except Exception as err:
logger.warning(f"读取音频标签失败:{path} - {err}")
return None
@@ -116,12 +137,17 @@ class AudioMetadataHelper:
info = getattr(audio, "info", None)
original_year = cls._year(cls._first_of(tags, "originaldate", "originalyear"))
release_year = cls._year(cls._first_of(tags, "date", "year"))
release_types = [value for key in ("releasetype", "musicbrainz_albumtype", "musicbrainz_album_type")
for value in cls._values(tags, key)]
album_type, secondary_types = parse_music_release_types(release_types, cls._first(tags, "compilation"))
meta = MetaMusic(
org_string=path.name,
title=cls._first(tags, "title"),
artists=cls._values(tags, "artist"),
album=cls._first(tags, "album"),
album_artist=cls._first_of(tags, "albumartist", "album artist"),
album_type=album_type,
secondary_types=secondary_types,
year=release_year or original_year,
original_year=original_year,
release_year=release_year,
@@ -146,7 +172,7 @@ class AudioMetadataHelper:
musicbrainz_release_track_id=cls._normalize_musicbrainz_id(
cls._first_of(tags, "musicbrainz_releasetrackid", "musicbrainz_release_track_id")),
)
return _mark_audio_evidence(meta, info)
return _mark_audio_evidence(meta, audio)
@staticmethod
def _readable_tags(tags: Any) -> Any:
@@ -163,7 +189,7 @@ class AudioMetadataHelper:
"title": "TIT2", "artist": "TPE1", "album": "TALB",
"albumartist": "TPE2", "date": "TDRC", "originaldate": "TDOR",
"tracknumber": "TRCK", "discnumber": "TPOS", "isrc": "TSRC",
"subtitle": "TIT3",
"subtitle": "TIT3", "compilation": "TCMP",
}
values = {
key: [str(value) for frame in tags.getall(frame_id) for value in frame.text]
@@ -263,6 +289,7 @@ class AudioMetadataHelper:
org_string=path.name,
title=path.stem,
audio_format=path.suffix.lstrip(".").upper() or None,
field_sources={"audio_format": "filename", "audio_lossless": "filename"} if path.suffix else None,
).apply_path_context(path)
@classmethod

View File

@@ -30,6 +30,7 @@ def _album_directory_cache_key(
regions: tuple[str, ...],
scripts: tuple[str, ...],
contextual_meta: Optional[MetaMusic] = None,
file_scope: Optional[list[str]] = None,
) -> str:
"""将发行偏好及资源证据纳入缓存键,同目录更换种子线索时重新识别。"""
evidence = {
@@ -40,7 +41,7 @@ def _album_directory_cache_key(
evidence["artists"] = [evidence["album_artist"]]
if evidence and not any(evidence.values()):
evidence = None
return json.dumps([os.path.abspath(directory), regions, scripts, evidence], ensure_ascii=False, sort_keys=True)
return json.dumps([os.path.abspath(directory), regions, scripts, evidence, file_scope], ensure_ascii=False, sort_keys=True)
def _album_context_with_resource(
@@ -76,8 +77,15 @@ class MediaAlbumOwner(_MediaOwnerBase):
"""专辑目录扫描、曲目对齐与缓存编排 owner。"""
@classmethod
def _directory_audio_files(cls, directory: Path) -> list[Path]:
def _directory_audio_files(cls, directory: Path, file_paths: Optional[list[Path]] = None) -> list[Path]:
"""收集专辑目录及其一级碟片子目录中的音频文件。"""
if file_paths is not None:
root = directory.absolute()
return sorted({
path for raw_path in file_paths if ".." not in raw_path.parts
for path in (raw_path.absolute(),) if path.is_relative_to(root)
and path.is_file() and path.suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions
})
files: list[Path] = []
def collect(current: Path) -> None:
@@ -292,12 +300,13 @@ class MediaAlbumOwner(_MediaOwnerBase):
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
contextual_meta: Optional[MetaMusic] = None,
file_paths: Optional[list[Path]] = None,
) -> dict[str, MusicInfo]:
"""按目录级线索批量识别整张专辑并返回文件到曲目的映射。"""
directory = Path(path)
directory = Path(path).absolute()
if not directory.is_dir():
return {}
files = self._directory_audio_files(directory)
files = self._directory_audio_files(directory, file_paths) if file_paths is not None else self._directory_audio_files(directory)
if len(files) < self._album_match_min_files:
return {}
regions, scripts = self._music_release_preferences(
@@ -305,7 +314,8 @@ class MediaAlbumOwner(_MediaOwnerBase):
music_release_scripts,
)
custom_preference = music_release_regions is not None or music_release_scripts is not None
key = _album_directory_cache_key(directory, regions, scripts, contextual_meta)
scope = [str(file.relative_to(directory)) for file in files] if file_paths is not None else None
key = _album_directory_cache_key(directory, regions, scripts, contextual_meta, scope)
signature = self._album_directory_signature(directory, files)
matched = self._album_dir_cache.resolve(
key,
@@ -333,12 +343,16 @@ class MediaAlbumOwner(_MediaOwnerBase):
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
contextual_meta: Optional[MetaMusic] = None,
file_paths: Optional[list[Path]] = None,
) -> dict[str, MusicInfo]:
"""异步按目录级线索批量识别整张专辑。"""
directory = Path(path)
directory = Path(path).absolute()
if not await run_in_threadpool(_is_directory, directory):
return {}
files = await run_in_threadpool(self._directory_audio_files, directory)
files = (
await run_in_threadpool(self._directory_audio_files, directory, file_paths)
if file_paths is not None else await run_in_threadpool(self._directory_audio_files, directory)
)
if len(files) < self._album_match_min_files:
return {}
regions, scripts = self._music_release_preferences(
@@ -346,7 +360,8 @@ class MediaAlbumOwner(_MediaOwnerBase):
music_release_scripts,
)
custom_preference = music_release_regions is not None or music_release_scripts is not None
key = _album_directory_cache_key(directory, regions, scripts, contextual_meta)
scope = [str(file.relative_to(directory)) for file in files] if file_paths is not None else None
key = _album_directory_cache_key(directory, regions, scripts, contextual_meta, scope)
signature = self._album_directory_signature(directory, files)
matched = await self._album_dir_cache.async_resolve(
key,

View File

@@ -172,6 +172,7 @@ if TYPE_CHECKING:
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
contextual_meta: Optional[MetaMusic] = None,
file_paths: Optional[list[Path]] = None,
) -> dict[str, MusicInfo]:
"""同步识别音乐专辑目录。"""
...
@@ -192,6 +193,7 @@ if TYPE_CHECKING:
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
contextual_meta: Optional[MetaMusic] = None,
file_paths: Optional[list[Path]] = None,
) -> dict[str, MusicInfo]:
"""异步识别音乐专辑目录。"""
...

View File

@@ -20,6 +20,7 @@ from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer.contract import _TransferOwnerBase
from app.chain.transfer.music import (
MusicReleaseGroup,
prepare_music_batch_context,
resolve_music_batch_file_context,
restore_music_resource_meta,
@@ -180,6 +181,7 @@ class _MusicFileFilterBase(_TransferOwnerBase):
file_meta: MetaMusic,
music_release_regions: Optional[list[str]] = None,
music_release_scripts: Optional[list[str]] = None,
release_group: Optional[MusicReleaseGroup] = None,
) -> tuple[MetaMusic, Optional[MusicInfo]]:
"""为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。
@@ -191,10 +193,11 @@ class _MusicFileFilterBase(_TransferOwnerBase):
return file_meta, None
try:
matched = MediaChain().recognize_music_album_directory(
file_path.parent,
release_group.directory if release_group else file_path.parent,
music_release_regions=music_release_regions,
music_release_scripts=music_release_scripts,
contextual_meta=file_meta,
**({"file_paths": release_group.paths} if release_group else {}),
)
except Exception as err:
logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}")

View File

@@ -25,9 +25,36 @@ class MusicBatchContext:
related_main_keys: dict[Tuple[str, str], Tuple[str, str]] = field(default_factory=dict)
single_main_keys: set[Tuple[str, str]] = field(default_factory=set)
album_main_keys: set[Tuple[str, str]] = field(default_factory=set)
directory_evidence: dict[Tuple[str, str], MetaMusic] = field(default_factory=dict)
album_evidence_by_main_key: dict[Tuple[str, str], MetaMusic] = field(default_factory=dict)
resolved_contexts: dict[Tuple[str, str], tuple[MetaMusic, MusicInfo]] = field(default_factory=dict)
tags_by_file: dict[Tuple[str, str], Optional[MetaMusic]] = field(default_factory=dict)
release_by_main_key: dict[Tuple[str, str], "MusicReleaseGroup"] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MusicReleaseGroup:
"""同一物理发行单元的文件范围和仅供专辑级补缺的标签共识。"""
directory: Path
files: tuple[FileItem, ...]
evidence: Optional[MetaMusic]
@property
def paths(self) -> list[Path]:
"""返回完整音轨路径列表,路径失效时拒绝悄悄缩小识别范围。"""
return [_release_item_path(item) for item in self.files]
def _music_file_key(item: FileItem) -> Tuple[str, str]:
"""按存储与规范路径隔离标签快照,远端同名路径不能借用本机文件标签。"""
return item.storage or "local", str(Path(item.path)) if item.path else ""
def _release_item_path(item: FileItem) -> Path:
"""发行分组只接受已经解析的实际文件路径,不能把空路径解释成当前目录。"""
if not item.path:
raise ValueError("发行分组中的音轨缺少路径")
return Path(item.path)
def restore_music_resource_meta(
@@ -85,10 +112,10 @@ def restore_music_resource_meta(
return meta
def _music_directory_consensus(values: list[str]) -> Optional[str]:
def _music_directory_consensus(values: list[str], *, artist: bool = False) -> Optional[str]:
"""只在同目录至少两个标签高度一致时返回共识值。"""
normalized = [
(music_text_key(value), value.strip())
("variousartists" if artist and _collective_artist(value) else music_text_key(value), value.strip())
for value in values
if value and music_text_key(value)
]
@@ -101,72 +128,168 @@ def _music_directory_consensus(values: list[str]) -> Optional[str]:
return next(value for current_key, value in normalized if current_key == key)
def _music_directory_evidence(items: list[FileItem]) -> Optional[MetaMusic]:
def _music_directory_evidence(
items: list[FileItem], tags_by_file: dict[Tuple[str, str], Optional[MetaMusic]],
) -> Optional[MetaMusic]:
"""从同一发行目录的原始音频标签提取可信艺人与专辑共识。"""
artist_values: list[str] = []
track_artists: list[str] = []
album_values: list[str] = []
collective_keys = {
music_text_key(value)
for value in ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")
}
metas: list[MetaMusic] = []
for item in items:
if getattr(item, "storage", "local") != "local" or not item.path:
continue
tag_meta = AudioMetadataHelper.read_tags(Path(item.path))
tag_meta = tags_by_file.get(_music_file_key(item))
if not tag_meta:
continue
artist = tag_meta.album_artist
if not artist and len(tag_meta.artists) == 1:
artist = tag_meta.artists[0]
if artist and music_text_key(artist) not in collective_keys:
artist = _album_artist(tag_meta, _release_directory(item))
if artist:
artist_values.append(artist)
if tag_meta.album:
album_values.append(tag_meta.album)
artist = _music_directory_consensus(artist_values)
if len(tag_meta.artists) == 1:
track_artists.append(tag_meta.artists[0])
metas.append(tag_meta)
artist = _music_directory_consensus(artist_values, artist=True)
track_artist = _music_directory_consensus(track_artists, artist=True)
album = _music_directory_consensus(album_values)
if not artist and not album:
return None
return MetaMusic(
artists=[artist] if artist else None,
evidence = MetaMusic(
artists=[track_artist] if track_artist and not _collective_artist(track_artist) else None,
album_artist=artist,
album=album,
)
def _music_tagged_album_groups(
items: list[FileItem],
) -> list[tuple[list[FileItem], MetaMusic]]:
"""按精确的专辑艺人和专辑标签划分同目录内的多碟/多发行分组。"""
grouped: dict[tuple[str, str], tuple[list[FileItem], str, str]] = {}
collective_keys = {
music_text_key(value)
for value in ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")
}
for item in items:
if getattr(item, "storage", "local") != "local" or not item.path:
continue
tag_meta = AudioMetadataHelper.read_tags(Path(item.path))
if not tag_meta or not tag_meta.album:
continue
artist = tag_meta.album_artist
if not artist and len(tag_meta.artists) == 1:
artist = tag_meta.artists[0]
artist_key = music_text_key(artist)
album_key = music_text_key(tag_meta.album)
if not artist or artist_key in collective_keys or not album_key:
continue
key = (artist_key, album_key)
if key not in grouped:
grouped[key] = ([], artist.strip(), tag_meta.album.strip())
grouped[key][0].append(item)
return [
(
grouped_items,
MetaMusic(artists=[artist], album_artist=artist, album=album),
for key in ("musicbrainz_release_id", "musicbrainz_release_group_id", "album_type", "original_year", "release_year"):
values = {getattr(meta, key) for meta in metas if getattr(meta, key)}
if len(values) == 1:
setattr(evidence, key, next(iter(values)))
secondary = {tuple(meta.secondary_types) for meta in metas if meta.secondary_types}
if len(secondary) == 1:
evidence.secondary_types = list(next(iter(secondary)))
discs = [meta.total_discs or meta.disc_number or 1 for meta in metas]
discs.extend(MetaMusic.parse_disc_dir(_release_item_path(item).parent.name) or 1 for item in items)
evidence.total_discs = max(discs, default=1) if max(discs, default=1) > 1 else None
if evidence.total_discs:
evidence.field_sources["total_discs"] = (
"album_tags" if any(meta.total_discs == evidence.total_discs for meta in metas) else "directory"
)
for grouped_items, artist, album in grouped.values()
if len(grouped_items) >= 2
]
return evidence
def _collective_artist(value: Optional[str]) -> bool:
"""识别专辑层的群星署名,不把它复制成每首歌曲的演唱者。"""
return music_text_key(value) in {
music_text_key(name) for name in ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")
}
def _album_artist(meta: MetaMusic, directory: Path) -> Optional[str]:
"""专辑署名优先读 Album Artist,明确的合辑标志可补群星,但不改逐曲艺人。"""
if meta.album_artist:
return meta.album_artist
if "Compilation" in meta.secondary_types:
return "Various Artists"
parsed = MetaMusic.parse_album_dir(directory.name)
if _collective_artist(parsed.get("artist")) and music_text_key(parsed.get("album")) == music_text_key(meta.album):
return cast(str, parsed["artist"])
return meta.artists[0] if len(meta.artists) == 1 else None
def _release_signature(meta: MetaMusic, directory: Path) -> tuple[str, str, Any]:
"""缺少发行 ID 时,以专辑署名、标题和当前年份限定可关联的标签。"""
artist = _album_artist(meta, directory)
artist_key = "variousartists" if _collective_artist(artist) else music_text_key(artist)
return artist_key, music_text_key(meta.album), meta.release_year or meta.year
def _rendition_groups(group: list[FileItem], tags: dict[Tuple[str, str], Optional[MetaMusic]]) -> list[list[FileItem]]:
"""同一曲序存在多份时按实际编码区分版本,正常混合介质的多碟仍保持一组。"""
positions = Counter(
(meta.disc_number or MetaMusic.parse_disc_dir(_release_item_path(item).parent.name) or 1, meta.track_number)
for item in group if (meta := tags.get(_music_file_key(item))) and meta.track_number
)
if not any(count > 1 for count in positions.values()):
return [group]
renditions: dict[tuple[Any, ...], list[FileItem]] = {}
for item in group:
meta = tags.get(_music_file_key(item))
key = (meta.audio_format, meta.bit_depth, meta.sample_rate) if meta else (None,)
renditions.setdefault(key, []).append(item)
return list(renditions.values())
def _release_groups(items: list[FileItem], tags_by_file: dict[Tuple[str, str], Optional[MetaMusic]]) -> list[MusicReleaseGroup]:
"""优先按具体发行 ID 分组,缺 ID 时按专辑署名、标题及发行年隔离同目录多专辑。"""
grouped: dict[tuple[Any, ...], list[FileItem]] = {}
unassigned: list[FileItem] = []
known: dict[tuple[str, str, Any], set[str]] = {}
for item in items:
meta = tags_by_file.get(_music_file_key(item))
if meta and meta.musicbrainz_release_id:
known.setdefault(_release_signature(meta, _release_directory(item)), set()).add(meta.musicbrainz_release_id)
for item in items:
meta = tags_by_file.get(_music_file_key(item))
if not meta:
unassigned.append(item)
continue
signature = _release_signature(meta, _release_directory(item))
matching_ids = known.get(signature, set())
key: tuple[Any, ...]
if meta.musicbrainz_release_id:
key = ("release", meta.musicbrainz_release_id)
elif len(matching_ids) == 1:
key = ("release", next(iter(matching_ids)))
elif signature[0] and signature[1]:
key = ("tags", *signature)
else:
unassigned.append(item)
continue
grouped.setdefault(key, []).append(item)
if len(grouped) == 1:
existing = next(iter(grouped.values()))
album_names = {music_text_key(meta.album) for item in existing if (meta := tags_by_file.get(_music_file_key(item))) and meta.album}
album_artists = {_album_artist(meta, _release_directory(item)) for item in existing
if (meta := tags_by_file.get(_music_file_key(item)))}
artist_keys = {music_text_key(artist) for artist in album_artists if artist}
collective = any(_collective_artist(artist) for artist in album_artists)
directory = MetaMusic.parse_album_dir(_release_directory(existing[0]).name)
directory_matches = bool(
(directory.get("artist") or directory.get("year"))
and music_text_key(directory.get("album")) in album_names
)
enough_tags = len(existing) * 2 >= len(items) or directory_matches
remaining = []
for item in unassigned:
meta = tags_by_file.get(_music_file_key(item))
if not enough_tags:
remaining.append(item)
continue
if meta and not meta.album and meta.artists and not collective and not (
artist_keys & {music_text_key(artist) for artist in meta.artists}
):
remaining.append(item)
continue
if not meta or not meta.album or music_text_key(meta.album) in album_names:
existing.append(item)
else:
remaining.append(item)
unassigned = remaining
if unassigned:
grouped[("unassigned",)] = unassigned
output = []
for group in grouped.values():
for rendition in _rendition_groups(group, tags_by_file):
root = _release_directory(rendition[0])
evidence = _music_directory_evidence(rendition, tags_by_file)
output.append(MusicReleaseGroup(root, tuple(rendition), evidence))
return output
def _release_directory(item: FileItem) -> Path:
"""把 CD/Disc 子目录定位到所属专辑根,不跨越其它子专辑目录。"""
parent = _release_item_path(item).parent
return parent.parent if MetaMusic.parse_disc_dir(parent.name) is not None else parent
def _apply_music_directory_evidence(
@@ -186,11 +309,27 @@ def _apply_music_directory_evidence(
if not merged.album and evidence.album:
merged.album = evidence.album
merged.field_sources["album"] = "album_tags"
for key in ("musicbrainz_release_id", "musicbrainz_release_group_id", "total_discs", "album_type",
"original_year", "release_year"):
if not getattr(merged, key) and getattr(evidence, key):
setattr(merged, key, getattr(evidence, key))
merged.field_sources[key] = evidence.field_sources.get(key, "album_tags")
if not merged.secondary_types and evidence.secondary_types:
merged.secondary_types = list(evidence.secondary_types)
merged.field_sources["secondary_types"] = "album_tags"
if not merged.year and (merged.release_year or merged.original_year):
merged.year = cast(Any, merged.release_year or merged.original_year)
merged.field_sources["year"] = merged.field_sources.get("release_year", merged.field_sources.get("original_year", "album_tags"))
return merged
def _apply_music_directory_year(meta: MetaMusic, file_path: Path) -> MetaMusic:
"""用最近发行目录开头的四位年份纠正合集内不可靠的音频年份标签。"""
"""保留明确的标签发行年,仅用目录年份补正旧版未区分语义的 year。"""
if meta.release_year and meta.field_sources.get("release_year") in {"tag", "album_tags"}:
merged = deepcopy(meta)
merged.year = cast(Any, meta.release_year)
merged.field_sources["year"] = meta.field_sources["release_year"]
return merged
for parent in list(file_path.parents)[:4]:
match = re.match(r"^((?:19|20)\d{2})(?:\D|$)", parent.name)
if not match:
@@ -206,11 +345,16 @@ def _local_music_context(
owner: _TransferOwnerBase,
file_item: FileItem,
file_path: Path,
batch_context: MusicBatchContext,
) -> Optional[tuple[MetaMusic, MusicInfo]]:
"""仅以本地真实标签建立快速整理上下文,不向在线来源确认身份。"""
if file_item.storage != "local" or not owner._is_audio_file(file_item):
return None
tags = AudioMetadataHelper.read_tags(file_path)
tags = batch_context.tags_by_file.get(_music_file_key(file_item)) if _music_file_key(file_item) in batch_context.tags_by_file else AudioMetadataHelper.read_tags(file_path)
if tags:
tags = deepcopy(tags)
if owner._get_file_key(file_item) in batch_context.album_main_keys:
tags = _apply_music_directory_evidence(tags, batch_context.album_evidence_by_main_key.get(owner._get_file_key(file_item)))
if tags is None or not music_tags_are_usable(tags):
return None
meta = tags.apply_path_context(file_path)
@@ -225,35 +369,34 @@ def prepare_music_batch_context(
) -> MusicBatchContext:
"""建立同目录音轨、歌词和单音轨目录的批次索引。"""
context = MusicBatchContext()
if batch_mtype != MediaType.MUSIC:
if batch_mtype not in (None, MediaType.MUSIC):
return context
main_items_by_dir: dict[Tuple[str, str], list[FileItem]] = {}
release_items: dict[Tuple[str, str], list[FileItem]] = {}
for current_item, current_bluray_dir in file_items:
if not current_bluray_dir and owner._is_media_file(current_item, MediaType.MUSIC):
main_items_by_dir.setdefault(
owner._get_file_parent_key(current_item), []
).append(current_item)
if current_item.path:
release_items.setdefault((current_item.storage or "local", str(_release_directory(current_item))), []).append(current_item)
if current_item.storage == "local":
context.tags_by_file[_music_file_key(current_item)] = AudioMetadataHelper.read_tags(Path(current_item.path))
context.single_main_keys = {
owner._get_file_key(items[0])
for items in main_items_by_dir.values()
for items in release_items.values()
if len(items) == 1
}
for parent_key, items in main_items_by_dir.items():
evidence = _music_directory_evidence(items)
if evidence:
context.directory_evidence[parent_key] = evidence
# 两首及以上音轨,且目录内艺人和专辑标签均达到高一致性时,足以在
# 远端临时不可用时证明这是一个专辑目录;不能把整个艺术家合集根目录
# 当成一张专辑,也不能仅凭文件夹名称猜测类别。
if evidence and len(items) > 1 and evidence.artists and evidence.album:
context.album_main_keys.update(
owner._get_file_key(item) for item in items
)
for grouped_items, grouped_evidence in _music_tagged_album_groups(items):
for item in grouped_items:
for items in release_items.values():
for group in _release_groups(items, context.tags_by_file):
evidence = group.evidence
for item in group.files:
item_key = owner._get_file_key(item)
context.album_main_keys.add(item_key)
context.album_evidence_by_main_key[item_key] = grouped_evidence
context.release_by_main_key[item_key] = group
if evidence:
context.album_evidence_by_main_key[item_key] = evidence
if evidence and len(group.files) > 1 and evidence.album and (evidence.album_artist or evidence.artists):
context.album_main_keys.add(item_key)
for current_item, _current_bluray_dir in file_items:
if not owner._is_music_lyrics_file(current_item):
continue
@@ -266,6 +409,55 @@ def prepare_music_batch_context(
return context
def _finalize_music_group_context(
owner: _TransferOwnerBase,
context: MusicBatchContext,
item: FileItem,
meta: Any,
info: Optional[Union[MediaInfo, MusicInfo]],
*,
local: bool,
preserve_selection: bool,
) -> tuple[Any, Optional[Union[MediaInfo, MusicInfo]]]:
"""统一同一发行的专辑名称与分类,保留各曲艺人及用户明确选择。"""
if not isinstance(meta, MetaMusic) or not isinstance(info, MusicInfo):
return meta, info
key = owner._get_file_key(item)
grouped = key in context.album_main_keys and not preserve_selection
if grouped:
meta, info = deepcopy(meta), deepcopy(info)
evidence = context.album_evidence_by_main_key.get(key)
for name in ("album", "album_artist", "total_discs"):
value = getattr(evidence, name, None)
if value:
setattr(meta, name, value)
setattr(info, name, value)
source = evidence.field_sources.get(name, "album_tags") if evidence else "album_tags"
meta.field_sources[name] = info.field_sources[name] = source
if meta.year:
info.year = meta.year
if meta.album_type:
info.album_type = meta.album_type
info.field_sources["album_type"] = meta.field_sources.get("album_type", "album_tags")
elif str(info.album_type or "").casefold() not in {"ep", "broadcast", "other"}:
# 未声明具体类型的多音轨标签共识沿用 Album 兜底,不能覆盖已声明的 EP/Single。
info.album_type = "Album"
info.field_sources["album_type"] = "album_tags"
if meta.secondary_types:
info.secondary_types = list(meta.secondary_types)
info.field_sources["secondary_types"] = meta.field_sources.get("secondary_types", "album_tags")
if local or grouped:
finalized = owner._finalize_recognition_result(
info, **({"allow_enrichment": False} if local else {"refresh": True}),
)
if isinstance(finalized, MusicInfo):
info = finalized
if not local and grouped and not info.classification and info.album_type == "Album":
# 没有装配分类服务的旧调用方保留兼容目录;真实策略结果不能被硬编码覆盖。
info.set_library_category("Album")
return meta, info
def resolve_music_batch_file_context(
owner: _TransferOwnerBase,
*,
@@ -298,8 +490,9 @@ def resolve_music_batch_file_context(
selected_tracks,
fallback,
)
selected_context = task_mediainfo is not None
local_context = (
_local_music_context(owner, file_item, file_path)
_local_music_context(owner, file_item, file_path, batch_context)
if not task_mediainfo and isinstance(file_meta, MetaMusic)
else None
)
@@ -309,8 +502,7 @@ def resolve_music_batch_file_context(
file_key = owner._get_file_key(file_item)
file_meta = _apply_music_directory_evidence(
file_meta,
batch_context.album_evidence_by_main_key.get(file_key)
or batch_context.directory_evidence.get(owner._get_file_parent_key(file_item)),
batch_context.album_evidence_by_main_key.get(file_key),
)
if discard_shared_identity and not local_context:
file_meta = _apply_music_directory_year(file_meta, file_path)
@@ -334,51 +526,10 @@ def resolve_music_batch_file_context(
if isinstance(task_mediainfo, MusicInfo) and file_meta.year:
task_mediainfo = deepcopy(task_mediainfo)
task_mediainfo.year = file_meta.year
directory_evidence = (
batch_context.album_evidence_by_main_key.get(owner._get_file_key(file_item))
or batch_context.directory_evidence.get(owner._get_file_parent_key(file_item))
file_meta, task_mediainfo = _finalize_music_group_context(
owner, batch_context, file_item, file_meta, task_mediainfo,
local=bool(local_context), preserve_selection=selected_context,
)
if (
owner._is_audio_file(file_item)
and isinstance(file_meta, MetaMusic)
and isinstance(task_mediainfo, MusicInfo)
and owner._get_file_key(file_item) in batch_context.album_main_keys
and directory_evidence
):
# 同一物理发行目录只能生成一个专辑目录。逐曲远端识别可能因服务
# 波动而混用正式标题、别名或本地兜底;用已验证的目录标签共识统一
# 专辑和专辑艺人,但保留每首曲目的远端 ID、曲名、封面等信息。
file_meta = deepcopy(file_meta)
task_mediainfo = deepcopy(task_mediainfo)
if directory_evidence.album:
file_meta.album = directory_evidence.album
task_mediainfo.album = directory_evidence.album
file_meta.field_sources["album"] = task_mediainfo.field_sources["album"] = "album_tags"
if directory_evidence.album_artist:
file_meta.album_artist = directory_evidence.album_artist
task_mediainfo.album_artist = directory_evidence.album_artist
file_meta.field_sources["album_artist"] = task_mediainfo.field_sources["album_artist"] = "album_tags"
if file_meta.year:
task_mediainfo.year = file_meta.year
if (
owner._is_audio_file(file_item)
and isinstance(task_mediainfo, MusicInfo)
and owner._get_file_key(file_item) in batch_context.album_main_keys
and str(task_mediainfo.album_type or "").casefold()
not in {"ep", "broadcast", "other"}
):
# 曲目可能同时作为 Single 单独发行。整理完整多音轨目录时,目录内
# 高一致性的专辑/专辑艺人标签是当前文件归属的更强证据,不能让某一
# 首歌的单曲发行身份把同一张专辑拆进 Single 分类。
task_mediainfo = deepcopy(task_mediainfo)
task_mediainfo.album_type = "Album"
if not local_context:
finalized = owner._finalize_recognition_result(task_mediainfo, refresh=True)
if isinstance(finalized, MusicInfo):
task_mediainfo = finalized
task_mediainfo.set_library_category("Album")
if local_context:
task_mediainfo = owner._finalize_recognition_result(task_mediainfo, allow_enrichment=False)
if (
owner._is_audio_file(file_item)
and isinstance(file_meta, MetaMusic)
@@ -410,6 +561,7 @@ def _recognize_music_batch_file(
file_meta,
release_regions,
release_scripts,
release_group=batch_context.release_by_main_key.get(owner._get_file_key(file_item)),
)
if not task_mediainfo and discard_shared_identity and owner._is_audio_file(file_item):
file_meta, task_mediainfo = owner._match_music_recording_context(
@@ -425,23 +577,23 @@ def _recognize_music_batch_file(
multi_track_batch
and owner._get_file_key(file_item) in batch_context.album_main_keys
):
task_mediainfo.album_type = "Album"
finalized = owner._finalize_recognition_result(task_mediainfo)
task_mediainfo.album_type = file_meta.album_type or "Album"
finalized = owner._finalize_recognition_result(task_mediainfo, allow_enrichment=False)
if isinstance(finalized, MusicInfo):
task_mediainfo = finalized
if not task_mediainfo.library_category:
task_mediainfo.set_library_category("Album")
if not task_mediainfo.library_category and not task_mediainfo.classification:
task_mediainfo.set_library_category(task_mediainfo.album_type)
return file_meta, task_mediainfo
if owner._get_file_key(file_item) not in batch_context.single_main_keys:
return file_meta, task_mediainfo
# 远端识别均未命中时,仅单音轨子目录可安全按 Single 兜底;
# 多音轨目录仍保持未识别,避免把缺失专辑误判为单曲。
task_mediainfo.album_type = "Single"
finalized = owner._finalize_recognition_result(task_mediainfo)
task_mediainfo.album_type = file_meta.album_type or "Single"
finalized = owner._finalize_recognition_result(task_mediainfo, allow_enrichment=False)
if isinstance(finalized, MusicInfo):
task_mediainfo = finalized
if not task_mediainfo.library_category:
if not task_mediainfo.library_category and not task_mediainfo.classification:
# 隔离测试可能没有装配分类服务,保留旧分类路径作为测试兼容。
task_mediainfo.set_library_category("Single")
task_mediainfo.set_library_category(task_mediainfo.album_type)
return file_meta, task_mediainfo

View File

@@ -846,6 +846,8 @@ class MusicInfo:
media_source=normalize_media_source(meta.media_source),
media_id=meta.media_id,
music_type=meta.music_type or MUSIC_ENTITY_RECORDING,
album_type=meta.album_type,
secondary_types=list(meta.secondary_types),
musicbrainz_release_id=meta.musicbrainz_release_id,
musicbrainz_release_group_id=meta.musicbrainz_release_group_id,
musicbrainz_release_track_id=meta.musicbrainz_release_track_id,

View File

@@ -45,6 +45,28 @@ _LOSSY_AUDIO_FORMATS = frozenset({"MP3", "AAC", "OGG", "OPUS", "WMA"})
logger = logging.getLogger(__name__)
def parse_music_release_types(values: Any, compilation: Optional[str] = None) -> tuple[Optional[str], list[str]]:
"""将标签中的发行主副类型归一,保留 EP/Single,不从曲目数推断类型。"""
primary_names = {"album": "Album", "studio album": "Album", "专辑": "Album", "專輯": "Album",
"single": "Single", "单曲": "Single", "單曲": "Single", "ep": "EP",
"broadcast": "Broadcast", "other": "Other"}
secondary_names = {"compilation": "Compilation", "精选集": "Compilation", "合辑": "Compilation",
"soundtrack": "Soundtrack", "ost": "Soundtrack", "原声": "Soundtrack",
"live": "Live", "现场": "Live", "remix": "Remix", "dj-mix": "DJ-mix",
"mixtape": "Mixtape/Street", "spokenword": "Spokenword"}
primary: Optional[str] = None
secondary: list[str] = []
for value in _string_list(values):
for token in re.split(r"[;,/+\\]", value):
key = token.strip().casefold()
primary = primary or primary_names.get(key)
if key in secondary_names and secondary_names[key] not in secondary:
secondary.append(secondary_names[key])
if str(compilation or "").strip().casefold() in {"1", "true", "yes"} and "Compilation" not in secondary:
secondary.append("Compilation")
return primary, secondary
def normalize_audio_format(value: Any) -> Optional[str]:
"""将音频格式名称归一为订阅筛选和展示使用的规范值。"""
text = str(value or "").strip().upper()
@@ -735,6 +757,8 @@ class MetaMusic(MetaBase):
release_year: Optional[int] = None,
field_sources: Optional[dict[str, str]] = None,
music_type: Optional[str] = None,
album_type: Optional[str] = None,
secondary_types: Optional[list[str]] = None,
):
"""初始化音乐标题、标签、音频规格和统一媒体身份。"""
# 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别
@@ -753,6 +777,8 @@ class MetaMusic(MetaBase):
self.musicbrainz_release_track_id = musicbrainz_release_track_id
self.field_sources = dict(field_sources or {})
self.music_type = music_type
self.album_type = album_type
self.secondary_types = list(secondary_types or [])
self.disc_number = disc_number
self.track_number = track_number
self.total_discs = total_discs
@@ -781,6 +807,8 @@ class MetaMusic(MetaBase):
self.__dict__.setdefault(key, None)
self.field_sources = dict(state.get("field_sources") or {})
self.music_type = state.get("music_type")
self.album_type = state.get("album_type")
self.secondary_types = list(state.get("secondary_types") or [])
@classmethod
def parse_query(cls, query: str) -> "MetaMusic":
@@ -1006,6 +1034,8 @@ class MetaMusic(MetaBase):
media_source=info.media_source,
media_id=info.media_id,
music_type=getattr(info, "music_type", None),
album_type=getattr(info, "album_type", None),
secondary_types=getattr(info, "secondary_types", None),
musicbrainz_release_id=getattr(info, "musicbrainz_release_id", None),
musicbrainz_release_group_id=getattr(info, "musicbrainz_release_group_id", None),
musicbrainz_release_track_id=getattr(info, "musicbrainz_release_track_id", None),
@@ -1710,7 +1740,7 @@ class MetaMusic(MetaBase):
def parse_disc_dir(cls, name: str) -> Optional[int]:
"""识别 CD1、Disc 2 这类碟片子目录并返回碟号。"""
match = _MUSIC_DISC_DIR_RE.match(str(name or "").strip())
return int(match.group("num")) if match else None
return int(match.group("num")) if match and int(match.group("num")) > 0 else None
@staticmethod
def _normalize_album_year(value: str) -> str:
@@ -1912,6 +1942,8 @@ class MetaMusic(MetaBase):
"release_year": self.release_year,
"field_sources": dict(self.field_sources),
"music_type": self.music_type,
"album_type": self.album_type,
"secondary_types": list(self.secondary_types),
}
@classmethod
@@ -1948,6 +1980,8 @@ class MetaMusic(MetaBase):
release_year=_optional_int(data.get("release_year")),
field_sources=data.get("field_sources"),
music_type=data.get("music_type"),
album_type=data.get("album_type"),
secondary_types=_string_list(data.get("secondary_types")),
)
meta.apply_words = data.get("apply_words")
return meta

View File

@@ -13,6 +13,8 @@ class MusicMeta(OptionalMediaIdentityMixin, BaseModel):
type: Literal["音乐"] = "音乐"
music_type: Optional[MusicEntityType] = Field(default=None, description="已知主身份的实体类型,未知时不推断")
album_type: Optional[str] = Field(default=None, description="标签声明的发行主类型,例如 Album、EP、Single")
secondary_types: list[str] = Field(default_factory=list, description="标签声明的 Compilation、Soundtrack 等副类型")
org_string: Optional[str] = None
apply_words: list[str] = Field(default_factory=list, description="资源解析时实际应用的自定义识别词")
title: Optional[str] = None

View File

@@ -512,6 +512,8 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身
音乐识别结果同时提供 `audio_format`、`audio_lossless`、`audio_quality`、`bit_depth`、`sample_rate`、`bitrate`、`audio_specs` 和 `audio_quality_score`。本地文件识别读取实际音频流参数,并使用 Chromaprint 的 `fpcalc` 在本地生成指纹后查询 AcoustID;音频文件本身不会上传。站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
`MusicMeta.music_type` 保留已绑定主身份的实体类型;仅从名称推测时可为空。
`MusicMeta.album_type` 和 `secondary_types` 保留标签声明的发行主副类型,例如 EP、Single、Compilation;
它们与 recording/album/artist 实体类型不同,不能根据曲目数量覆盖明确的标签类型。
`media_source`、`media_id` 与 `music_type` 必须一起解释,不能把专辑 ID 当作 Recording ID。
补充字段 `musicbrainz_release_id`、`musicbrainz_release_group_id`、`musicbrainz_release_track_id`
分别表示具体发行、发行组和发行中的曲目,不能替代主身份。`original_year` 与 `release_year`

View File

@@ -37,6 +37,9 @@ Music recognition, exploration, album and artist navigation, and recognition-cac
recording ID. The optional `musicbrainz_release_id`,
`musicbrainz_release_group_id`, and `musicbrainz_release_track_id` refer to
distinct MusicBrainz entities and never replace the primary identity.
- `MusicMeta.album_type` and `secondary_types` retain declared release types,
including EP, Single, and Compilation. These differ from the recording/album/
artist entity type; track count alone must not override a declared release type.
- `original_year` and `release_year` distinguish the original and current
edition; `year` remains the display-compatible value. A release group's first
release date does not prove the current edition. Preserve `total_discs` and

View File

@@ -0,0 +1,223 @@
"""按真实发行单元整理多碟、群星和全集,保持标签类型与文件查询范围。"""
import shutil
from pathlib import Path
from unittest.mock import Mock
import pytest
from mutagen.flac import FLAC
from mutagen.id3 import TPOS, TXXX
from app.application.audio import AudioMetadataHelper
from app.chain.media import MediaChain
from app.chain.media.cache import AlbumDirectoryCache
from app.domain.meta.metamusic import MetaMusic
from app.schemas.types import MediaType
from tests.test_music_identity_evidence import _tag_audio
from tests.test_transfer_sync_extra_files import make_fileitem, make_transfer_chain
RELEASE_A = "11111111-1111-4111-8111-111111111111"
RELEASE_B = "22222222-2222-4222-8222-222222222222"
def _audio(path: Path, *, artist="Artist", album="Record", release=None, release_type=None, disc=None, compilation=False):
"""写入真实 FLAC 标签,省略总碟数以验证目录补缺。"""
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", path)
audio = FLAC(path)
audio["title"] = [path.stem]
audio["tracknumber"] = ["1"]
audio["date"] = ["2003"]
for key, value in (("artist", artist), ("album", album), ("discnumber", disc),
("musicbrainz_albumid", release), ("releasetype", release_type)):
if value is not None:
audio[key] = [str(value)]
if compilation:
audio["compilation"] = ["1"]
audio.save()
return make_fileitem(str(path))
def _prepare(items, mtype=MediaType.MUSIC):
"""构建隔离整理批次,保留生产读取及分组函数。"""
owner = make_transfer_chain()
context = owner._prepare_music_batch_context([(item, False) for item in items], mtype)
return owner, context
def _resolve(owner, context, item):
"""从批次读取真实文件的最终局部音乐上下文,不执行文件副作用。"""
return owner._resolve_music_batch_file_context(
batch_context=context, file_item=item, file_path=Path(item.path),
file_meta=AudioMetadataHelper.read(Path(item.path)), selected_tracks={}, fallback=None,
discard_shared_identity=True, multi_track_batch=True, release_regions=None, release_scripts=None,
)
@pytest.mark.parametrize("mtype", [None, MediaType.MUSIC])
def test_disc_directories_share_release_and_keep_disc_paths(tmp_path, mtype):
"""自动与显式音乐目录的 CD1/CD2 属于同一发行,总碟数缺失也保留分碟。"""
items = [_audio(tmp_path / "Record" / f"CD{disc}" / "Song.flac", release=RELEASE_A)
for disc in (1, 2)]
owner, context = _prepare(items, mtype)
groups = [context.release_by_main_key[owner._get_file_key(item)] for item in items]
assert groups[0] is groups[1]
assert groups[0].directory == tmp_path / "Record"
assert context.single_main_keys == set()
for disc, item in enumerate(items, 1):
meta, info = _resolve(owner, context, item)
assert meta.disc_number == info.disc_number == disc
assert meta.total_discs == info.total_discs == 2
assert meta.field_sources["total_discs"] == "directory"
def test_compilation_groups_different_performers_without_replacing_them(tmp_path):
"""明确合辑标签可补专辑艺人为群星,各曲真实艺人不得被多数票覆盖。"""
items = [_audio(tmp_path / "Compilation" / f"Song {index}.flac", artist=artist, compilation=True)
for index, artist in enumerate(("Artist A", "Artist B"), 1)]
owner, context = _prepare(items)
for item, artist in zip(items, ("Artist A", "Artist B")):
meta, info = _resolve(owner, context, item)
assert meta.artists == info.artists == [artist]
assert meta.album_artist == info.album_artist == "Various Artists"
assert "Compilation" in info.secondary_types
@pytest.mark.parametrize("release_type,expected", [("EP", "EP"), ("single", "Single"), ("album / soundtrack", "Album")])
def test_explicit_release_type_survives_local_batch(tmp_path, release_type, expected):
"""多音轨目录不能把标签已明确的 EP 或 Single 强制改成 Album。"""
items = [_audio(tmp_path / "Record" / f"Song {index}.flac", release_type=release_type) for index in (1, 2)]
owner, context = _prepare(items)
for item in items:
meta, info = _resolve(owner, context, item)
assert meta.album_type == info.album_type == expected
if "soundtrack" in release_type:
assert info.secondary_types == ["Soundtrack"]
def test_collection_child_albums_and_same_name_editions_stay_separate(tmp_path):
"""全集子专辑和同目录不同 Release ID 都不能被合成一个发行单元。"""
items = [
_audio(tmp_path / "Collection" / "2003 - First" / "Song.flac", album="First"),
_audio(tmp_path / "Collection" / "2004 - Second" / "Song.flac", album="Second"),
_audio(tmp_path / "Editions" / "A.flac", release=RELEASE_A),
_audio(tmp_path / "Editions" / "B.flac", release=RELEASE_B),
]
owner, context = _prepare(items)
groups = [context.release_by_main_key[owner._get_file_key(item)] for item in items]
assert len({tuple(item.path for item in group.files) for group in groups}) == 4
def test_partial_tags_use_consistent_album_peers_without_online_lookup(tmp_path, monkeypatch):
"""同发行有足够一致标签时,只有曲名的文件可用邻居标签补专辑而不重复联网。"""
items = [_audio(tmp_path / "Record" / f"Song {index}.flac") for index in (1, 2)]
items.append(_audio(tmp_path / "Record" / "Third Song.flac", artist=None, album=None))
owner, context = _prepare(items)
online = Mock(side_effect=AssertionError("已确认的标签组不应重新联网"))
monkeypatch.setattr(owner, "_match_music_album_context", online)
meta, info = _resolve(owner, context, items[-1])
assert meta.title == info.title == "Third Song"
assert meta.album == info.album == "Record"
assert meta.artists == ["Artist"]
assert meta.field_sources["album"] == "album_tags"
online.assert_not_called()
def test_tag_scan_is_shared_by_grouping_and_local_resolution(tmp_path, monkeypatch):
"""同一批次的分组与快速整理复用纯标签快照,避免各分组重复读取。"""
items = [_audio(tmp_path / "Record" / f"Song {index}.flac") for index in (1, 2)]
reader = Mock(wraps=AudioMetadataHelper.read_tags)
monkeypatch.setattr(AudioMetadataHelper, "read_tags", reader)
owner, context = _prepare(items)
for item in items:
owner._resolve_music_batch_file_context(
batch_context=context, file_item=item, file_path=Path(item.path),
file_meta=MetaMusic(title=Path(item.path).stem), selected_tracks={}, fallback=None,
discard_shared_identity=True, multi_track_batch=True, release_regions=None, release_scripts=None,
)
assert reader.call_count == 2
def test_album_cache_separates_file_scopes_in_one_directory(tmp_path, monkeypatch):
"""同名同年的两组文件各自缓存结果,不能交替覆盖成重复查询或串用映射。"""
items = [_audio(tmp_path / f"Song {index}.flac") for index in range(4)]
chain = MediaChain()
monkeypatch.setattr(chain, "_album_dir_cache", AlbumDirectoryCache(16))
source = Mock(match_music_album=Mock(return_value=None))
monkeypatch.setattr("app.chain.media.album.MusicBrainzChain", Mock(return_value=source))
first = [Path(item.path) for item in items[:2]]
second = [Path(item.path) for item in items[2:]]
for selected in (first, second, first):
chain.recognize_music_album_directory(tmp_path, file_paths=selected)
assert source.match_music_album.call_count == 2
assert [len(call.args[1]) for call in source.match_music_album.call_args_list] == [2, 2]
def test_container_header_overrides_misleading_extension(tmp_path):
"""MP3 即使命名为 FLAC 也不能被标作无损音频。"""
path = tmp_path / "misnamed.flac"
shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.mp3", path)
meta = AudioMetadataHelper.read(path)
assert meta.audio_format == "MP3"
assert meta.audio_lossless is False
assert meta.field_sources["audio_format"] == "stream"
def test_tag_cache_never_crosses_storage_namespaces(tmp_path):
"""云盘同名逻辑路径不能借用本地音频的标签或发行分组。"""
local = _audio(tmp_path / "Song.flac")
remote = local.model_copy(update={"storage": "alist"})
owner, context = _prepare([local, remote])
assert len(context.tags_by_file) == 1
assert context.release_by_main_key[owner._get_file_key(remote)].evidence is None
@pytest.mark.parametrize("different_disc,expected_groups", [(False, 2), (True, 1)])
def test_duplicate_positions_split_encodings_but_mixed_discs_do_not(tmp_path, different_disc, expected_groups):
"""重复曲序的两种编码独立匹配,混合编码但不同碟号的同一发行保留一组。"""
paths = [tmp_path / "Song.flac", tmp_path / "Song.mp3"]
for path in paths:
shutil.copyfile(Path(__file__).parent / f"fixtures/audio/silence{path.suffix}", path)
_tag_audio(path)
if different_disc:
from mutagen.mp3 import MP3
audio = MP3(paths[1])
audio.tags.add(TPOS(encoding=3, text=["1/2"]))
audio.tags.add(TXXX(encoding=3, desc="MusicBrainz Release Track Id", text=["55555555-5555-4555-8555-555555555555"]))
audio.save()
items = [make_fileitem(str(path)) for path in paths]
owner, context = _prepare(items)
groups = {tuple(file.path for file in context.release_by_main_key[owner._get_file_key(item)].files) for item in items}
assert len(groups) == expected_groups
def test_missing_album_with_conflicting_artist_does_not_gain_peer_tags(tmp_path):
"""已知艺人冲突时,不能给未声明专辑的文件套上邻居的专辑标签。"""
items = [_audio(tmp_path / f"Song {index}.flac") for index in (1, 2)]
items.append(_audio(tmp_path / "Unrelated.flac", artist="Different Artist", album=None))
owner, context = _prepare(items)
assert owner._get_file_key(items[-1]) not in context.album_main_keys
assert context.release_by_main_key[owner._get_file_key(items[-1])].files == (items[-1],)
def test_sparse_tags_do_not_label_an_entire_mixed_inbox(tmp_path):
"""普通收件目录的少数标签不能把大量未知音轨都绑定到同一专辑。"""
items = [_audio(tmp_path / "inbox" / f"Tagged {index}.flac") for index in (1, 2)]
items += [_audio(tmp_path / "inbox" / f"Unknown {index}.flac", artist=None, album=None) for index in range(6)]
owner, context = _prepare(items)
assert all(owner._get_file_key(item) not in context.album_main_keys for item in items[2:])