mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
fix: 清理音乐刮削结果中 album_type 等字段的尾部空格 (#6327)
MusicBrainz 和 ListenBrainz 模块从 API 响应中提取 album_type、 secondary_types 时直接使用 dict.get() 取值,未做 strip() 处理, 导致部分结果带有尾部空格(如 'Album '、'Broadcast ')。 修复: - MusicBrainz 新增 _stripped() 工具方法,3 处 album_type 赋值统一清理 - ListenBrainz 新增 _stripped() 工具方法,_fresh_release_to_info 中清理 - Pydantic schema 层增加 field_validator 作为兜底安全网 - 新增 9 个回归测试覆盖所有场景
This commit is contained in:
@@ -265,10 +265,9 @@ class ListenBrainzModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
artist_name = str(release.get("artist_credit_name") or "").strip()
|
artist_name = str(release.get("artist_credit_name") or "").strip()
|
||||||
release_date = release.get("release_date") or None
|
release_date = release.get("release_date") or None
|
||||||
category_parts = [
|
primary_type = cls._stripped(release.get("release_group_primary_type"))
|
||||||
release.get("release_group_primary_type"),
|
secondary_type = cls._stripped(release.get("release_group_secondary_type"))
|
||||||
release.get("release_group_secondary_type"),
|
category_parts = [primary_type, secondary_type]
|
||||||
]
|
|
||||||
return MusicInfo(
|
return MusicInfo(
|
||||||
media_source=cls._source,
|
media_source=cls._source,
|
||||||
media_id=str(media_id),
|
media_id=str(media_id),
|
||||||
@@ -279,12 +278,12 @@ class ListenBrainzModule(_ModuleBase):
|
|||||||
album=str(title),
|
album=str(title),
|
||||||
album_artist=artist_name or None,
|
album_artist=artist_name or None,
|
||||||
album_id=str(media_id),
|
album_id=str(media_id),
|
||||||
album_type=release.get("release_group_primary_type") or None,
|
album_type=primary_type,
|
||||||
year=cls._year(release_date),
|
year=cls._year(release_date),
|
||||||
release_date=release_date,
|
release_date=release_date,
|
||||||
cover_url=cls._release_cover(release.get("caa_release_mbid"))
|
cover_url=cls._release_cover(release.get("caa_release_mbid"))
|
||||||
or cls._release_group_cover(media_id),
|
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],
|
genres=[str(tag) for tag in release.get("release_tags") or [] if tag],
|
||||||
names=[str(title)],
|
names=[str(title)],
|
||||||
detail_link=f"{cls._album_detail_url}/{media_id}",
|
detail_link=f"{cls._album_detail_url}/{media_id}",
|
||||||
@@ -321,6 +320,12 @@ class ListenBrainzModule(_ModuleBase):
|
|||||||
return []
|
return []
|
||||||
return [str(artist_mbids[0])] if artist_mbids and artist_mbids[0] else []
|
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
|
@staticmethod
|
||||||
def _year(release_date: Any) -> Optional[int]:
|
def _year(release_date: Any) -> Optional[int]:
|
||||||
"""从 ListenBrainz 发行日期提取年份。"""
|
"""从 ListenBrainz 发行日期提取年份。"""
|
||||||
|
|||||||
@@ -729,8 +729,8 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
title=str(title),
|
title=str(title),
|
||||||
artists=artists,
|
artists=artists,
|
||||||
artist_ids=artist_ids,
|
artist_ids=artist_ids,
|
||||||
album_type=release_group.get("primary-type"),
|
album_type=cls._stripped(release_group.get("primary-type")),
|
||||||
secondary_types=[str(item) for item in release_group.get("secondary-types") or []],
|
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,
|
release_date=detail.get("date") or None,
|
||||||
cover_url=cls._build_cover_url(group_id),
|
cover_url=cls._build_cover_url(group_id),
|
||||||
genres=cls._names_of(detail.get("genres")),
|
genres=cls._names_of(detail.get("genres")),
|
||||||
@@ -1387,8 +1387,8 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
album = (release or {}).get("title")
|
album = (release or {}).get("title")
|
||||||
artists, artist_ids = cls._artist_credits(recording.get("artist-credit"))
|
artists, artist_ids = cls._artist_credits(recording.get("artist-credit"))
|
||||||
album_artists, _ = cls._artist_credits((release or {}).get("artist-credit"))
|
album_artists, _ = cls._artist_credits((release or {}).get("artist-credit"))
|
||||||
category_parts = [release_group.get("primary-type")]
|
category_parts = [cls._stripped(release_group.get("primary-type"))]
|
||||||
category_parts.extend(release_group.get("secondary-types") or [])
|
category_parts.extend(cls._stripped(item) for item in release_group.get("secondary-types") or [])
|
||||||
return MusicInfo(
|
return MusicInfo(
|
||||||
media_source=cls._source,
|
media_source=cls._source,
|
||||||
media_id=str(media_id),
|
media_id=str(media_id),
|
||||||
@@ -1398,7 +1398,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
album=album,
|
album=album,
|
||||||
album_artist=" / ".join(album_artists) if album_artists else None,
|
album_artist=" / ".join(album_artists) if album_artists else None,
|
||||||
album_id=str(release_group["id"]) if release_group.get("id") 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),
|
year=cls._year(release_date),
|
||||||
release_date=release_date,
|
release_date=release_date,
|
||||||
duration=cls._duration_seconds(recording.get("length")),
|
duration=cls._duration_seconds(recording.get("length")),
|
||||||
@@ -1427,8 +1427,8 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
title=str(title),
|
title=str(title),
|
||||||
artists=artists,
|
artists=artists,
|
||||||
artist_ids=artist_ids,
|
artist_ids=artist_ids,
|
||||||
album_type=release_group.get("primary-type"),
|
album_type=cls._stripped(release_group.get("primary-type")),
|
||||||
secondary_types=[str(item) for item in release_group.get("secondary-types") or []],
|
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,
|
release_date=release_group.get("first-release-date") or None,
|
||||||
cover_url=cls._build_cover_url(media_id),
|
cover_url=cls._build_cover_url(media_id),
|
||||||
genres=cls._names_of(release_group.get("genres")),
|
genres=cls._names_of(release_group.get("genres")),
|
||||||
@@ -1627,6 +1627,12 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def _optional_int(value: Any) -> Optional[int]:
|
def _optional_int(value: Any) -> Optional[int]:
|
||||||
"""将 MusicBrainz 的碟号、音轨号等计数字段转换为可选整数。"""
|
"""将 MusicBrainz 的碟号、音轨号等计数字段转换为可选整数。"""
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
from typing import Literal, Optional, Union
|
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.common import JsonData
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||||
@@ -83,6 +83,12 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
overview: Optional[str] = None
|
overview: Optional[str] = None
|
||||||
vote_average: float = 0.0
|
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:
|
def __getattr__(self, name: str) -> None:
|
||||||
"""影视专用字段兜底返回 None:音乐模型不存在这些字段,避免下游逐点安全访问。
|
"""影视专用字段兜底返回 None:音乐模型不存在这些字段,避免下游逐点安全访问。
|
||||||
|
|
||||||
@@ -144,6 +150,12 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
overview: Optional[str] = None
|
overview: Optional[str] = None
|
||||||
vote_average: float = 0.0
|
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):
|
class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||||
"""标准化音乐艺术家信息。"""
|
"""标准化音乐艺术家信息。"""
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user