diff --git a/app/modules/listenbrainz/__init__.py b/app/modules/listenbrainz/__init__.py index 90a4b3105..fef75ca04 100644 --- a/app/modules/listenbrainz/__init__.py +++ b/app/modules/listenbrainz/__init__.py @@ -265,10 +265,9 @@ class ListenBrainzModule(_ModuleBase): return None artist_name = str(release.get("artist_credit_name") or "").strip() release_date = release.get("release_date") or None - category_parts = [ - release.get("release_group_primary_type"), - release.get("release_group_secondary_type"), - ] + primary_type = cls._stripped(release.get("release_group_primary_type")) + secondary_type = cls._stripped(release.get("release_group_secondary_type")) + category_parts = [primary_type, secondary_type] return MusicInfo( media_source=cls._source, media_id=str(media_id), @@ -279,12 +278,12 @@ class ListenBrainzModule(_ModuleBase): album=str(title), album_artist=artist_name or None, album_id=str(media_id), - album_type=release.get("release_group_primary_type") or None, + album_type=primary_type, year=cls._year(release_date), release_date=release_date, cover_url=cls._release_cover(release.get("caa_release_mbid")) or cls._release_group_cover(media_id), - category=" / ".join(str(part) for part in category_parts if part), + category=" / ".join(part for part in category_parts if part), genres=[str(tag) for tag in release.get("release_tags") or [] if tag], names=[str(title)], detail_link=f"{cls._album_detail_url}/{media_id}", @@ -321,6 +320,12 @@ class ListenBrainzModule(_ModuleBase): return [] return [str(artist_mbids[0])] if artist_mbids and artist_mbids[0] else [] + @staticmethod + def _stripped(value: Any) -> Optional[str]: + """去除 ListenBrainz 响应字段两端的空白,空值返回 None。""" + text = str(value).strip() if value is not None else "" + return text or None + @staticmethod def _year(release_date: Any) -> Optional[int]: """从 ListenBrainz 发行日期提取年份。""" diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 563d8301d..247b236e2 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -729,8 +729,8 @@ class MusicBrainzModule(_ModuleBase): title=str(title), artists=artists, artist_ids=artist_ids, - album_type=release_group.get("primary-type"), - secondary_types=[str(item) for item in release_group.get("secondary-types") or []], + album_type=cls._stripped(release_group.get("primary-type")), + secondary_types=[cls._stripped(item) for item in release_group.get("secondary-types") or [] if cls._stripped(item)], release_date=detail.get("date") or None, cover_url=cls._build_cover_url(group_id), genres=cls._names_of(detail.get("genres")), @@ -1387,8 +1387,8 @@ class MusicBrainzModule(_ModuleBase): album = (release or {}).get("title") artists, artist_ids = cls._artist_credits(recording.get("artist-credit")) album_artists, _ = cls._artist_credits((release or {}).get("artist-credit")) - category_parts = [release_group.get("primary-type")] - category_parts.extend(release_group.get("secondary-types") or []) + category_parts = [cls._stripped(release_group.get("primary-type"))] + category_parts.extend(cls._stripped(item) for item in release_group.get("secondary-types") or []) return MusicInfo( media_source=cls._source, media_id=str(media_id), @@ -1398,7 +1398,7 @@ class MusicBrainzModule(_ModuleBase): album=album, album_artist=" / ".join(album_artists) if album_artists else None, album_id=str(release_group["id"]) if release_group.get("id") else None, - album_type=release_group.get("primary-type"), + album_type=cls._stripped(release_group.get("primary-type")), year=cls._year(release_date), release_date=release_date, duration=cls._duration_seconds(recording.get("length")), @@ -1427,8 +1427,8 @@ class MusicBrainzModule(_ModuleBase): title=str(title), artists=artists, artist_ids=artist_ids, - album_type=release_group.get("primary-type"), - secondary_types=[str(item) for item in release_group.get("secondary-types") or []], + album_type=cls._stripped(release_group.get("primary-type")), + secondary_types=[cls._stripped(item) for item in release_group.get("secondary-types") or [] if cls._stripped(item)], release_date=release_group.get("first-release-date") or None, cover_url=cls._build_cover_url(media_id), genres=cls._names_of(release_group.get("genres")), @@ -1627,6 +1627,12 @@ class MusicBrainzModule(_ModuleBase): except (TypeError, ValueError): return None + @staticmethod + def _stripped(value: Any) -> Optional[str]: + """去除 MusicBrainz 响应字段两端的空白,空值返回 None。""" + text = str(value).strip() if value is not None else "" + return text or None + @staticmethod def _optional_int(value: Any) -> Optional[int]: """将 MusicBrainz 的碟号、音轨号等计数字段转换为可选整数。""" diff --git a/app/schemas/music.py b/app/schemas/music.py index cb43df5d8..9876a59ff 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -1,6 +1,6 @@ from typing import Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.schemas.common import JsonData from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin @@ -83,6 +83,12 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel): overview: Optional[str] = None vote_average: float = 0.0 + @field_validator("album_type", mode="before") + @classmethod + def _strip_album_type(cls, v: Optional[str]) -> Optional[str]: + """去除专辑类型字段两端的空白,防止数据源返回带空格的值。""" + return v.strip() if isinstance(v, str) and v.strip() else None + def __getattr__(self, name: str) -> None: """影视专用字段兜底返回 None:音乐模型不存在这些字段,避免下游逐点安全访问。 @@ -144,6 +150,12 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel): overview: Optional[str] = None vote_average: float = 0.0 + @field_validator("album_type", mode="before") + @classmethod + def _strip_album_type(cls, v: Optional[str]) -> Optional[str]: + """去除专辑类型字段两端的空白,防止数据源返回带空格的值。""" + return v.strip() if isinstance(v, str) and v.strip() else None + class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel): """标准化音乐艺术家信息。""" diff --git a/tests/test_music_album_type_strip.py b/tests/test_music_album_type_strip.py new file mode 100644 index 000000000..87d7cacf0 --- /dev/null +++ b/tests/test_music_album_type_strip.py @@ -0,0 +1,150 @@ +"""音乐刮削结果中 album_type 等字段不应包含两端空白(Issue #6327)。""" + +from app.modules.listenbrainz import ListenBrainzModule +from app.modules.musicbrainz import MusicBrainzModule +from app.schemas.music import MusicAlbumInfo as SchemaMusicAlbumInfo +from app.schemas.music import MusicInfo as SchemaMusicInfo + + +class TestMusicBrainzAlbumTypeStripped: + """MusicBrainz 模块应将 album_type 和 secondary_types 两端空白去除。""" + + def test_release_to_album_album_type_stripped(self): + """专辑详情中 primary-type 带尾部空格时应被清理。""" + detail = { + "id": "release-1", + "title": "Test Album", + "artist-credit": [{"name": "Artist", "artist": {"id": "a1"}}], + "release-group": { + "id": "rg-1", + "primary-type": "Album ", + "secondary-types": ["Compilation ", " Soundtrack"], + }, + "date": "2024-01-01", + "media": [], + } + + album = MusicBrainzModule._release_to_album(detail) + + assert album is not None + assert album.album_type == "Album" + assert album.secondary_types == ["Compilation", "Soundtrack"] + + def test_recording_to_info_album_type_stripped(self): + """歌曲识别结果中 album_type 和 category 不应包含空白。""" + recording = { + "id": "rec-1", + "title": "Test Song", + "artist-credit": [{"name": "Artist", "artist": {"id": "a1"}}], + "length": 300000, + "isrcs": ["USRC10000001"], + "genres": [], + "releases": [ + { + "id": "rel-1", + "title": "Test Single", + "artist-credit": [{"name": "Artist", "artist": {"id": "a1"}}], + "release-group": { + "id": "rg-1", + "primary-type": "Single ", + "secondary-types": [" Remix "], + }, + }, + ], + } + + info = MusicBrainzModule._recording_to_info(recording) + + assert info is not None + assert info.album_type == "Single" + assert info.category == "Single / Remix" + + def test_release_group_to_album_strips_types(self): + """Release Group 浏览结果中 album_type 和 secondary_types 应被清理。""" + release_group = { + "id": "rg-1", + "title": "Test EP", + "artist-credit": [{"name": "Artist", "artist": {"id": "a1"}}], + "primary-type": "EP ", + "secondary-types": ["Live ", " Compilation "], + "first-release-date": "2024", + "rating": {"value": "4", "votes-count": "10"}, + } + + album = MusicBrainzModule._release_group_to_album(release_group) + + assert album is not None + assert album.album_type == "EP" + assert album.secondary_types == ["Live", "Compilation"] + + +class TestListenBrainzAlbumTypeStripped: + """ListenBrainz 模块应将 album_type 和 category 两端空白去除。""" + + def test_fresh_release_to_info_strips_album_type(self): + """新发行条目中 release_group_primary_type 带空格时应被清理。""" + release = { + "release_group_mbid": "rg-1", + "release_name": "Test Album", + "artist_credit_name": "Artist", + "release_group_primary_type": "Album ", + "release_group_secondary_type": " Compilation ", + "release_date": "2024-01-01", + } + + info = ListenBrainzModule._fresh_release_to_info(release) + + assert info is not None + assert info.album_type == "Album" + assert info.category == "Album / Compilation" + + def test_fresh_release_to_info_handles_none_type(self): + """空白的 release_group_primary_type 应返回 None。""" + release = { + "release_group_mbid": "rg-1", + "release_name": "Test Album", + "artist_credit_name": "Artist", + "release_group_primary_type": " ", + "release_date": "2024-01-01", + } + + info = ListenBrainzModule._fresh_release_to_info(release) + + assert info is not None + assert info.album_type is None + + +class TestSchemaAlbumTypeValidator: + """Pydantic schema 应作为兜底清理 album_type 两端空白。""" + + def test_music_info_schema_strips_album_type(self): + """MusicInfo schema 构造时 album_type 尾部空格应被去除。""" + info = SchemaMusicInfo( + album_type="Broadcast ", + title="Test", + ) + assert info.album_type == "Broadcast" + + def test_music_album_info_schema_strips_album_type(self): + """MusicAlbumInfo schema 构造时 album_type 尾部空格应被去除。""" + album = SchemaMusicAlbumInfo( + album_type="EP ", + title="Test EP", + ) + assert album.album_type == "EP" + + def test_schema_blank_album_type_becomes_none(self): + """纯空白的 album_type 应被归一为 None。""" + info = SchemaMusicInfo( + album_type=" ", + title="Test", + ) + assert info.album_type is None + + def test_schema_none_album_type_stays_none(self): + """None 值的 album_type 应保持不变。""" + info = SchemaMusicInfo( + album_type=None, + title="Test", + ) + assert info.album_type is None