feat: 识别文件类型:音乐

This commit is contained in:
jxxghp
2026-08-08 23:38:34 +08:00
committed by GitHub
parent 4570a7b74a
commit 7797d34c48
35 changed files with 1061 additions and 773 deletions

View File

@@ -1,12 +1,12 @@
from pathlib import Path
from types import SimpleNamespace
from app.core.music import MusicInfo
from app.core.context import MusicInfo
from app.helper.audio import AudioMetadataHelper
def test_read_audio_metadata_maps_easy_tags(monkeypatch):
"""音频标签和技术参数应映射为 MusicMeta"""
"""音频标签和技术参数应映射为 MetaMusic。"""
audio = SimpleNamespace(
tags={
"title": ["Get Lucky"],

View File

@@ -2,8 +2,8 @@ from unittest.mock import AsyncMock, Mock, patch
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.core.meta import MetaBase, MetaMusic
from app.core.context import MusicInfo
from app.schemas import FileItem, MediaType
@@ -88,7 +88,7 @@ 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=["周杰伦"]),
MetaMusic(title="晴天", artists=["周杰伦"]),
MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",

View File

@@ -4,6 +4,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.core.meta import MetaBase, MetaMusic
from app.core.meta.metaanime import MetaAnime
from app.helper.torrent import TorrentHelper
from app.schemas.types import MediaType
@@ -172,6 +173,53 @@ def test_python_metainfo_fallback_preserves_xxx_movie_title():
assert meta.audio_encode == "DDP 5.1"
def test_metainfo_routes_audio_filename_to_music():
"""音频文件名应直接走音乐分支,不再进入影视季集解析。"""
meta = MetaInfo("周杰伦 - 晴天.flac")
assert isinstance(meta, MetaMusic)
assert isinstance(meta, MetaBase)
assert meta.type == MediaType.MUSIC
assert meta.org_string == "周杰伦 - 晴天.flac"
assert meta.title == "周杰伦 - 晴天"
assert meta.audio_format == "FLAC"
# 音乐没有季集信息,兼容通用访问
assert meta.season is None
assert meta.episode is None
assert meta.apply_words == []
def test_metainfo_routes_audio_path_to_music_without_parent_merge():
"""音频路径应直接构造音乐元数据,不与父目录季集合并。"""
meta = MetaInfoPath(Path("/music/叶惠美/周杰伦 - 晴天.flac"))
assert isinstance(meta, MetaMusic)
assert meta.type == MediaType.MUSIC
assert meta.org_string == "周杰伦 - 晴天.flac"
assert meta.title == "周杰伦 - 晴天"
assert meta.audio_format == "FLAC"
def test_metainfo_keeps_video_path_for_non_audio_files():
"""非音频文件应继续走影视识别链,不受音频路由影响。"""
meta = MetaInfoPath(Path("/movies/Inception (2010)/Inception.2010.1080p.mkv"))
assert not isinstance(meta, MetaMusic)
assert meta.type != MediaType.MUSIC
def test_metainfo_music_round_trip_preserves_fields():
"""音频解析结果字典往返后应保留音乐字段。"""
meta = MetaInfoPath(Path("/music/周杰伦 - 晴天.flac"))
payload = meta.to_dict()
restored = MetaMusic.from_dict(payload)
assert restored.type == MediaType.MUSIC
assert restored.title == "周杰伦 - 晴天"
assert restored.audio_format == "FLAC"
assert payload["type"] == "音乐"
def test_python_subtitle_episode_range_fin_with_chinese_season():
"""Python 兜底解析应识别副标题中 [01-26Fin] 格式的集数范围(#6103"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):

View File

@@ -1,5 +1,6 @@
from app.chain.music import MusicChain
from app.core.music import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
def test_parse_query_supports_artist_title_format():
@@ -156,7 +157,7 @@ def test_async_chart_applies_music_explore_filters(monkeypatch):
def test_select_path_candidate_prefers_matching_audio_tags():
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
meta = MusicMeta(title="晴天", artists=["周杰伦"], album="叶惠美")
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidates = [
MusicInfo(source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
MusicInfo(

View File

@@ -1,5 +1,6 @@
from app.core.context import Context, MediaInfo
from app.core.music import MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.context import Context as ContextSchema
from app.schemas.context import MediaInfo as MediaInfoSchema
from app.schemas.music import MusicInfo as MusicInfoSchema
@@ -16,8 +17,8 @@ def test_media_type_supports_music_agent_conversion():
def test_music_meta_round_trip_preserves_list_isolation():
"""MusicMeta 字典往返后应保留字段且不共享可变列表。"""
meta = MusicMeta(
"""MetaMusic 字典往返后应保留字段且不共享可变列表。"""
meta = MetaMusic(
org_string="Jay Chou - Common Jasmin Orange",
title="七里香",
artists=["周杰伦"],
@@ -27,7 +28,7 @@ def test_music_meta_round_trip_preserves_list_isolation():
)
payload = meta.to_dict()
restored = MusicMeta.from_dict(payload)
restored = MetaMusic.from_dict(payload)
restored.artists.append("Jay Chou")
assert payload["type"] == "音乐"
@@ -62,7 +63,7 @@ def test_music_info_serializes_shared_media_display_fields():
def test_core_context_serializes_music_models_without_video_fields():
"""核心 Context 应使用既有外层结构序列化音乐对象。"""
context = Context(
meta_info=MusicMeta(title="七里香", artists=["周杰伦"]),
meta_info=MetaMusic(title="七里香", artists=["周杰伦"]),
media_info=MusicInfo(
source="musicbrainz",
media_id="release-1",

View File

@@ -3,7 +3,7 @@ from unittest.mock import Mock, patch
from app.api.endpoints.download import download
from app.chain.download import DownloadChain
from app.chain.music import MusicChain
from app.core.music import MusicInfo
from app.core.context import MusicInfo
from app.schemas.context import TorrentInfo
from app.schemas.music import MusicInfo as MusicInfoSchema
from app.schemas.types import MediaType
@@ -50,7 +50,7 @@ def test_download_note_keeps_versioned_music_context():
def test_download_endpoint_builds_music_context():
"""现有添加下载接口应使用 MusicInfo 和 MusicMeta 构造音乐上下文。"""
"""现有添加下载接口应使用 MusicInfo 和 MetaMusic 构造音乐上下文。"""
chain = Mock()
chain.download_single.return_value = "hash-1"
current_user = Mock(name="admin")

View File

@@ -14,7 +14,7 @@ from app.api.endpoints.music import (
recognize_music,
search_music,
)
from app.core.music import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.schemas.music import MusicRecognizeRequest

View File

@@ -0,0 +1,114 @@
"""音乐识别统一入口路由测试。
覆盖 MediaChain 同步/异步 ``recognize_by_meta`` 与 ``recognize_by_path`` 按
``MetaMusic`` 路由到 ``MusicChain``,以及 ``MusicChain.recognize_by_meta`` 自身的
详情、搜索匹配与离线兜底分支。
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.schemas.types import MediaType
def _music_info() -> MusicInfo:
"""构造带远端身份的标准音乐信息,用于断言路由返回值。"""
return MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
def test_media_chain_recognize_by_meta_routes_metamusic_to_musicchain():
"""MetaMusic 应绕过影视识别链,直接交给 MusicChain.recognize_by_meta。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
music_chain = Mock()
music_chain.recognize_by_meta = Mock(return_value=expected)
with patch("app.chain.music.MusicChain", return_value=music_chain):
result = MediaChain().recognize_by_meta(meta, source="musicbrainz")
music_chain.recognize_by_meta.assert_called_once_with(meta, source="musicbrainz")
assert result is expected
def test_media_chain_async_recognize_by_meta_routes_metamusic_to_musicchain():
"""异步识别同样应把 MetaMusic 路由到 MusicChain.async_recognize_by_meta。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = _music_info()
music_chain = Mock()
music_chain.async_recognize_by_meta = AsyncMock(return_value=expected)
async def runner():
with patch("app.chain.music.MusicChain", return_value=music_chain):
return await MediaChain().async_recognize_by_meta(meta, source="musicbrainz")
result = asyncio.run(runner())
music_chain.async_recognize_by_meta.assert_awaited_once_with(meta, source="musicbrainz")
assert result is expected
def test_media_chain_recognize_by_path_routes_audio_file_to_musicchain():
"""音频文件路径应经 MetaInfoPath 构造 MetaMusic 并路由到音乐识别链。"""
expected = _music_info()
music_chain = Mock()
music_chain.recognize_by_meta = Mock(return_value=expected)
with patch("app.chain.music.MusicChain", return_value=music_chain):
context = MediaChain().recognize_by_path("/music/周杰伦 - 晴天.flac")
routed_meta = music_chain.recognize_by_meta.call_args.args[0]
assert isinstance(routed_meta, MetaMusic)
assert context.media_info is expected
assert isinstance(context.meta_info, MetaMusic)
def test_music_chain_recognize_by_meta_uses_detail_when_meta_has_identity(monkeypatch):
"""meta 携带 source+media_id 时应走详情分支,不再触发搜索。"""
chain = MusicChain()
meta = MetaMusic(title="晴天", media_source="musicbrainz", media_id="recording-1")
expected = _music_info()
monkeypatch.setattr(chain, "recognize", Mock(return_value=expected))
search_mock = Mock(return_value=[])
monkeypatch.setattr(chain, "run_module", search_mock)
result = chain.recognize_by_meta(meta, source="musicbrainz")
chain.recognize.assert_called_once_with("musicbrainz", "recording-1")
search_mock.assert_not_called()
assert result is expected
def test_music_chain_recognize_by_meta_matches_search_candidate(monkeypatch):
"""无身份时应按标题搜索并选择匹配候选。"""
chain = MusicChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidate = _music_info()
monkeypatch.setattr(chain, "run_module", Mock(return_value=[candidate]))
result = chain.recognize_by_meta(meta)
assert result is candidate
def test_music_chain_recognize_by_meta_falls_back_to_offline_when_no_match(monkeypatch):
"""搜索无候选时应返回离线兜底,且兜底结果不带远端 source。"""
chain = MusicChain()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
monkeypatch.setattr(chain, "run_module", Mock(return_value=[]))
result = chain.recognize_by_meta(meta)
assert result is not None
assert result.title == "未知曲目"
# 离线兜底不携带远端来源,订阅等场景据此判定未真实命中
assert result.source is None

View File

@@ -1,7 +1,8 @@
from unittest.mock import Mock, patch
from app.chain.search import SearchChain
from app.core.music import MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.context import TorrentInfo
from app.schemas.types import MediaType
@@ -38,7 +39,7 @@ def test_music_context_builder_keeps_only_music_category():
assert len(contexts) == 1
assert contexts[0].media_info is music
assert isinstance(contexts[0].meta_info, MusicMeta)
assert isinstance(contexts[0].meta_info, MetaMusic)
assert contexts[0].meta_info.media_id == "recording-1"
assert contexts[0].torrent_info.category == MediaType.MUSIC.value

View File

@@ -3,7 +3,8 @@ from unittest.mock import Mock, patch
from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.core.context import Context, TorrentInfo
from app.core.music import MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.types import MediaType
@@ -56,10 +57,10 @@ def _subscribe() -> SimpleNamespace:
def test_build_subscribe_meta_returns_music_meta():
"""音乐订阅应构造 MusicMeta,而不是交给影视标题解析器。"""
"""音乐订阅应构造 MetaMusic而不是交给影视标题解析器。"""
meta = build_subscribe_meta(_subscribe())
assert isinstance(meta, MusicMeta)
assert isinstance(meta, MetaMusic)
assert meta.type == MediaType.MUSIC
assert meta.media_id == "recording-1"
assert meta.original_name == "晴天"
@@ -96,7 +97,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
rule_groups=[],
)
assert context.media_info is target
assert isinstance(context.meta_info, MusicMeta)
assert isinstance(context.meta_info, MetaMusic)
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
download_chain.batch_download.assert_called_once()
chain.finish_subscribe_or_not.assert_called_once()
@@ -121,3 +122,56 @@ def test_music_subscribe_ignores_non_music_category():
chain._search_music_subscribe(subscribe)
download_chain.assert_not_called()
def test_subscribe_add_music_uses_unified_recognize_by_meta():
"""音乐订阅新增应走统一 recognize_by_meta并把媒体身份落到 MetaMusic 上。"""
target = _music_info()
media_chain = Mock()
media_chain.recognize_by_meta = Mock(return_value=target)
subscribe_oper = Mock()
subscribe_oper.add.return_value = (1, "")
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain.subscribe.MoviePilotServerHelper"), \
patch("app.chain.subscribe.eventmanager"):
sid, err_msg = SubscribeChain().add(
title="周杰伦 - 晴天",
year="2003",
mtype=MediaType.MUSIC,
media_source="musicbrainz",
media_id="recording-1",
message=False,
)
assert sid == 1
assert err_msg == ""
media_chain.recognize_by_meta.assert_called_once()
routed_meta = media_chain.recognize_by_meta.call_args.args[0]
assert isinstance(routed_meta, MetaMusic)
# 媒体身份落到 meta供统一识别的详情分支复用
assert routed_meta.media_id == "recording-1"
assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "musicbrainz"
def test_subscribe_add_music_fails_fast_on_offline_fallback():
"""统一识别返回离线兜底(无远端 source时订阅应直接失败不写入数据库。"""
offline = MusicInfo(title="未知曲目", artists=["未知艺术家"])
media_chain = Mock()
media_chain.recognize_by_meta = Mock(return_value=offline)
subscribe_oper = Mock()
subscribe_oper.add.return_value = (1, "")
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper):
sid, err_msg = SubscribeChain().add(
title="未知曲目",
year=None,
mtype=MediaType.MUSIC,
message=False,
)
assert sid is None
assert err_msg == "未识别到媒体信息"
subscribe_oper.add.assert_not_called()

