Files
MoviePilot/app/application/music/catalog.py

167 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多来源音乐目录搜索应用服务。"""
import asyncio
from itertools import zip_longest
from typing import Any, Callable, Iterable, Optional, Protocol
from app.domain.context import MusicAlbumInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaSourceSelection, MusicEntityType
class MusicSourcePort(Protocol):
"""目录服务只依赖音乐搜索与专辑详情,不依赖具体Chain或来源模块。"""
def search_music(self, meta: MetaMusic, limit: int = 20,
music_types: Optional[Iterable[MusicEntityType]] = None) -> list[MusicInfo]:
"""同步搜索一个固定来源。"""
...
async def async_search_music(self, meta: MetaMusic, limit: int = 20,
music_types: Optional[Iterable[MusicEntityType]] = None) -> list[MusicInfo]:
"""异步搜索一个固定来源。"""
...
def get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""读取同来源专辑及其曲目表。"""
...
async def async_get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步读取同来源专辑及其曲目表。"""
...
class MusicCatalogService:
"""编排音乐来源选择、容错搜索和候选归一化。"""
def __init__(
self,
source_resolver: Callable[[MediaSource], Optional[MusicSourcePort]],
warning: Callable[[str], None],
primary_source: MediaSource = MediaSource.MusicBrainz,
) -> None:
"""注入来源解析器、告警输出和默认音乐来源。"""
self._source_resolver = source_resolver
self._warning = warning
self._primary_source = primary_source
def search_sources(
self,
media_source: Optional[MediaSourceSelection],
) -> list[MediaSource]:
"""解析有序音乐来源,保留合法插件扩展来源并去重。"""
if not media_source:
return [self._primary_source]
raw_sources = (
(media_source,)
if isinstance(media_source, MediaSource)
else media_source
)
sources = []
for raw_source in raw_sources:
source = normalize_media_source(raw_source)
if source and source not in sources:
sources.append(source)
return sources
@staticmethod
def normalize_candidates(
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并按来源身份或元数据去重音乐候选。"""
results = []
identities = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
if info.media_source and info.media_id:
identity = (
"id",
str(info.media_source).casefold(),
str(info.music_type).casefold(),
str(info.media_id).casefold(),
)
else:
identity = (
"metadata",
str(info.music_type).casefold(),
MetaMusic.compact_text(info.title),
MetaMusic.compact_text(info.artist),
MetaMusic.compact_text(info.album),
)
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 | MetaMusic,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
music_types: Optional[Iterable[MusicEntityType]] = None,
) -> list[MusicInfo]:
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query)
normalized_music_types = tuple(music_types) if music_types is not None else None
candidates = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if not chain:
continue
try:
search_options: dict[str, Any] = {"limit": limit}
if normalized_music_types is not None:
search_options["music_types"] = normalized_music_types
candidates.append(chain.search_music(meta, **search_options))
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return self.merge_sources(candidates, limit=limit)
async def async_search(
self,
query: str | MetaMusic,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
music_types: Optional[Iterable[MusicEntityType]] = None,
) -> list[MusicInfo]:
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query)
normalized_music_types = tuple(music_types) if music_types is not None else None
searches = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if chain:
searches.append(self._async_search_source(chain, source, meta, limit, normalized_music_types))
source_results = await asyncio.gather(*searches) if searches else []
return self.merge_sources(source_results, limit=limit)
@classmethod
def merge_sources(cls, groups: list[list[MusicInfo]], limit: int) -> list[MusicInfo]:
"""先按来源独立去重再轮询合并,避免首个来源占满全局条数上限。"""
normalized = [cls.normalize_candidates(group) for group in groups]
candidates = [item for row in zip_longest(*normalized) for item in row if item is not None]
return cls.normalize_candidates(candidates, limit=limit)
async def _async_search_source(
self,
chain: Any,
source: MediaSource,
meta: MetaMusic,
limit: int,
music_types: Optional[Iterable[MusicEntityType]],
) -> list[MusicInfo]:
"""异步搜索单个来源,并把异常降级为空候选。"""
try:
search_options: dict[str, Any] = {"limit": limit}
if music_types is not None:
search_options["music_types"] = music_types
return await chain.async_search_music(meta, **search_options)
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return []