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
+1 -2
View File
@@ -6,9 +6,8 @@ from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.site_oper import SiteOper
+1 -2
View File
@@ -9,8 +9,7 @@ from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.music import MusicInfo
from app.core.context import Context, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
+1 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app import schemas
from app.chain.music import MusicChain
from app.core.music import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.security import verify_token
from app.modules.listenbrainz import (
LISTENBRAINZ_CHART_RANGES,
+2 -3
View File
@@ -15,8 +15,7 @@ from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.core.cache import FileCache
from app.core.config import settings, global_vars
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context
from app.core.music import MusicInfo, MusicMeta
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
@@ -66,7 +65,7 @@ class DownloadChain(ChainBase):
def _build_download_note(
source: Optional[str],
media: MediaInfo | MusicInfo,
meta: MetaBase | MusicMeta,
meta: MetaBase,
) -> dict:
"""构造下载历史备注,并为音乐保存可恢复的版本化上下文。"""
note = {"source": source}
+9 -1
View File
@@ -11,7 +11,7 @@ from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import Context, MediaInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
@@ -741,6 +741,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return MusicChain().recognize_by_meta(metainfo, source=source)
title = metainfo.title
share_meta = deepcopy(metainfo)
@@ -1735,6 +1739,10 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return await MusicChain().async_recognize_by_meta(metainfo, source=source)
title = metainfo.title
share_meta = deepcopy(metainfo)
+49 -13
View File
@@ -7,13 +7,13 @@ from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.music import (
from app.core.context import (
MUSIC_ENTITY_RECORDING,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
MusicMeta,
)
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.utils.http import RequestUtils
@@ -26,10 +26,10 @@ class MusicChain(ChainBase):
_spaces_pattern = re.compile(r"\s+")
@classmethod
def parse_query(cls, query: str) -> MusicMeta:
"""将用户输入解析为最小可用的音乐搜索元数据。"""
def parse_query(cls, query: str) -> MetaMusic:
"""将用户输入的搜索关键词解析为音乐元数据。"""
normalized = cls._normalize_text(query)
meta = MusicMeta(org_string=query, title=normalized)
meta = MetaMusic(org_string=query, title=normalized)
match = cls._artist_title_pattern.match(normalized)
if match:
meta.artists = [match.group("artist").strip()]
@@ -37,7 +37,7 @@ class MusicChain(ChainBase):
return meta
@classmethod
def build_site_keywords(cls, music: MusicMeta | MusicInfo) -> list[str]:
def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""根据音乐元数据生成按精确度递减的站点搜索关键词。"""
artists = music.artists or []
artist = artists[0] if artists else music.album_artist
@@ -118,6 +118,42 @@ class MusicChain(ChainBase):
return MusicInfo.from_dict(result)
return None
def recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = self.recognize(resolved_source, str(meta.media_id))
if info:
return info
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=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
async def async_recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = await self.async_recognize(resolved_source, str(meta.media_id))
if info:
return info
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=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
"""读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
candidates = self.run_module(
@@ -280,7 +316,7 @@ class MusicChain(ChainBase):
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def read_path_meta(cls, path: str | Path) -> MusicMeta:
def read_path_meta(cls, path: str | Path) -> MetaMusic:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
@@ -291,7 +327,7 @@ class MusicChain(ChainBase):
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, MusicInfo]:
) -> tuple[MetaMusic, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.read_path_meta(path)
candidates = await self.async_run_module(
@@ -309,7 +345,7 @@ class MusicChain(ChainBase):
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, MusicInfo]:
) -> tuple[MetaMusic, MusicInfo]:
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
meta = self.read_path_meta(path)
candidates = self.run_module("search_music", meta=meta, limit=10)
@@ -420,9 +456,9 @@ class MusicChain(ChainBase):
)
@classmethod
def to_meta(cls, info: MusicInfo) -> MusicMeta:
def to_meta(cls, info: MusicInfo) -> MetaMusic:
"""将用户选中的标准音乐信息转换为下载和整理上下文元数据。"""
return MusicMeta(
return MetaMusic(
title=info.title,
artists=list(info.artists),
album=info.album,
@@ -441,7 +477,7 @@ class MusicChain(ChainBase):
@classmethod
def _select_path_candidate(
cls,
meta: MusicMeta,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
source: str,
) -> Optional[MusicInfo]:
@@ -470,7 +506,7 @@ class MusicChain(ChainBase):
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MusicMeta) -> MusicInfo:
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
return MusicInfo(
source=meta.media_source,
+3 -2
View File
@@ -16,9 +16,10 @@ from app.chain.music import MusicChain
from app.core.config import global_vars, settings
from app.core.context import Context
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo
from app.core.music import MusicInfo, MusicMeta
from app.core.event import eventmanager, Event
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.core.context import MusicInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.progress import ProgressHelper
from app.helper.sites import SitesHelper # noqa
@@ -1031,7 +1032,7 @@ class SearchChain(ChainBase):
) -> Any:
"""根据限定媒体类型构造模糊搜索结果的上下文元数据。"""
if mtype == MediaType.MUSIC:
return MusicMeta(
return MetaMusic(
org_string=torrent.title,
title=torrent.title,
)
+24 -28
View File
@@ -17,12 +17,12 @@ from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings, global_vars
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.meta import MetaMusic
from app.core.meta.words import WordsMatcher
from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo, MusicMeta
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.subscribe import Subscribe
from app.db.site_oper import SiteOper
@@ -55,12 +55,12 @@ from app.utils.media import (
subscribe_interaction_manager = SlashInteractionManager()
def build_subscribe_meta(subscribe: Subscribe) -> Union[MetaBase, MusicMeta]:
def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
"""
按订阅对象构造主程序链路共用的媒体元数据
"""
if subscribe.type == MediaType.MUSIC.value:
return MusicMeta(
return MetaMusic(
title=subscribe.name,
year=subscribe.year,
media_source=subscribe.media_source,
@@ -931,16 +931,10 @@ class SubscribeChain(ChainBase):
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
if mtype == MediaType.MUSIC:
if media_source and media_id:
mediainfo = MusicChain().recognize(
source=media_source,
media_id=str(media_id),
)
if not mediainfo:
music_candidates = MusicChain().search(title, limit=1)
mediainfo = music_candidates[0] if music_candidates else None
elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
# 音乐身份落到 meta,由统一 recognize_by_meta 的详情分支处理,不再单独编排 recognize+search
if mtype == MediaType.MUSIC and media_id:
metainfo.media_id = str(media_id)
if mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = self.recognize_media(
meta=metainfo,
mtype=mtype,
@@ -953,7 +947,7 @@ class SubscribeChain(ChainBase):
episode_group=episode_group,
cache=False,
)
elif mediaid:
elif mtype != MediaType.MUSIC and mediaid:
mediainfo = self.__get_event_media(mediaid, metainfo)
if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb":
@@ -963,13 +957,17 @@ class SubscribeChain(ChainBase):
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo and mtype != MediaType.MUSIC:
# 音乐与影视共用统一 recognize_by_meta 入口,MediaChain 按 MetaMusic 路由到 MusicChain。
if not mediainfo:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
)
# 音乐 recognize_by_meta 未命中远端时返回离线兜底,订阅创建要求真实命中
if mtype == MediaType.MUSIC and mediainfo and not mediainfo.source:
mediainfo = None
# 识别失败
if not mediainfo:
@@ -1149,16 +1147,10 @@ class SubscribeChain(ChainBase):
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
if mtype == MediaType.MUSIC:
if media_source and media_id:
mediainfo = await MusicChain().async_recognize(
source=media_source,
media_id=str(media_id),
)
if not mediainfo:
music_candidates = await MusicChain().async_search(title, limit=1)
mediainfo = music_candidates[0] if music_candidates else None
elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
# 音乐身份落到 meta,由统一 recognize_by_meta 的详情分支处理,不再单独编排 recognize+search
if mtype == MediaType.MUSIC and media_id:
metainfo.media_id = str(media_id)
if mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = await self.async_recognize_media(
meta=metainfo,
mtype=mtype,
@@ -1171,7 +1163,7 @@ class SubscribeChain(ChainBase):
episode_group=episode_group,
cache=False,
)
elif mediaid:
elif mtype != MediaType.MUSIC and mediaid:
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb":
@@ -1181,13 +1173,17 @@ class SubscribeChain(ChainBase):
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo and mtype != MediaType.MUSIC:
# 音乐与影视共用统一 recognize_by_meta 入口,MediaChain 按 MetaMusic 路由到 MusicChain。
if not mediainfo:
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
)
# 音乐 recognize_by_meta 未命中远端时返回离线兜底,订阅创建要求真实命中
if mtype == MediaType.MUSIC and mediainfo and not mediainfo.source:
mediainfo = None
# 识别失败
if not mediainfo:
+1 -1
View File
@@ -10,7 +10,7 @@ from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.config import settings, global_vars
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.music import MusicInfo
from app.core.context import MusicInfo
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
+7 -8
View File
@@ -18,10 +18,9 @@ from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings, global_vars
from app.core.context import MediaInfo
from app.core.music import MusicInfo, MusicMeta
from app.core.context import MediaInfo, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfoPath
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
@@ -205,7 +204,7 @@ class JobManager:
"""
获取元数据
"""
if isinstance(task.meta, MusicMeta):
if isinstance(task.meta, MetaMusic):
return schemas.MusicMeta(**task.meta.to_dict())
return schemas.MetaInfo(**task.meta.to_dict())
@@ -1046,7 +1045,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
@staticmethod
def _music_info_from_meta(meta: MusicMeta) -> MusicInfo:
def _music_info_from_meta(meta: MetaMusic) -> MusicInfo:
"""将音频文件标签解析结果转换为可整理的最小音乐信息。"""
return MusicInfo(
source=meta.media_source,
@@ -1070,14 +1069,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cls,
download_history: Optional[DownloadHistory],
file_path: Path,
) -> tuple[Optional[MusicMeta], Optional[MusicInfo]]:
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
note = getattr(download_history, "note", None)
music_note = note.get("music") if isinstance(note, dict) else None
if not isinstance(music_note, dict) or music_note.get("version") != 1:
return None, None
try:
saved_meta = MusicMeta.from_dict(music_note.get("meta") or {})
saved_meta = MetaMusic.from_dict(music_note.get("meta") or {})
saved_info = MusicInfo.from_dict(music_note.get("media") or {})
except (TypeError, ValueError):
return None, None
@@ -3618,7 +3617,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。
task_mediainfo = mediainfo or history_music_info
if not task_mediainfo and isinstance(file_meta, MusicMeta):
if not task_mediainfo and isinstance(file_meta, MetaMusic):
task_mediainfo = self._music_info_from_meta(file_meta)
if (
not manual
+512 -4
View File
@@ -1,12 +1,11 @@
import re
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field, fields
from datetime import datetime
from typing import List, Dict, Any, Tuple, Optional, Set, Union
from typing import List, Dict, Any, Tuple, Optional, Set, Union, Self
from app.core.config import settings
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo, MusicMeta
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@@ -15,6 +14,515 @@ ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
ANILIST_CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")
ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]")
# 音乐可浏览实体类型:单曲(Recording)、专辑(Release Group)、艺术家(Artist
MUSIC_ENTITY_RECORDING = "recording"
MUSIC_ENTITY_ALBUM = "album"
MUSIC_ENTITY_ARTIST = "artist"
def _validate_music_type(value: object) -> None:
"""校验音乐模型类型字段,仅接受音乐或空值。"""
if value in {None, MediaType.MUSIC, MediaType.MUSIC.value, "music"}:
return
raise ValueError(f"不支持的音乐媒体类型:{value}")
def _music_string_list(value: object) -> list[str]:
"""将音乐标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。"""
if value is None:
return []
if isinstance(value, str):
return [value] if value else []
if isinstance(value, (list, tuple, set)):
return [str(item) for item in value if str(item)]
return [str(value)]
def _music_aligned_list(value: object) -> list[str]:
"""保留原始位置的字符串列表,用于与艺术家名称按下标对应的 ID 列表。"""
if value is None:
return []
if isinstance(value, (list, tuple)):
return [str(item or "") for item in value]
return [str(value or "")]
def _music_optional_int(value: object) -> int | None:
"""将音乐技术参数安全转换为整数,空值与非数字返回 None。"""
if value in {None, ""}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _music_optional_float(value: object) -> float:
"""将音乐评分安全转换为浮点数,空值与异常返回 0.0。"""
if value in {None, ""}:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _music_year_of(release_date: object) -> int | None:
"""从 MusicBrainz 可变精度日期(YYYY / YYYY-MM / YYYY-MM-DD)提取年份。"""
text = str(release_date or "")[:4]
return int(text) if text.isdigit() else None
def _music_init_values(model: type, data: dict[str, Any]) -> dict[str, Any]:
"""按 dataclass 可初始化字段过滤字典,避免传入非法构造参数。"""
init_names = {item.name for item in fields(model) if item.init}
return {key: value for key, value in data.items() if key in init_names}
@dataclass
class MusicInfo:
"""标准化音乐元数据信息。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
source: str | None = None
media_id: str | None = None
# 音乐实体类型,用于区分单曲、专辑和艺术家三类可浏览对象
music_type: str = MUSIC_ENTITY_RECORDING
title: str | None = None
artists: list[str] = field(default_factory=list)
# 艺术家标准 ID,顺序与 artists 一致,供详情页关联跳转
artist_ids: list[str] = field(default_factory=list)
album: str | None = None
album_artist: str | None = None
# 所属专辑标准 IDMusicBrainz Release Group
album_id: str | None = None
# 专辑主类型:Album、EP、Single 等
album_type: str | None = None
year: int | None = None
release_date: str | None = None
disc_number: int | None = None
track_number: int | None = None
total_tracks: int | None = None
duration: int | None = None
isrc: str | None = None
cover_url: str | None = None
lyrics: str | None = None
version: str | None = None
category: str = ""
genres: list[str] = field(default_factory=list)
names: list[str] = field(default_factory=list)
detail_link: str | None = None
listen_count: int | None = None
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def tmdb_id(self) -> None:
"""音乐不使用 TMDB ID,兼容现有下载历史字段。"""
return None
@property
def imdb_id(self) -> None:
"""音乐不使用 IMDB ID,兼容现有下载历史字段。"""
return None
@property
def tvdb_id(self) -> None:
"""音乐不使用 TVDB ID,兼容现有下载历史字段。"""
return None
@property
def douban_id(self) -> None:
"""音乐不使用豆瓣 ID,兼容现有下载历史字段。"""
return None
@property
def bangumi_id(self) -> None:
"""音乐不使用 Bangumi ID,兼容现有下载历史字段。"""
return None
@property
def anilist_id(self) -> None:
"""音乐不使用 AniList ID,兼容现有下载历史字段。"""
return None
@property
def episode_group(self) -> None:
"""音乐没有剧集组,兼容现有下载历史字段。"""
return None
@property
def season(self) -> None:
"""音乐没有季信息,兼容失败冷却和目录逻辑。"""
return None
@property
def vote_average(self) -> float:
"""音乐当前没有评分字段,兼容订阅统计与持久化。"""
return 0.0
@property
def overview(self) -> str:
"""返回兼容订阅描述字段的音乐摘要。"""
parts = [self.artist, self.album, self.version]
return " · ".join(part for part in parts if part)
@property
def title_year(self) -> str:
"""返回包含年份的展示标题。"""
if not self.title:
return ""
return f"{self.title} ({self.year})" if self.year else self.title
@property
def poster_path(self) -> str | None:
"""返回兼容现有媒体卡片的封面地址。"""
return self.cover_url
@property
def backdrop_path(self) -> str | None:
"""返回兼容现有下载卡片的背景地址。"""
return self.cover_url
def get_message_image(self, default: bool | None = None) -> str | None:
"""返回通知消息使用的音乐封面。"""
return self.cover_url
def get_poster_image(self, default: bool | None = None) -> str | None:
"""返回海报位使用的音乐封面。"""
return self.cover_url
def get_backdrop_image(self, default: bool = False) -> str | None:
"""返回背景图位使用的音乐封面。"""
return self.cover_url
def clear(self) -> None:
"""清理不参与队列展示和持久化的上游原始响应。"""
self.raw_data.clear()
def to_dict(self) -> dict[str, Any]:
"""转换为兼容现有 Context 外层结构的字典。"""
payload = asdict(self)
payload.update(
{
"type": self.type.value,
"artist": self.artist,
"title_year": self.title_year,
"poster_path": self.poster_path,
"backdrop_path": self.backdrop_path,
"mediaid_prefix": self.source,
"overview": self.overview,
"vote_average": self.vote_average,
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化音乐元数据。"""
_validate_music_type(data.get("type"))
values = _music_init_values(cls, data)
values["artists"] = _music_string_list(values.get("artists") or data.get("artist"))
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
values["genres"] = _music_string_list(values.get("genres"))
values["names"] = _music_string_list(values.get("names"))
values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING)
values["raw_data"] = dict(values.get("raw_data") or {})
for key in (
"year",
"disc_number",
"track_number",
"total_tracks",
"duration",
"listen_count",
):
values[key] = _music_optional_int(values.get(key))
return cls(**values)
@dataclass
class MusicRelease:
"""音乐专辑下的单个发行版本(MusicBrainz Release)。"""
media_id: str | None = None
title: str | None = None
date: str | None = None
country: str | None = None
status: str | None = None
packaging: str | None = None
formats: list[str] = field(default_factory=list)
track_count: int | None = None
cover_url: str | None = None
@property
def year(self) -> int | None:
"""返回发行版本年份。"""
return _music_year_of(self.date)
def to_dict(self) -> dict[str, Any]:
"""转换为可传输的字典。"""
payload = asdict(self)
payload["year"] = self.year
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复发行版本信息。"""
values = _music_init_values(cls, data)
values["formats"] = _music_string_list(values.get("formats"))
values["track_count"] = _music_optional_int(values.get("track_count"))
return cls(**values)
@dataclass
class MusicAlbumInfo:
"""标准化音乐专辑信息(MusicBrainz Release Group)。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
music_type: str = field(default=MUSIC_ENTITY_ALBUM, init=False)
source: str | None = None
media_id: str | None = None
title: str | None = None
artists: list[str] = field(default_factory=list)
artist_ids: list[str] = field(default_factory=list)
# 专辑主类型:Album、EP、Single、Broadcast、Other
album_type: str | None = None
# 专辑副类型:Live、Compilation、Soundtrack、Remix 等
secondary_types: list[str] = field(default_factory=list)
release_date: str | None = None
cover_url: str | None = None
genres: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
rating: float = 0.0
rating_votes: int | None = None
detail_link: str | None = None
# 专辑内的音乐,按碟号和音轨号排序
tracks: list[MusicInfo] = field(default_factory=list)
# 同一专辑下的其它发行版本
releases: list[MusicRelease] = field(default_factory=list)
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def year(self) -> int | None:
"""返回专辑首次发行年份。"""
return _music_year_of(self.release_date)
@property
def category(self) -> str:
"""返回专辑主类型与副类型组合成的分类文本。"""
return " / ".join(part for part in [self.album_type, *self.secondary_types] if part)
@property
def track_count(self) -> int:
"""返回专辑内已解析的音乐数量。"""
return len(self.tracks)
@property
def duration(self) -> int | None:
"""返回专辑内所有音乐时长之和。"""
durations = [track.duration for track in self.tracks if track.duration]
return sum(durations) if durations else None
@property
def title_year(self) -> str:
"""返回包含年份的专辑展示标题。"""
if not self.title:
return ""
return f"{self.title} ({self.year})" if self.year else self.title
@property
def poster_path(self) -> str | None:
"""返回兼容现有媒体卡片的封面地址。"""
return self.cover_url
@property
def backdrop_path(self) -> str | None:
"""返回兼容现有详情页背景的封面地址。"""
return self.cover_url
@property
def overview(self) -> str:
"""返回专辑摘要,供卡片和通知复用。"""
parts = [self.artist, self.category, self.release_date, " / ".join(self.genres[:3])]
return " · ".join(part for part in parts if part)
def to_dict(self) -> dict[str, Any]:
"""转换为兼容前端 MediaInfo 结构的字典。"""
payload = {
key: value
for key, value in asdict(self).items()
if key not in {"tracks", "releases", "type"}
}
payload.update(
{
"type": self.type.value,
"artist": self.artist,
"album": self.title,
"year": self.year,
"category": self.category,
"duration": self.duration,
"total_tracks": self.track_count,
"title_year": self.title_year,
"poster_path": self.poster_path,
"backdrop_path": self.backdrop_path,
"mediaid_prefix": self.source,
"overview": self.overview,
"vote_average": self.rating,
"tracks": [track.to_dict() for track in self.tracks],
"releases": [release.to_dict() for release in self.releases],
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化专辑信息。"""
_validate_music_type(data.get("type"))
values = _music_init_values(cls, data)
for key in ("artists", "secondary_types", "genres", "tags"):
values[key] = _music_string_list(values.get(key))
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
values["rating"] = _music_optional_float(values.get("rating"))
values["rating_votes"] = _music_optional_int(values.get("rating_votes"))
values["tracks"] = [
item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item)
for item in data.get("tracks") or []
]
values["releases"] = [
item if isinstance(item, MusicRelease) else MusicRelease.from_dict(item)
for item in data.get("releases") or []
]
values["raw_data"] = dict(values.get("raw_data") or {})
return cls(**values)
def to_music_info(self) -> MusicInfo:
"""转换为专辑卡片使用的音乐信息,供列表接口统一返回。"""
return MusicInfo(
source=self.source,
media_id=self.media_id,
music_type=MUSIC_ENTITY_ALBUM,
title=self.title,
artists=list(self.artists),
artist_ids=list(self.artist_ids),
album=self.title,
album_artist=self.artist or None,
album_id=self.media_id,
album_type=self.album_type,
year=self.year,
release_date=self.release_date,
total_tracks=self.track_count or None,
duration=self.duration,
cover_url=self.cover_url,
category=self.category,
genres=list(self.genres),
names=[name for name in (self.title,) if name],
detail_link=self.detail_link,
)
@dataclass
class MusicArtistInfo:
"""标准化音乐艺术家信息(MusicBrainz Artist)。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
music_type: str = field(default=MUSIC_ENTITY_ARTIST, init=False)
source: str | None = None
media_id: str | None = None
name: str | None = None
sort_name: str | None = None
# MusicBrainz 消歧义说明,同名艺术家依靠该字段区分
disambiguation: str | None = None
# 艺术家类型:Person、Group、Orchestra、Choir、Character、Other
artist_type: str | None = None
gender: str | None = None
country: str | None = None
area: str | None = None
begin_date: str | None = None
end_date: str | None = None
ended: bool = False
genres: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
# 关联艺术家场景下的关系文本,例如乐队成员、子团体
relation: str | None = None
image_url: str | None = None
detail_link: str | None = None
# 外部站点链接,键为关系类型,值为地址
external_links: dict[str, str] = field(default_factory=dict)
album_count: int | None = None
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def title(self) -> str | None:
"""返回兼容通用媒体展示组件的标题。"""
return self.name
@property
def life_span(self) -> str:
"""返回艺术家活跃时间区间文本。"""
if not self.begin_date and not self.end_date:
return ""
end = self.end_date or ("" if self.ended else "")
return f"{self.begin_date or '?'} - {end}" if end else (self.begin_date or "")
@property
def overview(self) -> str:
"""返回艺术家摘要,供卡片和详情页复用。"""
parts = [
self.artist_type,
self.disambiguation,
self.area or self.country,
self.life_span,
" / ".join(self.genres[:3]),
]
return " · ".join(part for part in parts if part)
@property
def poster_path(self) -> str | None:
"""返回兼容现有卡片的艺术家图片地址。"""
return self.image_url
def to_dict(self) -> dict[str, Any]:
"""转换为可传输的字典。"""
payload = {key: value for key, value in asdict(self).items() if key != "type"}
payload.update(
{
"type": self.type.value,
"title": self.title,
"life_span": self.life_span,
"overview": self.overview,
"poster_path": self.poster_path,
"mediaid_prefix": self.source,
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化艺术家信息。"""
_validate_music_type(data.get("type"))
values = _music_init_values(cls, data)
for key in ("genres", "tags", "aliases"):
values[key] = _music_string_list(values.get(key))
values["ended"] = bool(values.get("ended"))
values["album_count"] = _music_optional_int(values.get("album_count"))
values["external_links"] = {
str(key): str(value)
for key, value in (values.get("external_links") or {}).items()
if value
}
values["raw_data"] = dict(values.get("raw_data") or {})
return cls(**values)
@dataclass
class TorrentInfo:
@@ -1142,7 +1650,7 @@ class Context:
"""
# 识别信息
meta_info: Optional[Union[MetaBase, MusicMeta]] = None
meta_info: Optional[MetaBase] = None
# 媒体信息
media_info: Optional[Union[MediaInfo, MusicInfo]] = None
# 种子信息
+1
View File
@@ -1,3 +1,4 @@
from .metabase import MetaBase
from .metavideo import MetaVideo
from .metaanime import MetaAnime
from .metamusic import MetaMusic
+163
View File
@@ -0,0 +1,163 @@
from typing import Any, Optional
from app.core.meta.metabase import MetaBase
from app.schemas.types import MediaType
def _optional_int(value: Any) -> Optional[int]:
"""将音频技术参数安全转换为整数,空值与非数字返回 None。"""
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _string_list(value: Any) -> list[str]:
"""将标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。"""
if value is None:
return []
if isinstance(value, str):
return [value] if value else []
if isinstance(value, (list, tuple, set)):
return [str(item) for item in value if str(item)]
return [str(value)]
class MetaMusic(MetaBase):
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
def __init__(
self,
org_string: Optional[str] = None,
title: Optional[str] = None,
artists: Optional[list[str]] = None,
album: Optional[str] = None,
album_artist: Optional[str] = None,
year: Optional[int] = None,
disc_number: Optional[int] = None,
track_number: Optional[int] = None,
total_discs: Optional[int] = None,
total_tracks: Optional[int] = None,
version: Optional[str] = None,
audio_format: Optional[str] = None,
bit_depth: Optional[int] = None,
sample_rate: Optional[int] = None,
bitrate: Optional[int] = None,
duration: Optional[int] = None,
isrc: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
):
# 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别
super().__init__(title or org_string or "")
self.type = MediaType.MUSIC
self.org_string = org_string
self.title = title
self.artists = list(artists) if artists else []
self.album = album
self.album_artist = album_artist
self.year = year
self.disc_number = disc_number
self.track_number = track_number
self.total_discs = total_discs
self.total_tracks = total_tracks
self.version = version
self.audio_format = audio_format
self.bit_depth = bit_depth
self.sample_rate = sample_rate
self.bitrate = bitrate
self.duration = duration
self.isrc = isrc
self.media_source = media_source
self.media_id = media_id
@property
def name(self) -> str:
"""返回搜索和展示使用的音乐名称,优先专辑名其次标题。"""
return self.album or self.title or ""
@name.setter
def name(self, value: Optional[str]) -> None:
"""辅助识别链回写标题时落到 title 字段,保持音乐名称可写。"""
self.title = value or None
@property
def original_name(self) -> str:
"""返回未经过通用识别词处理的原始名称,兼容影视识别链的公共访问。"""
return self.org_string or self.title or self.album or ""
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def season(self) -> None:
"""音乐没有季信息,兼容下载与事件链的通用访问。"""
return None
@property
def episode(self) -> None:
"""音乐没有集信息,兼容下载与历史记录的通用访问。"""
return None
@property
def apply_words(self) -> list[str]:
"""音乐当前不应用影视自定义识别词。"""
return []
def to_dict(self) -> dict[str, Any]:
"""转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。"""
return {
"type": self.type.value,
"org_string": self.org_string,
"title": self.title,
"artists": list(self.artists),
"artist": self.artist,
"album": self.album,
"album_artist": self.album_artist,
"year": self.year,
"disc_number": self.disc_number,
"track_number": self.track_number,
"total_discs": self.total_discs,
"total_tracks": self.total_tracks,
"version": self.version,
"audio_format": self.audio_format,
"bit_depth": self.bit_depth,
"sample_rate": self.sample_rate,
"bitrate": self.bitrate,
"duration": self.duration,
"isrc": self.isrc,
"media_source": self.media_source,
"media_id": self.media_id,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MetaMusic":
"""从字典恢复音乐解析结果,兼容 artists/artist 两种键。"""
raw_type = data.get("type")
if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"):
raise ValueError(f"不支持的音乐媒体类型:{raw_type}")
return cls(
org_string=data.get("org_string"),
title=data.get("title"),
artists=_string_list(data.get("artists") or data.get("artist")),
album=data.get("album"),
album_artist=data.get("album_artist"),
year=_optional_int(data.get("year")),
disc_number=_optional_int(data.get("disc_number")),
track_number=_optional_int(data.get("track_number")),
total_discs=_optional_int(data.get("total_discs")),
total_tracks=_optional_int(data.get("total_tracks")),
version=data.get("version"),
audio_format=data.get("audio_format"),
bit_depth=_optional_int(data.get("bit_depth")),
sample_rate=_optional_int(data.get("sample_rate")),
bitrate=_optional_int(data.get("bitrate")),
duration=_optional_int(data.get("duration")),
isrc=data.get("isrc"),
media_source=data.get("media_source"),
media_id=data.get("media_id"),
)
+18 -2
View File
@@ -6,7 +6,7 @@ from typing import Tuple, List, Optional
import regex as re
from app.core.config import settings
from app.core.meta import MetaAnime, MetaVideo, MetaBase
from app.core.meta import MetaAnime, MetaMusic, MetaVideo, MetaBase
from app.core.meta.infopath import (
clear_parsed_title_for_parent_merge,
should_use_parent_title_for_file_stem,
@@ -425,8 +425,16 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param title: 标题种子名文件名
:param subtitle: 副标题描述
:param custom_words: 自定义识别词列表
:return: MetaAnimeMetaVideo
:return: MetaAnimeMetaVideoMetaMusic
"""
# 音频文件名直接走音乐分支,避免进入影视季集解析
audio_suffix = Path(title).suffix.lower() if title else ""
if audio_suffix in settings.RMT_AUDIOEXT:
return MetaMusic(
org_string=title,
title=Path(title).stem,
audio_format=audio_suffix.lstrip(".").upper() or None,
)
rust_meta = None
if not _requires_python_metainfo(title, custom_words):
rust_meta = _meta_from_rust(
@@ -449,6 +457,14 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
:param path: 路径
:param custom_words: 自定义识别词列表
"""
# 音频文件直接构造音乐元数据,不参与父目录季集合并
audio_suffix = path.suffix.lower()
if audio_suffix in settings.RMT_AUDIOEXT:
return MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=audio_suffix.lstrip(".").upper() or None,
)
path_context = " ".join(
[path.name, path.parent.name, path.parent.parent.name]
)
-661
View File
@@ -1,661 +0,0 @@
from dataclasses import asdict, dataclass, field, fields
from typing import Any, Self
from app.schemas.types import MediaType
# 音乐可浏览实体类型:单曲(Recording)、专辑(Release Group)、艺术家(Artist
MUSIC_ENTITY_RECORDING = "recording"
MUSIC_ENTITY_ALBUM = "album"
MUSIC_ENTITY_ARTIST = "artist"
def _validate_music_type(value: object) -> None:
if value in {None, MediaType.MUSIC, MediaType.MUSIC.value, "music"}:
return
raise ValueError(f"不支持的音乐媒体类型:{value}")
def _string_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value] if value else []
if isinstance(value, (list, tuple, set)):
return [str(item) for item in value if str(item)]
return [str(value)]
def _aligned_list(value: object) -> list[str]:
"""保留原始位置的字符串列表,用于与艺术家名称按下标对应的 ID 列表。"""
if value is None:
return []
if isinstance(value, (list, tuple)):
return [str(item or "") for item in value]
return [str(value or "")]
def _optional_int(value: object) -> int | None:
if value in {None, ""}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(value: object) -> float:
if value in {None, ""}:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _year_of(release_date: object) -> int | None:
"""从 MusicBrainz 可变精度日期(YYYY / YYYY-MM / YYYY-MM-DD)提取年份。"""
text = str(release_date or "")[:4]
return int(text) if text.isdigit() else None
def _init_values(model: type, data: dict[str, Any]) -> dict[str, Any]:
init_names = {item.name for item in fields(model) if item.init}
return {key: value for key, value in data.items() if key in init_names}
@dataclass
class MusicMeta:
"""音乐名称及音频文件解析结果。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
org_string: str | None = None
title: str | None = None
artists: list[str] = field(default_factory=list)
album: str | None = None
album_artist: str | None = None
year: int | None = None
disc_number: int | None = None
track_number: int | None = None
total_discs: int | None = None
total_tracks: int | None = None
version: str | None = None
audio_format: str | None = None
bit_depth: int | None = None
sample_rate: int | None = None
bitrate: int | None = None
duration: int | None = None
isrc: str | None = None
media_source: str | None = None
media_id: str | None = None
@property
def name(self) -> str:
"""返回搜索和展示使用的音乐名称。"""
return self.album or self.title or ""
@property
def original_name(self) -> str:
"""返回未经过通用识别词处理的原始名称,兼容影视识别链的公共访问。"""
return self.org_string or self.title or self.album or ""
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def season(self) -> None:
"""音乐没有季信息,兼容下载与事件链的通用访问。"""
return None
@property
def begin_season(self) -> None:
"""音乐没有起始季,兼容整理作业分组。"""
return None
@property
def end_season(self) -> None:
"""音乐没有结束季,兼容整理元数据比较。"""
return None
@property
def begin_episode(self) -> None:
"""音乐没有起始集,兼容整理预览。"""
return None
@property
def end_episode(self) -> None:
"""音乐没有结束集,兼容整理预览。"""
return None
@property
def episode(self) -> None:
"""音乐没有集信息,兼容下载与历史记录的通用访问。"""
return None
@property
def season_list(self) -> list[int]:
"""音乐返回空季列表,避免通用下载链访问视频专属字段。"""
return []
@property
def episode_list(self) -> list[int]:
"""音乐返回空集列表,避免通用下载链访问视频专属字段。"""
return []
@property
def season_episode(self) -> str:
"""音乐没有季集展示文本。"""
return ""
@property
def part(self) -> None:
"""音乐不使用影视分段字段。"""
return None
@property
def apply_words(self) -> list[str]:
"""音乐当前不应用影视自定义识别词。"""
return []
@property
def resource_team(self) -> None:
"""音乐当前不使用影视制作组字段。"""
return None
@property
def customization(self) -> None:
"""音乐当前不使用影视自定义占位符。"""
return None
@property
def tmdbid(self) -> None:
"""音乐不使用 TMDB ID。"""
return None
@property
def doubanid(self) -> None:
"""音乐不使用豆瓣 ID。"""
return None
@property
def bangumiid(self) -> None:
"""音乐不使用 Bangumi ID。"""
return None
@property
def anilistid(self) -> None:
"""音乐不使用 AniList ID。"""
return None
def to_dict(self) -> dict[str, Any]:
"""转换为可持久化和传输的字典。"""
payload = asdict(self)
payload["type"] = self.type.value
payload["artist"] = self.artist
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复音乐解析结果。"""
_validate_music_type(data.get("type"))
values = _init_values(cls, data)
values["artists"] = _string_list(values.get("artists") or data.get("artist"))
for key in (
"year",
"disc_number",
"track_number",
"total_discs",
"total_tracks",
"bit_depth",
"sample_rate",
"bitrate",
"duration",
):
values[key] = _optional_int(values.get(key))
return cls(**values)
@dataclass
class MusicInfo:
"""标准化音乐元数据信息。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
source: str | None = None
media_id: str | None = None
# 音乐实体类型,用于区分单曲、专辑和艺术家三类可浏览对象
music_type: str = MUSIC_ENTITY_RECORDING
title: str | None = None
artists: list[str] = field(default_factory=list)
# 艺术家标准 ID,顺序与 artists 一致,供详情页关联跳转
artist_ids: list[str] = field(default_factory=list)
album: str | None = None
album_artist: str | None = None
# 所属专辑标准 IDMusicBrainz Release Group
album_id: str | None = None
# 专辑主类型:Album、EP、Single 等
album_type: str | None = None
year: int | None = None
release_date: str | None = None
disc_number: int | None = None
track_number: int | None = None
total_tracks: int | None = None
duration: int | None = None
isrc: str | None = None
cover_url: str | None = None
lyrics: str | None = None
version: str | None = None
category: str = ""
genres: list[str] = field(default_factory=list)
names: list[str] = field(default_factory=list)
detail_link: str | None = None
listen_count: int | None = None
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def tmdb_id(self) -> None:
"""音乐不使用 TMDB ID,兼容现有下载历史字段。"""
return None
@property
def imdb_id(self) -> None:
"""音乐不使用 IMDB ID,兼容现有下载历史字段。"""
return None
@property
def tvdb_id(self) -> None:
"""音乐不使用 TVDB ID,兼容现有下载历史字段。"""
return None
@property
def douban_id(self) -> None:
"""音乐不使用豆瓣 ID,兼容现有下载历史字段。"""
return None
@property
def bangumi_id(self) -> None:
"""音乐不使用 Bangumi ID,兼容现有下载历史字段。"""
return None
@property
def anilist_id(self) -> None:
"""音乐不使用 AniList ID,兼容现有下载历史字段。"""
return None
@property
def episode_group(self) -> None:
"""音乐没有剧集组,兼容现有下载历史字段。"""
return None
@property
def season(self) -> None:
"""音乐没有季信息,兼容失败冷却和目录逻辑。"""
return None
@property
def vote_average(self) -> float:
"""音乐当前没有评分字段,兼容订阅统计与持久化。"""
return 0.0
@property
def overview(self) -> str:
"""返回兼容订阅描述字段的音乐摘要。"""
parts = [self.artist, self.album, self.version]
return " · ".join(part for part in parts if part)
@property
def title_year(self) -> str:
"""返回包含年份的展示标题。"""
if not self.title:
return ""
return f"{self.title} ({self.year})" if self.year else self.title
@property
def poster_path(self) -> str | None:
"""返回兼容现有媒体卡片的封面地址。"""
return self.cover_url
@property
def backdrop_path(self) -> str | None:
"""返回兼容现有下载卡片的背景地址。"""
return self.cover_url
def get_message_image(self, default: bool | None = None) -> str | None:
"""返回通知消息使用的音乐封面。"""
return self.cover_url
def get_poster_image(self, default: bool | None = None) -> str | None:
"""返回海报位使用的音乐封面。"""
return self.cover_url
def get_backdrop_image(self, default: bool = False) -> str | None:
"""返回背景图位使用的音乐封面。"""
return self.cover_url
def clear(self) -> None:
"""清理不参与队列展示和持久化的上游原始响应。"""
self.raw_data.clear()
def to_dict(self) -> dict[str, Any]:
"""转换为兼容现有 Context 外层结构的字典。"""
payload = asdict(self)
payload.update(
{
"type": self.type.value,
"artist": self.artist,
"title_year": self.title_year,
"poster_path": self.poster_path,
"backdrop_path": self.backdrop_path,
"mediaid_prefix": self.source,
"overview": self.overview,
"vote_average": self.vote_average,
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化音乐元数据。"""
_validate_music_type(data.get("type"))
values = _init_values(cls, data)
values["artists"] = _string_list(values.get("artists") or data.get("artist"))
values["artist_ids"] = _aligned_list(values.get("artist_ids"))
values["genres"] = _string_list(values.get("genres"))
values["names"] = _string_list(values.get("names"))
values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING)
values["raw_data"] = dict(values.get("raw_data") or {})
for key in (
"year",
"disc_number",
"track_number",
"total_tracks",
"duration",
"listen_count",
):
values[key] = _optional_int(values.get(key))
return cls(**values)
@dataclass
class MusicRelease:
"""音乐专辑下的单个发行版本(MusicBrainz Release)。"""
media_id: str | None = None
title: str | None = None
date: str | None = None
country: str | None = None
status: str | None = None
packaging: str | None = None
formats: list[str] = field(default_factory=list)
track_count: int | None = None
cover_url: str | None = None
@property
def year(self) -> int | None:
"""返回发行版本年份。"""
return _year_of(self.date)
def to_dict(self) -> dict[str, Any]:
"""转换为可传输的字典。"""
payload = asdict(self)
payload["year"] = self.year
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复发行版本信息。"""
values = _init_values(cls, data)
values["formats"] = _string_list(values.get("formats"))
values["track_count"] = _optional_int(values.get("track_count"))
return cls(**values)
@dataclass
class MusicAlbumInfo:
"""标准化音乐专辑信息(MusicBrainz Release Group)。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
music_type: str = field(default=MUSIC_ENTITY_ALBUM, init=False)
source: str | None = None
media_id: str | None = None
title: str | None = None
artists: list[str] = field(default_factory=list)
artist_ids: list[str] = field(default_factory=list)
# 专辑主类型:Album、EP、Single、Broadcast、Other
album_type: str | None = None
# 专辑副类型:Live、Compilation、Soundtrack、Remix 等
secondary_types: list[str] = field(default_factory=list)
release_date: str | None = None
cover_url: str | None = None
genres: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
rating: float = 0.0
rating_votes: int | None = None
detail_link: str | None = None
# 专辑内的音乐,按碟号和音轨号排序
tracks: list[MusicInfo] = field(default_factory=list)
# 同一专辑下的其它发行版本
releases: list[MusicRelease] = field(default_factory=list)
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def artist(self) -> str:
"""返回兼容现有展示组件的艺术家文本。"""
return " / ".join(self.artists)
@property
def year(self) -> int | None:
"""返回专辑首次发行年份。"""
return _year_of(self.release_date)
@property
def category(self) -> str:
"""返回专辑主类型与副类型组合成的分类文本。"""
return " / ".join(part for part in [self.album_type, *self.secondary_types] if part)
@property
def track_count(self) -> int:
"""返回专辑内已解析的音乐数量。"""
return len(self.tracks)
@property
def duration(self) -> int | None:
"""返回专辑内所有音乐时长之和。"""
durations = [track.duration for track in self.tracks if track.duration]
return sum(durations) if durations else None
@property
def title_year(self) -> str:
"""返回包含年份的专辑展示标题。"""
if not self.title:
return ""
return f"{self.title} ({self.year})" if self.year else self.title
@property
def poster_path(self) -> str | None:
"""返回兼容现有媒体卡片的封面地址。"""
return self.cover_url
@property
def backdrop_path(self) -> str | None:
"""返回兼容现有详情页背景的封面地址。"""
return self.cover_url
@property
def overview(self) -> str:
"""返回专辑摘要,供卡片和通知复用。"""
parts = [self.artist, self.category, self.release_date, " / ".join(self.genres[:3])]
return " · ".join(part for part in parts if part)
def to_dict(self) -> dict[str, Any]:
"""转换为兼容前端 MediaInfo 结构的字典。"""
payload = {
key: value
for key, value in asdict(self).items()
if key not in {"tracks", "releases", "type"}
}
payload.update(
{
"type": self.type.value,
"artist": self.artist,
"album": self.title,
"year": self.year,
"category": self.category,
"duration": self.duration,
"total_tracks": self.track_count,
"title_year": self.title_year,
"poster_path": self.poster_path,
"backdrop_path": self.backdrop_path,
"mediaid_prefix": self.source,
"overview": self.overview,
"vote_average": self.rating,
"tracks": [track.to_dict() for track in self.tracks],
"releases": [release.to_dict() for release in self.releases],
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化专辑信息。"""
_validate_music_type(data.get("type"))
values = _init_values(cls, data)
for key in ("artists", "secondary_types", "genres", "tags"):
values[key] = _string_list(values.get(key))
values["artist_ids"] = _aligned_list(values.get("artist_ids"))
values["rating"] = _optional_float(values.get("rating"))
values["rating_votes"] = _optional_int(values.get("rating_votes"))
values["tracks"] = [
item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item)
for item in data.get("tracks") or []
]
values["releases"] = [
item if isinstance(item, MusicRelease) else MusicRelease.from_dict(item)
for item in data.get("releases") or []
]
values["raw_data"] = dict(values.get("raw_data") or {})
return cls(**values)
def to_music_info(self) -> MusicInfo:
"""转换为专辑卡片使用的音乐信息,供列表接口统一返回。"""
return MusicInfo(
source=self.source,
media_id=self.media_id,
music_type=MUSIC_ENTITY_ALBUM,
title=self.title,
artists=list(self.artists),
artist_ids=list(self.artist_ids),
album=self.title,
album_artist=self.artist or None,
album_id=self.media_id,
album_type=self.album_type,
year=self.year,
release_date=self.release_date,
total_tracks=self.track_count or None,
duration=self.duration,
cover_url=self.cover_url,
category=self.category,
genres=list(self.genres),
names=[name for name in (self.title,) if name],
detail_link=self.detail_link,
)
@dataclass
class MusicArtistInfo:
"""标准化音乐艺术家信息(MusicBrainz Artist)。"""
type: MediaType = field(default=MediaType.MUSIC, init=False)
music_type: str = field(default=MUSIC_ENTITY_ARTIST, init=False)
source: str | None = None
media_id: str | None = None
name: str | None = None
sort_name: str | None = None
# MusicBrainz 消歧义说明,同名艺术家依靠该字段区分
disambiguation: str | None = None
# 艺术家类型:Person、Group、Orchestra、Choir、Character、Other
artist_type: str | None = None
gender: str | None = None
country: str | None = None
area: str | None = None
begin_date: str | None = None
end_date: str | None = None
ended: bool = False
genres: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
# 关联艺术家场景下的关系文本,例如乐队成员、子团体
relation: str | None = None
image_url: str | None = None
detail_link: str | None = None
# 外部站点链接,键为关系类型,值为地址
external_links: dict[str, str] = field(default_factory=dict)
album_count: int | None = None
raw_data: dict[str, Any] = field(default_factory=dict)
@property
def title(self) -> str | None:
"""返回兼容通用媒体展示组件的标题。"""
return self.name
@property
def life_span(self) -> str:
"""返回艺术家活跃时间区间文本。"""
if not self.begin_date and not self.end_date:
return ""
end = self.end_date or ("" if self.ended else "")
return f"{self.begin_date or '?'} - {end}" if end else (self.begin_date or "")
@property
def overview(self) -> str:
"""返回艺术家摘要,供卡片和详情页复用。"""
parts = [
self.artist_type,
self.disambiguation,
self.area or self.country,
self.life_span,
" / ".join(self.genres[:3]),
]
return " · ".join(part for part in parts if part)
@property
def poster_path(self) -> str | None:
"""返回兼容现有卡片的艺术家图片地址。"""
return self.image_url
def to_dict(self) -> dict[str, Any]:
"""转换为可传输的字典。"""
payload = {key: value for key, value in asdict(self).items() if key != "type"}
payload.update(
{
"type": self.type.value,
"title": self.title,
"life_span": self.life_span,
"overview": self.overview,
"poster_path": self.poster_path,
"mediaid_prefix": self.source,
}
)
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化艺术家信息。"""
_validate_music_type(data.get("type"))
values = _init_values(cls, data)
for key in ("genres", "tags", "aliases"):
values[key] = _string_list(values.get(key))
values["ended"] = bool(values.get("ended"))
values["album_count"] = _optional_int(values.get("album_count"))
values["external_links"] = {
str(key): str(value)
for key, value in (values.get("external_links") or {}).items()
if value
}
values["raw_data"] = dict(values.get("raw_data") or {})
return cls(**values)
+7 -6
View File
@@ -6,7 +6,8 @@ from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC
from mutagen.mp4 import MP4, MP4Cover
from app.core.music import MusicInfo, MusicMeta
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.log import logger
@@ -14,9 +15,9 @@ class AudioMetadataHelper:
"""读取和写入音频标签,并转换为标准音乐元数据。"""
@classmethod
def read(cls, path: Path) -> MusicMeta:
def read(cls, path: Path) -> MetaMusic:
"""读取本地音频文件标签;读取失败时返回基于文件名的最小元数据。"""
fallback = MusicMeta(
fallback = MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=path.suffix.lstrip(".").upper() or None,
@@ -33,7 +34,7 @@ class AudioMetadataHelper:
track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber"))
disc_number, total_discs = cls._number_pair(cls._first(tags, "discnumber"))
info = getattr(audio, "info", None)
return MusicMeta(
return MetaMusic(
org_string=path.name,
title=cls._first(tags, "title") or path.stem,
artists=cls._values(tags, "artist"),
@@ -57,7 +58,7 @@ class AudioMetadataHelper:
def write(
cls,
path: Path,
music: Union[MusicMeta, MusicInfo],
music: Union[MetaMusic, MusicInfo],
cover_data: Optional[bytes] = None,
cover_mime: str = "image/jpeg",
overwrite: bool = True,
@@ -93,7 +94,7 @@ class AudioMetadataHelper:
return False
@classmethod
def _tag_values(cls, music: Union[MusicMeta, MusicInfo]) -> dict[str, Any]:
def _tag_values(cls, music: Union[MetaMusic, MusicInfo]) -> dict[str, Any]:
"""把标准音乐对象转换为 Mutagen Easy 标签字典。"""
track_number = cls._number_text(
getattr(music, "track_number", None),
+3 -4
View File
@@ -15,9 +15,8 @@ from jinja2 import Template
from app.core.cache import TTLCache
from app.core.config import global_vars
from app.core.context import MediaInfo, TorrentInfo
from app.core.meta import MetaBase
from app.core.music import MusicInfo, MusicMeta
from app.core.context import MediaInfo, MusicInfo, TorrentInfo
from app.core.meta import MetaBase, MetaMusic
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.message import Notification
@@ -196,7 +195,7 @@ class TemplateContextBuilder:
"""
if not meta:
return
if isinstance(meta, MusicMeta):
if isinstance(meta, MetaMusic):
context.update({
"original_name": meta.org_string or meta.title,
"name": cls.__convert_invalid_characters(meta.title),
+1 -1
View File
@@ -2,7 +2,7 @@ from typing import Any, Optional, Tuple, Union
from app.core.cache import cached
from app.core.config import settings
from app.core.music import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
+4 -4
View File
@@ -4,14 +4,14 @@ from typing import Any, Optional, Tuple, Union
from app.core.cache import cached
from app.core.config import settings
from app.core.music import (
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
MusicMeta,
MusicRelease,
)
from app.core.meta import MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import MediaRecognizeType, ModuleType
@@ -97,7 +97,7 @@ class MusicBrainzModule(_ModuleBase):
"""返回音乐元数据模块执行优先级。"""
return 5
def search_music(self, meta: MusicMeta, limit: int = 20) -> list[MusicInfo]:
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""根据标准音乐搜索条件返回 MusicBrainz 录音候选。"""
query = self._build_query(meta)
if not query:
@@ -208,7 +208,7 @@ class MusicBrainzModule(_ModuleBase):
return self._related_artists(payload.get("relations") or [], count=count)
@classmethod
def _build_query(cls, meta: MusicMeta) -> str:
def _build_query(cls, meta: MetaMusic) -> str:
"""构造 MusicBrainz Recording 搜索表达式。"""
clauses = []
if meta.title:
+2 -2
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"],
+3 -3
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",
+48
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):
+3 -2
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(
+6 -5
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",
+2 -2
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")
+1 -1
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
+114
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
+3 -2
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
+58 -4
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()
+3 -2
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"
+3 -2
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",
+3 -3
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 == "晴天"
+1 -1
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
+3
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",
+1 -1
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