mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
fix(v3): complete music recognition and scraping
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.music import MusicInfo
|
||||
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.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"]
|
||||
|
||||
@@ -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.meta import MetaBase
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
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.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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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():
|
||||
@@ -110,3 +110,64 @@ def test_chart_converts_page_to_listenbrainz_offset(monkeypatch):
|
||||
"count": 30,
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -90,8 +90,8 @@ def test_recognize_music_returns_404_for_unknown_item():
|
||||
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.async_chart = AsyncMock(
|
||||
return_value=[
|
||||
@@ -106,11 +106,24 @@ def test_explore_music_serializes_monthly_chart():
|
||||
)
|
||||
|
||||
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
|
||||
chain.async_chart.assert_awaited_once_with(
|
||||
range_name="this_month",
|
||||
range_name="this_week",
|
||||
page=2,
|
||||
count=20,
|
||||
sort_by="listen_count.asc",
|
||||
min_listen_count=100,
|
||||
with_cover=True,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
from app.core.music import MusicMeta
|
||||
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():
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.core.music import MusicInfo
|
||||
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()
|
||||
|
||||
|
||||
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"])
|
||||
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
|
||||
"""异步新增与同步路径使用相同的剧集组身份契约。"""
|
||||
|
||||
Reference in New Issue
Block a user