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

129 lines
4.6 KiB
Python

"""多来源音乐目录搜索应用服务。"""
import asyncio
from typing import Any, Callable, Iterable, Optional
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaSourceSelection
class MusicCatalogService:
"""编排音乐来源选择、容错搜索和候选归一化。"""
def __init__(
self,
source_resolver: Callable[[MediaSource], Any],
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,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
candidates = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if not chain:
continue
try:
candidates.extend(chain.search_music(meta, limit=limit))
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return self.normalize_candidates(candidates, limit=limit)
async def async_search(
self,
query: str,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
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))
source_results = await asyncio.gather(*searches) if searches else []
return self.normalize_candidates(
[candidate for results in source_results for candidate in results],
limit=limit,
)
async def _async_search_source(
self,
chain: Any,
source: MediaSource,
meta: MetaMusic,
limit: int,
) -> list[MusicInfo]:
"""异步搜索单个来源,并把异常降级为空候选。"""
try:
return await chain.async_search_music(meta, limit=limit)
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return []