Files
MoviePilot/app/chain/music.py
2026-08-09 17:05:36 +08:00

347 lines
13 KiB
Python

import re
from pathlib import Path
from typing import Any, Iterable, Optional
from fastapi.concurrency import run_in_threadpool
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.helper.audio import AudioMetadataHelper
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 read_path_meta(cls, path: str | Path) -> MetaMusic:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return cls.parse_query(file_path.stem)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
# Mutagen 会同步读取本地文件,异步识别入口需要移出事件循环。
meta = await run_in_threadpool(self.read_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.read_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()