mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
feat: 识别文件类型:音乐
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
# 所属专辑标准 ID(MusicBrainz 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,3 +1,4 @@
|
||||
from .metabase import MetaBase
|
||||
from .metavideo import MetaVideo
|
||||
from .metaanime import MetaAnime
|
||||
from .metamusic import MetaMusic
|
||||
|
||||
@@ -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
@@ -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: MetaAnime、MetaVideo
|
||||
:return: MetaAnime、MetaVideo、MetaMusic
|
||||
"""
|
||||
# 音频文件名直接走音乐分支,避免进入影视季集解析
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# 所属专辑标准 ID(MusicBrainz 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
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == "晴天"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user