Files
MoviePilot/app/chain/music.py
jxxghp 60aa762ca1 feat: 音乐识别与刮削统一入口整合
- 音乐识别统一走 recognize_media,移除 MusicChain.recognize/async_recognize 及 /music/search 独立端点
- 音乐刮削收拢到 MediaChain.scrape_music_metadata,消除 MusicChain 对 MediaChain 的嵌套引用
- MetaInfo/MetaInfoPath 增加 force_video 参数,影视附加音轨保留季集归属
- 整理、订阅、搜索、API 层音乐识别入口全部统一
2026-08-09 10:06:54 +08:00

340 lines
12 KiB
Python

import re
from pathlib import Path
from typing import Any, Iterable, Optional
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_RECORDING,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.core.meta import MetaMusic
from app.log import logger
class MusicChain(ChainBase):
"""音乐元数据搜索、识别与站点搜索参数编排链。"""
_artist_title_pattern = re.compile(r"^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$")
_spaces_pattern = re.compile(r"\s+")
@classmethod
def parse_query(cls, query: str) -> MetaMusic:
"""将用户输入的搜索关键词解析为音乐元数据。"""
normalized = cls._normalize_text(query)
meta = MetaMusic(org_string=query, title=normalized)
match = cls._artist_title_pattern.match(normalized)
if match:
meta.artists = [match.group("artist").strip()]
meta.title = match.group("title").strip()
return meta
@classmethod
def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""根据音乐元数据生成按精确度递减的站点搜索关键词。"""
artists = music.artists or []
artist = artists[0] if artists else music.album_artist
keywords = []
if artist and music.album:
keywords.append(f"{artist} {music.album}")
if artist and music.title:
keywords.append(f"{artist} {music.title}")
if music.album:
keywords.append(music.album)
if music.title:
keywords.append(music.title)
return cls._unique_texts(keywords)
@classmethod
def normalize_candidates(
cls,
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并去重来自一个或多个音乐元数据模块的候选。"""
results: list[MusicInfo] = []
identities: set[tuple[str, ...]] = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
identity = cls._candidate_identity(info)
if identity in identities:
continue
identities.add(identity)
results.append(info)
if limit and len(results) >= limit:
break
return results
def search(self, query: str, limit: int = 20) -> list[MusicInfo]:
"""调用已启用的音乐元数据模块搜索候选。"""
meta = self.parse_query(query)
candidates = self.run_module("search_music", meta=meta, limit=limit)
return self.normalize_candidates(candidates, limit=limit)
async def async_search(self, query: str, limit: int = 20) -> list[MusicInfo]:
"""异步调用已启用的音乐元数据模块搜索候选。"""
meta = self.parse_query(query)
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
return self.normalize_candidates(candidates, limit=limit)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
"""读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
candidates = self.run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * count,
count=count,
)
return self.normalize_candidates(candidates, limit=count)
async def async_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 热门榜单,并应用音乐探索筛选和排序。"""
candidates = await self.async_run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * count,
count=count,
entity=entity,
)
results = self._filter_candidates(
self.normalize_candidates(candidates),
min_listen_count=min_listen_count,
with_cover=with_cover,
)
results.sort(
key=lambda info: info.listen_count or 0,
reverse=sort_by != "listen_count.asc",
)
return results[:count]
async def async_fresh_releases(
self,
days: int = 14,
sort: str = "release_date",
past: bool = True,
future: bool = True,
page: int = 1,
count: int = 30,
with_cover: bool = False,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 官方新发行专辑,排序由官方接口决定。"""
candidates = await self.async_run_module(
"music_fresh_releases",
days=days,
sort=sort,
past=past,
future=future,
offset=max(page - 1, 0) * count,
count=count,
)
results = self._filter_candidates(
self.normalize_candidates(candidates),
min_listen_count=0,
with_cover=with_cover,
)
return results[:count]
async def async_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步按来源和专辑 ID 获取标准化专辑详情及曲目。"""
result = await self.async_run_module(
"music_album",
source=source,
media_id=media_id,
)
if isinstance(result, MusicAlbumInfo):
return result
if isinstance(result, dict):
return MusicAlbumInfo.from_dict(result)
return None
async def async_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""异步按来源和艺术家 ID 获取标准化艺术家详情。"""
result = await self.async_run_module(
"music_artist",
source=source,
media_id=media_id,
)
if isinstance(result, MusicArtistInfo):
return result
if isinstance(result, dict):
return MusicArtistInfo.from_dict(result)
return None
async def async_artist_albums(
self,
source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""异步分页读取艺术家名下的专辑、EP 和单曲。"""
candidates = await self.async_run_module(
"music_artist_albums",
source=source,
media_id=media_id,
page=page,
count=count,
album_type=album_type,
)
return self.normalize_candidates(candidates, limit=count)
async def async_artist_related(
self,
source: str,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""异步读取关联艺术家,供详情页继续浏览。"""
candidates = await self.async_run_module(
"music_artist_related",
source=source,
media_id=media_id,
count=count,
)
results: list[MusicArtistInfo] = []
identities: set[str] = set()
for candidate in candidates or []:
info = (
candidate
if isinstance(candidate, MusicArtistInfo)
else MusicArtistInfo.from_dict(candidate)
)
identity = (info.media_id or info.name or "").casefold()
if not identity or identity in identities:
continue
identities.add(identity)
results.append(info)
return results[:count]
@staticmethod
def _filter_candidates(
candidates: list[MusicInfo],
min_listen_count: int,
with_cover: bool,
) -> list[MusicInfo]:
"""按热度和封面条件过滤音乐探索候选。"""
results = candidates
if min_listen_count > 0:
results = [
info for info in results
if (info.listen_count or 0) >= min_listen_count
]
if with_cover:
results = [info for info in results if info.cover_url]
return list(results)
@classmethod
def is_audio_path(cls, path: str | Path) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def parse_path_meta(cls, path: str | Path) -> MetaMusic:
"""按音频文件名解析最小音乐元数据,识别阶段不读取文件标签。"""
return cls.parse_query(Path(path).stem)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""根据文件名的歌曲信息识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = await self.async_recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
def recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""同步根据文件名识别音乐,并保留离线最小结果。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = self.recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
@classmethod
def to_meta(cls, info: MusicInfo) -> MetaMusic:
"""将用户选中的标准音乐信息转换为下载和整理上下文元数据。"""
return MetaMusic(
title=info.title,
artists=list(info.artists),
album=info.album,
album_artist=info.album_artist,
year=info.year,
disc_number=info.disc_number,
track_number=info.track_number,
total_tracks=info.total_tracks,
version=info.version,
duration=info.duration,
isrc=info.isrc,
media_source=info.source,
media_id=info.media_id,
)
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
title=meta.title,
artists=list(meta.artists),
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
disc_number=meta.disc_number,
track_number=meta.track_number,
total_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
@classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""
if info.source and info.media_id:
return "id", info.source.casefold(), info.media_id.casefold()
return (
"metadata",
cls._normalize_text(info.title).casefold(),
cls._normalize_text(info.artist).casefold(),
cls._normalize_text(info.album).casefold(),
)
@classmethod
def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]:
"""按规范化文本去重并保留原始顺序。"""
results = []
seen = set()
for value in values:
normalized = cls._normalize_text(value)
identity = normalized.casefold()
if not normalized or identity in seen:
continue
seen.add(identity)
results.append(normalized)
return results
@classmethod
def _normalize_text(cls, value: Optional[str]) -> str:
"""清理音乐检索文本中的多余空白。"""
return cls._spaces_pattern.sub(" ", str(value or "")).strip()