mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 15:50:24 +08:00
167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""多来源音乐目录搜索应用服务。"""
|
||
|
||
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 []
|