View File

@@ -2,7 +2,8 @@ import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.chain.torrents import TorrentsChain
from app.core.music import MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.types import MediaType
@@ -60,7 +61,7 @@ def test_music_cache_context_uses_music_models():
result = chain.refresh(stype="spider", sites=[1])
context = result["example.com"][0]
assert isinstance(context.meta_info, MusicMeta)
assert isinstance(context.meta_info, MetaMusic)
assert isinstance(context.media_info, MusicInfo)
assert context.meta_info.artists == ["Daft Punk"]
assert context.media_info.title == "Get Lucky"

View File

@@ -4,14 +4,15 @@ from types import SimpleNamespace
from app.chain.music import MusicChain
from app.chain.transfer import JobManager, TransferChain
from app.core.config import settings
from app.core.music import MusicInfo, MusicMeta
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.helper.message import TemplateHelper
from app.schemas.file import FileItem
from app.schemas.transfer import TransferTask
from app.schemas.types import MediaType
def _music_context() -> tuple[MusicMeta, MusicInfo]:
def _music_context() -> tuple[MetaMusic, MusicInfo]:
"""构造整理测试使用的音乐元数据和媒体信息。"""
info = MusicInfo(
source="musicbrainz",

View File

@@ -1,4 +1,4 @@
from app.core.music import MusicMeta
from app.core.meta import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule
from app.core.config import settings
@@ -12,7 +12,7 @@ def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
def test_build_query_uses_structured_music_fields():
"""MusicBrainz 查询应同时使用歌曲、艺术家和专辑条件。"""
query = MusicBrainzModule._build_query(
MusicMeta(
MetaMusic(
title='Love "Story"',
artists=["Taylor Swift"],
album="Fearless",
@@ -76,7 +76,7 @@ def test_search_music_normalizes_candidates(monkeypatch):
},
)
results = module.search_music(MusicMeta(title="晴天"), limit=5)
results = module.search_music(MetaMusic(title="晴天"), limit=5)
assert len(results) == 1
assert results[0].title == "晴天"

View File

@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.chain.recommend import RecommendChain
from app.core.music import MusicInfo
from app.core.context import MusicInfo
from app.core.cache import TTLCache

View File

@@ -77,6 +77,7 @@ def _load_subscribe_chain_class():
context_module.TorrentInfo = SimpleNamespace
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
context_module.MusicInfo = SimpleNamespace
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
@@ -106,6 +107,7 @@ def _load_subscribe_chain_class():
meta_module = ensure_module("app.core.meta", types.ModuleType("app.core.meta"))
meta_module.MetaBase = SimpleNamespace
meta_module.MetaMusic = SimpleNamespace
metainfo_module = ensure_module("app.core.metainfo", types.ModuleType("app.core.metainfo"))
@@ -349,6 +351,7 @@ def _load_subscribe_chain_class():
"app.chain.download": "DownloadChain",
"app.chain.media": "MediaChain",
"app.chain.mediaserver": "MediaServerChain",
"app.chain.music": "MusicChain",
"app.chain.search": "SearchChain",
"app.chain.tmdb": "TmdbChain",
"app.chain.torrents": "TorrentsChain",

View File

@@ -8,7 +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.core.context import MusicInfo
from app.schemas.types import MediaType