fix(v3): complete music recognition and scraping

This commit is contained in:
jxxghp
2026-08-07 23:46:54 +08:00
parent 4f22ceb1a6
commit c356d47807
11 changed files with 630 additions and 15 deletions
+44 -1
View File
@@ -1,13 +1,16 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, List, Optional, Union from typing import Annotated, Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app import schemas from app import schemas
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.tmdb import TmdbChain from app.chain.tmdb import TmdbChain
from app.core.config import settings from app.core.config import settings
from app.core.context import Context from app.core.context import Context
from app.core.music import MusicInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath from app.core.metainfo import MetaInfo, MetaInfoPath
@@ -23,6 +26,17 @@ router = APIRouter()
MediaSource = str MediaSource = str
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID,其它现有来源使用数字 ID。"""
if source == "musicbrainz":
try:
UUID(media_id)
return True
except (TypeError, ValueError):
return False
return media_id.isdigit()
def _build_recognize_metainfo( def _build_recognize_metainfo(
title: str, title: str,
subtitle: Optional[str] = None, subtitle: Optional[str] = None,
@@ -148,6 +162,12 @@ async def recognize_file(
""" """
根据文件路径识别媒体信息 根据文件路径识别媒体信息
""" """
if MusicChain.is_audio_path(path) or source == "musicbrainz":
meta_info, media_info = await MusicChain().async_recognize_by_path(
path=path,
source=source or "musicbrainz",
)
return Context(meta_info=meta_info, media_info=media_info).to_dict()
# 识别媒体信息 # 识别媒体信息
context = await MediaChain().async_recognize_by_path(path, source=source) context = await MediaChain().async_recognize_by_path(path, source=source)
if context: if context:
@@ -255,9 +275,32 @@ def scrape(
return schemas.Response( return schemas.Response(
success=False, message="指定媒体ID时必须同时指定媒体数据源" success=False, message="指定媒体ID时必须同时指定媒体数据源"
) )
if normalized_media_id and not normalized_media_id.isdigit(): if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id):
return schemas.Response(success=False, message="媒体ID格式无效") return schemas.Response(success=False, message="媒体ID格式无效")
is_music = (
type_name == MediaType.MUSIC
or media_source == "musicbrainz"
or MusicChain.is_audio_path(fileitem.path)
)
if is_music:
if type_name not in (None, MediaType.MUSIC):
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
music_info = MusicChain().recognize(
source=media_source or "musicbrainz",
media_id=normalized_media_id,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
success, message = MusicChain().scrape_metadata(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,
)
return schemas.Response(success=success, message=message)
chain = MediaChain() chain = MediaChain()
if normalized_media_id: if normalized_media_id:
meta_info = MetaInfoPath(Path(fileitem.path)) meta_info = MetaInfoPath(Path(fileitem.path))
+17 -2
View File
@@ -10,6 +10,14 @@ from app.core.security import verify_token
router = APIRouter() router = APIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)] CountParam = Annotated[int, Query(ge=1, le=100)]
MusicRangeParam = Annotated[
str,
Query(pattern="^(this_week|this_month|this_year|all_time)$"),
]
MusicSortParam = Annotated[
str,
Query(pattern="^listen_count\\.(desc|asc)$"),
]
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo: def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
@@ -59,12 +67,19 @@ async def recognize_music(
async def explore_music( async def explore_music(
page: Annotated[int, Query(ge=1)] = 1, page: Annotated[int, Query(ge=1)] = 1,
count: CountParam = 30, count: CountParam = 30,
range_name: MusicRangeParam = "this_month",
sort_by: MusicSortParam = "listen_count.desc",
min_listen_count: Annotated[int, Query(ge=0)] = 0,
with_cover: bool = False,
_: schemas.TokenPayload = Depends(verify_token), _: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]: ) -> list[schemas.MusicInfo]:
"""月度全站收听榜单分页返回可搜索和订阅的音乐候选。""" """周期、热度和封面条件返回可搜索和订阅的音乐候选。"""
results = await MusicChain().async_chart( results = await MusicChain().async_chart(
range_name="this_month", range_name=range_name,
page=page, page=page,
count=count, count=count,
sort_by=sort_by,
min_listen_count=min_listen_count,
with_cover=with_cover,
) )
return [_serialize_music(info) for info in results] return [_serialize_music(info) for info in results]
+226 -2
View File
@@ -1,8 +1,16 @@
import re import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
from app import schemas
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.music import MusicInfo, MusicMeta from app.core.music import MusicInfo, MusicMeta
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.utils.http import RequestUtils
class MusicChain(ChainBase): class MusicChain(ChainBase):
@@ -119,15 +127,175 @@ class MusicChain(ChainBase):
range_name: str, range_name: str,
page: int = 1, page: int = 1,
count: int = 30, count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
) -> list[MusicInfo]: ) -> list[MusicInfo]:
"""异步读取 ListenBrainz 全站音乐榜单并标准化分页结果""" """异步读取 ListenBrainz 榜单,并应用音乐探索筛选和排序"""
candidates = await self.async_run_module( candidates = await self.async_run_module(
"music_chart", "music_chart",
range_name=range_name, range_name=range_name,
offset=max(page - 1, 0) * count, offset=max(page - 1, 0) * count,
count=count, count=count,
) )
return self.normalize_candidates(candidates, limit=count) results = self.normalize_candidates(candidates)
if min_listen_count > 0:
results = [
info for info in results
if (info.listen_count or 0) >= min_listen_count
]
if with_cover:
results = [info for info in results if info.cover_url]
results.sort(
key=lambda info: info.listen_count or 0,
reverse=sort_by != "listen_count.asc",
)
return results[:count]
@classmethod
def is_audio_path(cls, path: str | Path) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def read_path_meta(cls, path: str | Path) -> MusicMeta:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return cls.parse_query(file_path.stem)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.read_path_meta(path)
candidates = await self.async_run_module(
"search_music",
meta=meta,
limit=10,
)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
if matched:
return meta, matched
return meta, self._info_from_meta(meta)
def recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, MusicInfo]:
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
meta = self.read_path_meta(path)
candidates = self.run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
return meta, matched or self._info_from_meta(meta)
def scrape_metadata(
self,
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,复用现有存储下载上传能力。"""
files = self._audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
_, info = self.recognize_by_path(audio_item.path)
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_audio_file(audio_item, info, overwrite=overwrite):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
return False, "".join(failures[:3])
return True, f"已刮削 {len(files)} 个音频文件"
@staticmethod
def _download_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过统一请求封装下载封面,并返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
response = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT,
timeout=20,
).get_res(url)
if not response:
return None, "image/jpeg"
try:
if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg"
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime
finally:
response.close()
def _audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self.is_audio_path(fileitem.path or "") else []
return [
item
for item in StorageChain().list_files(fileitem, recursion=True) or []
if item.type == "file" and self.is_audio_path(item.path or "")
]
def _scrape_audio_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
overwrite: bool,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = self._download_cover(mediainfo.cover_url)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
):
return False
parent = storage.get_parent_item(fileitem)
if not parent:
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
return False
return bool(
storage.upload_file(
parent,
local_path,
new_name=fileitem.name or local_path.name,
)
)
@classmethod @classmethod
def to_meta(cls, info: MusicInfo) -> MusicMeta: def to_meta(cls, info: MusicInfo) -> MusicMeta:
@@ -148,6 +316,62 @@ class MusicChain(ChainBase):
media_id=info.media_id, media_id=info.media_id,
) )
@classmethod
def _select_path_candidate(
cls,
meta: MusicMeta,
candidates: Iterable[MusicInfo],
source: str,
) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的文件识别候选。"""
normalized_source = cls._normalize_text(source).casefold()
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
if cls._same_text(meta.title, candidate.title):
score += 4
if meta.artists and any(
cls._same_text(meta.artists[0], artist)
for artist in candidate.artists
):
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
if meta.isrc and cls._same_text(meta.isrc, candidate.isrc):
score += 5
ranked.append((score, candidate))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MusicMeta) -> MusicInfo:
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
title=meta.title,
artists=list(meta.artists),
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
disc_number=meta.disc_number,
track_number=meta.track_number,
total_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
"""忽略空白和大小写比较两个音乐文本字段。"""
return cls._normalize_text(left).casefold() == cls._normalize_text(right).casefold()
@classmethod @classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]: def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。""" """构造跨来源稳定的候选去重键。"""
+2
View File
@@ -517,6 +517,8 @@ class ConfigModel(BaseModel):
"ykimg.com", "ykimg.com",
"qpic.cn", "qpic.cn",
"anilist.co", "anilist.co",
"coverartarchive.org",
"archive.org",
] ]
) )
# 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果 # 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果
+126 -3
View File
@@ -1,14 +1,17 @@
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional, Union
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC
from mutagen.mp4 import MP4, MP4Cover
from app.core.music import MusicMeta from app.core.music import MusicInfo, MusicMeta
from app.log import logger from app.log import logger
class AudioMetadataHelper: class AudioMetadataHelper:
"""读取音频标签和技术参数并转换为标准 MusicMeta""" """读取和写入音频标签并转换为标准音乐元数据"""
@classmethod @classmethod
def read(cls, path: Path) -> MusicMeta: def read(cls, path: Path) -> MusicMeta:
@@ -50,6 +53,126 @@ class AudioMetadataHelper:
isrc=cls._first(tags, "isrc"), isrc=cls._first(tags, "isrc"),
) )
@classmethod
def write(
cls,
path: Path,
music: Union[MusicMeta, MusicInfo],
cover_data: Optional[bytes] = None,
cover_mime: str = "image/jpeg",
overwrite: bool = True,
) -> bool:
"""把标准音乐字段写入音频标签,并为常见格式嵌入专辑封面。"""
try:
audio = MutagenFile(path, easy=True)
if not audio:
logger.warning(f"无法写入音频标签:{path}")
return False
if audio.tags is None:
audio.add_tags()
for key, value in cls._tag_values(music).items():
if value in (None, "", []):
continue
if not overwrite and audio.tags.get(key):
continue
try:
audio[key] = value if isinstance(value, list) else [str(value)]
except (KeyError, TypeError, ValueError) as err:
logger.debug(f"音频格式不支持标签 {key}{path} - {err}")
audio.save()
if cover_data:
cls._write_cover(
path=path,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
return True
except Exception as err:
logger.warning(f"写入音频标签失败:{path} - {err}")
return False
@classmethod
def _tag_values(cls, music: Union[MusicMeta, MusicInfo]) -> dict[str, Any]:
"""把标准音乐对象转换为 Mutagen Easy 标签字典。"""
track_number = cls._number_text(
getattr(music, "track_number", None),
getattr(music, "total_tracks", None),
)
disc_number = cls._number_text(
getattr(music, "disc_number", None),
getattr(music, "total_discs", None),
)
return {
"title": getattr(music, "title", None),
"artist": list(getattr(music, "artists", None) or []),
"album": getattr(music, "album", None),
"albumartist": getattr(music, "album_artist", None),
"date": getattr(music, "year", None),
"tracknumber": track_number,
"discnumber": disc_number,
"isrc": getattr(music, "isrc", None),
}
@staticmethod
def _number_text(current: Optional[int], total: Optional[int]) -> Optional[str]:
"""把曲序或碟号转换为常见的 current/total 标签文本。"""
if current is None:
return None
return f"{current}/{total}" if total else str(current)
@staticmethod
def _write_cover(
path: Path,
cover_data: bytes,
cover_mime: str,
overwrite: bool,
) -> None:
"""为 MP3、FLAC 和 MP4/M4A 写入内嵌封面,其它格式保留标签写入结果。"""
audio = MutagenFile(path)
if isinstance(audio, FLAC):
if audio.pictures and not overwrite:
return
picture = Picture()
picture.type = 3
picture.mime = cover_mime
picture.desc = "Cover"
picture.data = cover_data
if overwrite:
audio.clear_pictures()
audio.add_picture(picture)
audio.save()
return
if isinstance(audio, MP4):
if audio.tags is None:
audio.add_tags()
if audio.tags.get("covr") and not overwrite:
return
image_format = (
MP4Cover.FORMAT_PNG
if cover_mime == "image/png"
else MP4Cover.FORMAT_JPEG
)
audio.tags["covr"] = [MP4Cover(cover_data, imageformat=image_format)]
audio.save()
return
tags = getattr(audio, "tags", None)
if tags is not None and hasattr(tags, "add"):
if tags.getall("APIC") and not overwrite:
return
if overwrite:
tags.delall("APIC")
tags.add(
APIC(
encoding=3,
mime=cover_mime,
type=3,
desc="Cover",
data=cover_data,
)
)
audio.save()
@staticmethod @staticmethod
def _values(tags: Any, key: str) -> list[str]: def _values(tags: Any, key: str) -> list[str]:
"""从 Mutagen Easy 标签中提取非空字符串列表。""" """从 Mutagen Easy 标签中提取非空字符串列表。"""
+40
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from app.core.music import MusicInfo
from app.helper.audio import AudioMetadataHelper from app.helper.audio import AudioMetadataHelper
@@ -46,3 +47,42 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
assert meta.title == "Unknown Track" assert meta.title == "Unknown Track"
assert meta.audio_format == "MP3" assert meta.audio_format == "MP3"
def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
"""音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。"""
class FakeAudio:
"""记录 Mutagen Easy 标签写入结果。"""
def __init__(self):
self.tags = {}
self.saved = False
def __setitem__(self, key, value):
self.tags[key] = value
def save(self):
self.saved = True
audio = FakeAudio()
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
success = AudioMetadataHelper.write(
Path("/music/08 - Get Lucky.flac"),
MusicInfo(
title="Get Lucky",
artists=["Daft Punk", "Pharrell Williams"],
album="Random Access Memories",
album_artist="Daft Punk",
year=2013,
track_number=8,
total_tracks=13,
isrc="USQX91300105",
),
)
assert success is True
assert audio.saved is True
assert audio.tags["title"] == ["Get Lucky"]
assert audio.tags["artist"] == ["Daft Punk", "Pharrell Williams"]
assert audio.tags["tracknumber"] == ["8/13"]
+65 -2
View File
@@ -1,8 +1,9 @@
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, Mock, patch
from app.api.endpoints.media import scrape from app.api.endpoints.media import recognize_file, scrape
from app.core.context import Context, MediaInfo from app.core.context import Context, MediaInfo
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.music import MusicInfo, MusicMeta
from app.schemas import FileItem, MediaType from app.schemas import FileItem, MediaType
@@ -80,3 +81,65 @@ def test_scrape_rejects_media_id_without_source() -> None:
assert result.success is False assert result.success is False
assert result.message == "指定媒体ID时必须同时指定媒体数据源" assert result.message == "指定媒体ID时必须同时指定媒体数据源"
def test_recognize_file_routes_audio_to_music_chain() -> None:
"""文件管理识别音频文件时应返回音乐专属上下文。"""
music_chain = Mock()
music_chain.async_recognize_by_path = AsyncMock(
return_value=(
MusicMeta(title="晴天", artists=["周杰伦"]),
MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
artists=["周杰伦"],
),
)
)
import asyncio
with patch("app.api.endpoints.media.MusicChain", return_value=music_chain):
result = asyncio.run(recognize_file(path="/music/晴天.flac", _=Mock()))
assert result["meta_info"]["type"] == "音乐"
assert result["media_info"]["title"] == "晴天"
music_chain.async_recognize_by_path.assert_awaited_once_with(
path="/music/晴天.flac",
source="musicbrainz",
)
def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
"""手动音乐刮削应接受 MusicBrainz UUID 并进入音乐标签写入流程。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
info = MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
)
chain = Mock()
chain.recognize.return_value = info
chain.scrape_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MusicChain", return_value=chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
_=Mock(),
)
assert result.success is True
chain.recognize.assert_called_once_with(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
)
chain.scrape_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=info,
overwrite=True,
)
+62 -1
View File
@@ -1,5 +1,5 @@
from app.chain.music import MusicChain from app.chain.music import MusicChain
from app.core.music import MusicInfo from app.core.music import MusicInfo, MusicMeta
def test_parse_query_supports_artist_title_format(): def test_parse_query_supports_artist_title_format():
@@ -110,3 +110,64 @@ def test_chart_converts_page_to_listenbrainz_offset(monkeypatch):
"count": 30, "count": 30,
} }
assert len(results) == 1 assert len(results) == 1
def test_async_chart_applies_music_explore_filters(monkeypatch):
"""音乐探索应按收听次数、封面条件和升序设置筛选榜单。"""
chain = MusicChain()
async def fake_async_run_module(method, **kwargs):
"""返回包含不同热度和封面状态的榜单候选。"""
assert method == "music_chart"
return [
MusicInfo(media_id="1", source="musicbrainz", title="A", listen_count=300),
MusicInfo(
media_id="2",
source="musicbrainz",
title="B",
listen_count=120,
cover_url="https://coverartarchive.org/release/2/front-500",
),
MusicInfo(
media_id="3",
source="musicbrainz",
title="C",
listen_count=240,
cover_url="https://coverartarchive.org/release/3/front-500",
),
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
results = asyncio.run(
chain.async_chart(
range_name="this_month",
count=30,
sort_by="listen_count.asc",
min_listen_count=100,
with_cover=True,
)
)
assert [item.title for item in results] == ["B", "C"]
def test_select_path_candidate_prefers_matching_audio_tags():
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
meta = MusicMeta(title="晴天", artists=["周杰伦"], album="叶惠美")
candidates = [
MusicInfo(source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
MusicInfo(
source="musicbrainz",
media_id="2",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
),
]
selected = MusicChain._select_path_candidate(meta, candidates, source="musicbrainz")
assert selected is candidates[1]
+17 -4
View File
@@ -90,8 +90,8 @@ def test_recognize_music_returns_404_for_unknown_item():
assert error.value.status_code == 404 assert error.value.status_code == 404
def test_explore_music_serializes_monthly_chart(): def test_explore_music_forwards_filters_and_serializes_chart():
"""音乐探索接口应按月度榜单分页并保留收听统计""" """音乐探索接口应传递周期、排序、热度和封面筛选条件"""
chain = Mock() chain = Mock()
chain.async_chart = AsyncMock( chain.async_chart = AsyncMock(
return_value=[ return_value=[
@@ -106,11 +106,24 @@ def test_explore_music_serializes_monthly_chart():
) )
with patch("app.api.endpoints.music.MusicChain", return_value=chain): with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(explore_music(page=2, count=20, _=Mock())) result = asyncio.run(
explore_music(
page=2,
count=20,
range_name="this_week",
sort_by="listen_count.asc",
min_listen_count=100,
with_cover=True,
_=Mock(),
)
)
assert result[0].listen_count == 123 assert result[0].listen_count == 123
chain.async_chart.assert_awaited_once_with( chain.async_chart.assert_awaited_once_with(
range_name="this_month", range_name="this_week",
page=2, page=2,
count=20, count=20,
sort_by="listen_count.asc",
min_listen_count=100,
with_cover=True,
) )
+7
View File
@@ -1,5 +1,12 @@
from app.core.music import MusicMeta from app.core.music import MusicMeta
from app.modules.musicbrainz import MusicBrainzModule from app.modules.musicbrainz import MusicBrainzModule
from app.core.config import settings
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
"""MusicBrainz 封面及其归档重定向域名应进入图片代理安全列表。"""
assert "coverartarchive.org" in settings.SECURITY_IMAGE_DOMAINS
assert "archive.org" in settings.SECURITY_IMAGE_DOMAINS
def test_build_query_uses_structured_music_fields(): def test_build_query_uses_structured_music_fields():
+24
View File
@@ -8,6 +8,7 @@ import pytest
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory from app.db.models.subscribehistory import SubscribeHistory
from app.db.subscribe_oper import SubscribeOper from app.db.subscribe_oper import SubscribeOper
from app.core.music import MusicInfo
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -96,6 +97,29 @@ def test_add_scopes_duplicate_lookup_by_episode_group(episode_group):
created.create.assert_called_once() created.create.assert_called_once()
def test_music_subscribe_persists_release_cover_as_poster_and_backdrop():
"""音乐订阅应把 MusicBrainz 发行封面写入订阅海报和背景字段。"""
persisted = SimpleNamespace(id=92)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
cover_url="https://coverartarchive.org/release-group/example/front-500",
)
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = SubscribeOper(db=object()).add(mediainfo=media, season=None)
assert sid == 92
payload = subscribe_model.call_args.kwargs
assert payload["poster"] == media.cover_url
assert payload["backdrop"] == media.cover_url
@pytest.mark.parametrize("episode_group", [None, "eg-1"]) @pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group): def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""异步新增与同步路径使用相同的剧集组身份契约。""" """异步新增与同步路径使用相同的剧集组身份契约。"""