mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 17:36:49 +08:00
refactor: 统一音乐与影视的识别搜索匹配流程
This commit is contained in:
@@ -348,9 +348,11 @@ FIELD_DESCRIPTIONS = {
|
|||||||
"modify_time": "Storage item modification timestamp.",
|
"modify_time": "Storage item modification timestamp.",
|
||||||
"mtype": "MoviePilot media type or subscription-history category required by the operation.",
|
"mtype": "MoviePilot media type or subscription-history category required by the operation.",
|
||||||
"music_type": "Music identity level: recording, album, or artist where supported.",
|
"music_type": "Music identity level: recording, album, or artist where supported.",
|
||||||
|
"include_candidates": "Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.",
|
||||||
"name": "Human-readable name of the site, storage item, subscription, or rule group.",
|
"name": "Human-readable name of the site, storage item, subscription, or rule group.",
|
||||||
"new_name": "Replacement name for the existing filter-rule group.",
|
"new_name": "Replacement name for the existing filter-rule group.",
|
||||||
"new_rule_id": "Replacement stable ID for the existing custom filter rule.",
|
"new_rule_id": "Replacement stable ID for the existing custom filter rule.",
|
||||||
|
"next_run_at": "Next scheduled subscription search time. Null when no future execution is planned.",
|
||||||
"note": "Structured auxiliary metadata stored with the record.",
|
"note": "Structured auxiliary metadata stored with the record.",
|
||||||
"operation": "Setting update mode: replace, merge_dict, upsert_list_item, or remove_list_item.",
|
"operation": "Setting update mode: replace, merge_dict, upsert_list_item, or remove_list_item.",
|
||||||
"operation_id": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
|
"operation_id": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
|
||||||
|
|||||||
@@ -3673,6 +3673,18 @@
|
|||||||
"description": "Human-readable workflow, provider, or execution error message.",
|
"description": "Human-readable workflow, provider, or execution error message.",
|
||||||
"title": "Error"
|
"title": "Error"
|
||||||
},
|
},
|
||||||
|
"next_run_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Next scheduled subscription search time. Null when no future execution is planned.",
|
||||||
|
"title": "Next Run At"
|
||||||
|
},
|
||||||
"phase": {
|
"phase": {
|
||||||
"description": "Current phase of a subscription execution.",
|
"description": "Current phase of a subscription execution.",
|
||||||
"title": "Phase",
|
"title": "Phase",
|
||||||
@@ -9607,6 +9619,12 @@
|
|||||||
"description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.",
|
"description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.",
|
||||||
"title": "Count"
|
"title": "Count"
|
||||||
},
|
},
|
||||||
|
"include_candidates": {
|
||||||
|
"default": false,
|
||||||
|
"description": "Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.",
|
||||||
|
"title": "Include Candidates",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"media_source": {
|
"media_source": {
|
||||||
"$ref": "#/$defs/MediaSource",
|
"$ref": "#/$defs/MediaSource",
|
||||||
"description": "Metadata source identifier. Preserve the exact value returned with media_id."
|
"description": "Metadata source identifier. Preserve the exact value returned with media_id."
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from app.chain.media import MediaChain
|
|||||||
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
||||||
from app.domain.media import is_music_media_source, normalize_music_type
|
from app.domain.media import is_music_media_source, normalize_music_type
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
|
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
|
||||||
from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData
|
from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData
|
||||||
@@ -162,11 +161,8 @@ def _resolve_add_media(
|
|||||||
)
|
)
|
||||||
if is_music and not normalized_music_type:
|
if is_music and not normalized_music_type:
|
||||||
normalized_music_type = MUSIC_ENTITY_RECORDING
|
normalized_music_type = MUSIC_ENTITY_RECORDING
|
||||||
metainfo = (
|
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description,
|
||||||
MetaMusic.parse_query(torrent_in.title)
|
mtype=MediaType.MUSIC if is_music else None)
|
||||||
if is_music
|
|
||||||
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
|
||||||
)
|
|
||||||
if media_source and media_id:
|
if media_source and media_id:
|
||||||
mediainfo = MediaChain().recognize_media(
|
mediainfo = MediaChain().recognize_media(
|
||||||
meta=metainfo,
|
meta=metainfo,
|
||||||
@@ -232,12 +228,10 @@ def download(
|
|||||||
"""
|
"""
|
||||||
if isinstance(media_in, _SchemaMusicInfo):
|
if isinstance(media_in, _SchemaMusicInfo):
|
||||||
mediainfo = MusicInfo.from_dict(media_in.model_dump())
|
mediainfo = MusicInfo.from_dict(media_in.model_dump())
|
||||||
metainfo = MetaMusic.from_music_info(mediainfo)
|
|
||||||
metainfo.org_string = torrent_in.title
|
|
||||||
else:
|
else:
|
||||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
|
||||||
mediainfo = MediaInfo()
|
mediainfo = MediaInfo()
|
||||||
mediainfo.from_dict(media_in.model_dump())
|
mediainfo.from_dict(media_in.model_dump())
|
||||||
|
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description, mtype=mediainfo.type)
|
||||||
# 种子信息
|
# 种子信息
|
||||||
torrentinfo = TorrentInfo()
|
torrentinfo = TorrentInfo()
|
||||||
torrentinfo.from_dict(torrent_in.model_dump())
|
torrentinfo.from_dict(torrent_in.model_dump())
|
||||||
|
|||||||
@@ -322,16 +322,12 @@ async def search(
|
|||||||
source_selection = selected_sources or None
|
source_selection = selected_sources or None
|
||||||
|
|
||||||
media_chain = MediaChain()
|
media_chain = MediaChain()
|
||||||
if type == "music" or any(is_music_media_source(source) for source in selected_sources):
|
is_music = type == "music" or any(is_music_media_source(source) for source in selected_sources)
|
||||||
# 音乐搜索统一入口,与影视搜索共用 /media/search
|
if type == "media" or is_music:
|
||||||
music_search_params = {"query": title, "limit": count}
|
_, medias = await media_chain.async_search(
|
||||||
# 未指定来源时由 MediaChain 使用默认 MusicBrainz 来源。
|
title=title, media_source=source_selection,
|
||||||
if source_selection:
|
**({"mtype": MediaType.MUSIC, "limit": count} if is_music else {}),
|
||||||
music_search_params["media_source"] = source_selection
|
)
|
||||||
music_infos = await media_chain.async_search_music(**music_search_params)
|
|
||||||
return [info.to_dict() for info in music_infos] if music_infos else []
|
|
||||||
if type == "media":
|
|
||||||
_, medias = await media_chain.async_search(title=title, media_source=source_selection)
|
|
||||||
result = [media.to_dict() for media in medias] if medias else []
|
result = [media.to_dict() for media in medias] if medias else []
|
||||||
elif type == "collection":
|
elif type == "collection":
|
||||||
collections = await media_chain.async_search_collections(name=title, media_source=source_selection)
|
collections = await media_chain.async_search_collections(name=title, media_source=source_selection)
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ async def search_by_id_stream(
|
|||||||
season: Optional[str] = None,
|
season: Optional[str] = None,
|
||||||
sites: Optional[str] = None,
|
sites: Optional[str] = None,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
_: _SchemaTokenPayload = Depends(verify_resource_token),
|
_: _SchemaTokenPayload = Depends(verify_resource_token),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -426,6 +427,8 @@ async def search_by_id_stream(
|
|||||||
if not search_params:
|
if not search_params:
|
||||||
yield {"type": "error", "success": False, "message": message}
|
yield {"type": "error", "success": False, "message": message}
|
||||||
return
|
return
|
||||||
|
if include_candidates:
|
||||||
|
search_params["include_candidates"] = True
|
||||||
torrents = SearchChain().async_search_by_id_stream(
|
torrents = SearchChain().async_search_by_id_stream(
|
||||||
**search_params,
|
**search_params,
|
||||||
mtype=media_type,
|
mtype=media_type,
|
||||||
@@ -457,6 +460,7 @@ async def search_by_id(
|
|||||||
season: Optional[str] = None,
|
season: Optional[str] = None,
|
||||||
sites: Optional[str] = None,
|
sites: Optional[str] = None,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
_: _SchemaTokenPayload = Depends(verify_token),
|
_: _SchemaTokenPayload = Depends(verify_token),
|
||||||
page: CompatiblePageParam = None,
|
page: CompatiblePageParam = None,
|
||||||
count: CompatibleCountParam = None,
|
count: CompatibleCountParam = None,
|
||||||
@@ -474,6 +478,8 @@ async def search_by_id(
|
|||||||
)
|
)
|
||||||
if not search_params:
|
if not search_params:
|
||||||
return _SchemaResponse(success=False, message=message)
|
return _SchemaResponse(success=False, message=message)
|
||||||
|
if include_candidates:
|
||||||
|
search_params["include_candidates"] = True
|
||||||
torrents = await SearchChain().async_search_by_id(
|
torrents = await SearchChain().async_search_by_id(
|
||||||
**search_params,
|
**search_params,
|
||||||
mtype=media_type,
|
mtype=media_type,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""多来源音乐目录搜索应用服务。"""
|
"""多来源音乐目录搜索应用服务。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from itertools import zip_longest
|
||||||
from typing import Any, Callable, Iterable, Optional
|
from typing import Any, Callable, Iterable, Optional
|
||||||
|
|
||||||
from app.domain.context import MusicInfo
|
from app.domain.context import MusicInfo
|
||||||
@@ -77,41 +78,45 @@ class MusicCatalogService:
|
|||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str | MetaMusic,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
media_source: Optional[MediaSourceSelection] = None,
|
media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> list[MusicInfo]:
|
) -> list[MusicInfo]:
|
||||||
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
|
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
|
||||||
meta = MetaMusic.parse_query(query)
|
meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query)
|
||||||
candidates = []
|
candidates = []
|
||||||
for source in self.search_sources(media_source):
|
for source in self.search_sources(media_source):
|
||||||
chain = self._source_resolver(source)
|
chain = self._source_resolver(source)
|
||||||
if not chain:
|
if not chain:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
candidates.extend(chain.search_music(meta, limit=limit))
|
candidates.append(chain.search_music(meta, limit=limit))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
|
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
|
||||||
return self.normalize_candidates(candidates, limit=limit)
|
return self.merge_sources(candidates, limit=limit)
|
||||||
|
|
||||||
async def async_search(
|
async def async_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str | MetaMusic,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
media_source: Optional[MediaSourceSelection] = None,
|
media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> list[MusicInfo]:
|
) -> list[MusicInfo]:
|
||||||
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
|
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
|
||||||
meta = MetaMusic.parse_query(query)
|
meta = query if isinstance(query, MetaMusic) else MetaMusic.parse_query(query)
|
||||||
searches = []
|
searches = []
|
||||||
for source in self.search_sources(media_source):
|
for source in self.search_sources(media_source):
|
||||||
chain = self._source_resolver(source)
|
chain = self._source_resolver(source)
|
||||||
if chain:
|
if chain:
|
||||||
searches.append(self._async_search_source(chain, source, meta, limit))
|
searches.append(self._async_search_source(chain, source, meta, limit))
|
||||||
source_results = await asyncio.gather(*searches) if searches else []
|
source_results = await asyncio.gather(*searches) if searches else []
|
||||||
return self.normalize_candidates(
|
return self.merge_sources(source_results, limit=limit)
|
||||||
[candidate for results in source_results for candidate in 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(
|
async def _async_search_source(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ def normalize_search_params(
|
|||||||
}
|
}
|
||||||
if params.get("music_type"):
|
if params.get("music_type"):
|
||||||
normalized["music_type"] = str(params["music_type"])
|
normalized["music_type"] = str(params["music_type"])
|
||||||
|
if str(params.get("include_candidates", "")).casefold() in ("true", "1"):
|
||||||
|
normalized["include_candidates"] = "true"
|
||||||
return normalized if normalized["keyword"] or media_id else None
|
return normalized if normalized["keyword"] or media_id else None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ class SearchStateService:
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
result_type: Optional[str] = "torrent",
|
result_type: Optional[str] = "torrent",
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> Optional[Dict[str, str]]:
|
) -> Optional[Dict[str, str]]:
|
||||||
"""把公开搜索参数构造成可持久化的兼容字典。"""
|
"""把公开搜索参数构造成可持久化的兼容字典。"""
|
||||||
return normalize_search_params(
|
return normalize_search_params(
|
||||||
@@ -99,6 +102,7 @@ class SearchStateService:
|
|||||||
"sites": stringify_sites(sites),
|
"sites": stringify_sites(sites),
|
||||||
"music_type": music_type,
|
"music_type": music_type,
|
||||||
"result_type": result_type or "torrent",
|
"result_type": result_type or "torrent",
|
||||||
|
"include_candidates": include_candidates,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ class TorrentHelper:
|
|||||||
_torrent = _context.torrent_info
|
_torrent = _context.torrent_info
|
||||||
_media = _context.media_info
|
_media = _context.media_info
|
||||||
# 标题
|
# 标题
|
||||||
_title = str(_media.title).ljust(200, ' ')
|
_title = str(_media.title if _media else _meta.name).ljust(200, ' ')
|
||||||
# 站点优先级
|
# 站点优先级
|
||||||
_site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0')
|
_site_order = str(999 - (_torrent.site_order or 0)).rjust(3, '0')
|
||||||
# 站点上传量
|
# 站点上传量
|
||||||
|
|||||||
+3
-50
@@ -406,9 +406,7 @@ class MusicSubscribeMixin:
|
|||||||
|
|
||||||
context = copy.copy(source_context)
|
context = copy.copy(source_context)
|
||||||
context.torrent_info = torrent
|
context.torrent_info = torrent
|
||||||
meta = MetaMusic.from_music_info(mediainfo)
|
meta = MetaMusic.parse_resource(torrent.title, torrent.description)
|
||||||
meta.org_string = torrent.title
|
|
||||||
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
|
|
||||||
if subscribe.best_version:
|
if subscribe.best_version:
|
||||||
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
|
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
|
||||||
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
|
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
|
||||||
@@ -513,53 +511,8 @@ class MusicSubscribeMixin:
|
|||||||
subscribe: SubscriptionSnapshot,
|
subscribe: SubscriptionSnapshot,
|
||||||
execution_context: Optional[SubscriptionExecutionContext] = None,
|
execution_context: Optional[SubscriptionExecutionContext] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
|
"""兼容音乐订阅入口,主动搜索、站点预算和结果处理统一由订阅搜索 owner 编排。"""
|
||||||
self._ensure_music_execution_active(execution_context)
|
self._process_search_subscription(subscribe, None, execution_context=execution_context)
|
||||||
target = self._prepare_music_subscribe(subscribe)
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
subscribe, mediainfo, _ = target
|
|
||||||
self._ensure_music_execution_active(execution_context)
|
|
||||||
|
|
||||||
sites = self.get_sub_sites(subscribe)
|
|
||||||
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
|
|
||||||
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
|
|
||||||
rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or []
|
|
||||||
keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo)
|
|
||||||
if not keywords:
|
|
||||||
keywords = [subscribe.name]
|
|
||||||
|
|
||||||
searchchain = SearchChain()
|
|
||||||
contexts: List[Context] = []
|
|
||||||
if execution_context:
|
|
||||||
execution_context.report_phase("searching")
|
|
||||||
for keyword in keywords:
|
|
||||||
self._ensure_music_execution_active(execution_context)
|
|
||||||
contexts = searchchain.search_by_title(
|
|
||||||
title=keyword,
|
|
||||||
sites=sites,
|
|
||||||
mtype=MediaType.MUSIC,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
)
|
|
||||||
self._ensure_music_execution_active(execution_context)
|
|
||||||
contexts = self._filter_music_subscribe_contexts(
|
|
||||||
subscribe=subscribe,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
contexts=contexts,
|
|
||||||
)
|
|
||||||
if contexts:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not contexts:
|
|
||||||
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._download_music_subscribe(
|
|
||||||
subscribe,
|
|
||||||
mediainfo,
|
|
||||||
contexts,
|
|
||||||
execution_context=execution_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _match_music_subscribe(
|
def _match_music_subscribe(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+1
-1
@@ -752,7 +752,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
|
|||||||
self,
|
self,
|
||||||
rule_groups: List[str],
|
rule_groups: List[str],
|
||||||
torrent_list: List[TorrentInfo],
|
torrent_list: List[TorrentInfo],
|
||||||
mediainfo: MediaInfo = None,
|
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
|
||||||
) -> List[TorrentInfo]:
|
) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
过滤种子资源
|
过滤种子资源
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ def _new_torrent_helper() -> TorrentHelper:
|
|||||||
return factory()
|
return factory()
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmed_batch_contexts(contexts: List[Context]) -> List[Context]:
|
||||||
|
"""排除待人工确认的资源,覆盖原始候选及插件替换候选两个入口。"""
|
||||||
|
confirmed = [context for context in contexts if getattr(context, "match_status", None) in (None, "exact")]
|
||||||
|
if len(confirmed) != len(contexts):
|
||||||
|
logger.warning(f"跳过 {len(contexts) - len(confirmed)} 个待人工确认的资源,不参与自动批量下载")
|
||||||
|
return confirmed
|
||||||
|
|
||||||
|
|
||||||
class DownloadSelectionOwner(_DownloadOwnerBase):
|
class DownloadSelectionOwner(_DownloadOwnerBase):
|
||||||
"""下载候选规范化、排序和媒体选择 owner。"""
|
"""下载候选规范化、排序和媒体选择 owner。"""
|
||||||
|
|
||||||
@@ -120,6 +128,9 @@ class DownloadSelectionOwner(_DownloadOwnerBase):
|
|||||||
|
|
||||||
:return: 排序后的上下文和本轮失败冷却记录;资源选择事件仍可替换上下文列表
|
:return: 排序后的上下文和本轮失败冷却记录;资源选择事件仍可替换上下文列表
|
||||||
"""
|
"""
|
||||||
|
contexts = _confirmed_batch_contexts(contexts)
|
||||||
|
if not contexts:
|
||||||
|
return [], {}
|
||||||
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
||||||
event_data = ResourceSelectionEventData(
|
event_data = ResourceSelectionEventData(
|
||||||
contexts=contexts,
|
contexts=contexts,
|
||||||
@@ -135,7 +146,7 @@ class DownloadSelectionOwner(_DownloadOwnerBase):
|
|||||||
f"items (source: {event_data.source})"
|
f"items (source: {event_data.source})"
|
||||||
)
|
)
|
||||||
contexts = event_data.updated_contexts
|
contexts = event_data.updated_contexts
|
||||||
contexts = _new_torrent_helper().sort_torrents(contexts)
|
contexts = _new_torrent_helper().sort_torrents(_confirmed_batch_contexts(contexts))
|
||||||
active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = {
|
active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = {
|
||||||
fingerprint: failure
|
fingerprint: failure
|
||||||
for fingerprint, failure in self._active_download_failure_fingerprints(
|
for fingerprint, failure in self._active_download_failure_fingerprints(
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ from app.foundation.text import convert as zhconv_convert
|
|||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
|
MUSIC_ENTITY_ALBUM,
|
||||||
MediaSource,
|
MediaSource,
|
||||||
MediaSourceSelection,
|
MediaSourceSelection,
|
||||||
|
MediaType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +102,8 @@ class MediaCatalogOwner(_MediaOwnerBase):
|
|||||||
media_source: Optional[MediaSourceSelection] = None,
|
media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> list[MusicInfo]:
|
) -> list[MusicInfo]:
|
||||||
"""按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。"""
|
"""按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。"""
|
||||||
return self._music_catalog().search(query, limit, media_source)
|
_, candidates = self.search(title=query, media_source=media_source, mtype=MediaType.MUSIC, limit=limit)
|
||||||
|
return cast(list[MusicInfo], candidates)
|
||||||
|
|
||||||
async def async_search_music(
|
async def async_search_music(
|
||||||
self,
|
self,
|
||||||
@@ -109,11 +112,8 @@ class MediaCatalogOwner(_MediaOwnerBase):
|
|||||||
media_source: Optional[MediaSourceSelection] = None,
|
media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> list[MusicInfo]:
|
) -> list[MusicInfo]:
|
||||||
"""并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。"""
|
"""并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。"""
|
||||||
return await self._music_catalog().async_search(
|
_, candidates = await self.async_search(title=query, media_source=media_source, mtype=MediaType.MUSIC, limit=limit)
|
||||||
query,
|
return cast(list[MusicInfo], candidates)
|
||||||
limit,
|
|
||||||
media_source,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_music_result(
|
def _validate_music_result(
|
||||||
@@ -160,6 +160,22 @@ class MediaCatalogOwner(_MediaOwnerBase):
|
|||||||
if not updates:
|
if not updates:
|
||||||
return info
|
return info
|
||||||
simplified = deepcopy(info)
|
simplified = deepcopy(info)
|
||||||
|
for field_name, alias_field in (
|
||||||
|
("title", "title_aliases"),
|
||||||
|
("album", "album_aliases"),
|
||||||
|
("artists", "artist_aliases"),
|
||||||
|
("album_artist", "artist_aliases"),
|
||||||
|
("names", "title_aliases"),
|
||||||
|
):
|
||||||
|
if field_name not in updates:
|
||||||
|
continue
|
||||||
|
if field_name in ("album_artist", "names") and info.music_type != MUSIC_ENTITY_ALBUM:
|
||||||
|
continue
|
||||||
|
original = getattr(info, field_name)
|
||||||
|
originals = original if isinstance(original, list) else [original]
|
||||||
|
setattr(simplified, alias_field, list(dict.fromkeys([
|
||||||
|
*(getattr(simplified, alias_field, None) or []), *originals,
|
||||||
|
])))
|
||||||
for field_name, value in updates.items():
|
for field_name, value in updates.items():
|
||||||
setattr(simplified, field_name, value)
|
setattr(simplified, field_name, value)
|
||||||
return simplified
|
return simplified
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class _ProjectionEventManagerPort(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.application.music.catalog import MusicCatalogService
|
||||||
|
|
||||||
class _MediaOwnerBase:
|
class _MediaOwnerBase:
|
||||||
"""声明各媒体 owner 组合后可依赖的精确静态合同。"""
|
"""声明各媒体 owner 组合后可依赖的精确静态合同。"""
|
||||||
@@ -268,6 +269,20 @@ if TYPE_CHECKING:
|
|||||||
"""同步搜索媒体候选。"""
|
"""同步搜索媒体候选。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def _music_catalog(self) -> MusicCatalogService:
|
||||||
|
"""提供音乐来源 ABI 的目录适配器。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def search(self, title: str, media_source: Optional[MediaSourceSelection] = None,
|
||||||
|
mtype: Optional[MediaType] = None, limit: int = 20) -> tuple[Optional[MetaBase], list[MediaInfo] | list[MusicInfo]]:
|
||||||
|
"""通过共用入口搜索所有媒体类型。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def async_search(self, title: str, media_source: Optional[MediaSourceSelection] = None,
|
||||||
|
mtype: Optional[MediaType] = None, limit: int = 20) -> tuple[Optional[MetaBase], list[MediaInfo] | list[MusicInfo]]:
|
||||||
|
"""通过共用入口异步搜索所有媒体类型。"""
|
||||||
|
...
|
||||||
|
|
||||||
async def async_search_medias(
|
async def async_search_medias(
|
||||||
self,
|
self,
|
||||||
meta: MetaBase,
|
meta: MetaBase,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class MediaRecognitionOwner(_MediaOwnerBase):
|
|||||||
"media_source": self._music_primary_source,
|
"media_source": self._music_primary_source,
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
"cache": cache,
|
"cache": cache,
|
||||||
"music_type": MUSIC_ENTITY_RECORDING,
|
"music_type": module_kwargs.get("music_type") or MUSIC_ENTITY_RECORDING,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return _NativeRecognitionPlan(
|
return _NativeRecognitionPlan(
|
||||||
@@ -182,7 +182,7 @@ class MediaRecognitionOwner(_MediaOwnerBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_remote_identity(result: Optional[MediaInfo]) -> bool:
|
def _has_remote_identity(result: Optional[MediaInfo]) -> bool:
|
||||||
"""音乐识别仅在取得远端来源身份后视为完整命中。"""
|
"""音乐识别仅在取得远端来源身份后视为完整命中。"""
|
||||||
return bool(result and result.media_source)
|
return bool(result and result.media_source and result.media_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _accepted_recognition(
|
def _accepted_recognition(
|
||||||
|
|||||||
+22
-17
@@ -7,14 +7,19 @@ from app.chain.media.contract import _MediaOwnerBase
|
|||||||
from app.domain import title as title_rules
|
from app.domain import title as title_rules
|
||||||
from app.domain.context import (
|
from app.domain.context import (
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
|
MusicInfo,
|
||||||
)
|
)
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
MediaSourceSelection,
|
MediaSourceSelection,
|
||||||
|
MediaType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CatalogResults = List[MediaInfo] | List[MusicInfo]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _MediaSearchRequest:
|
class _MediaSearchRequest:
|
||||||
@@ -24,8 +29,10 @@ class _MediaSearchRequest:
|
|||||||
meta: MetaBase
|
meta: MetaBase
|
||||||
|
|
||||||
|
|
||||||
def _build_media_search_request(title: str) -> _MediaSearchRequest:
|
def _build_media_search_request(title: str, mtype: Optional[MediaType] = None) -> _MediaSearchRequest:
|
||||||
"""将搜索文本一次性投影为规范元数据,避免双入口规则漂移。"""
|
"""将搜索文本一次性投影为规范元数据,避免双入口规则漂移。"""
|
||||||
|
if mtype == MediaType.MUSIC:
|
||||||
|
return _MediaSearchRequest(content=title, meta=MetaInfo(title=title, mtype=mtype))
|
||||||
mtype, _, season_num, episode_num, year, content = title_rules.parse_search_keyword(title)
|
mtype, _, season_num, episode_num, year, content = title_rules.parse_search_keyword(title)
|
||||||
content = content or title
|
content = content or title
|
||||||
meta = MetaInfo(content)
|
meta = MetaInfo(content)
|
||||||
@@ -44,8 +51,8 @@ def _build_media_search_request(title: str) -> _MediaSearchRequest:
|
|||||||
|
|
||||||
def _finish_media_search(
|
def _finish_media_search(
|
||||||
request: _MediaSearchRequest,
|
request: _MediaSearchRequest,
|
||||||
medias: Optional[List[MediaInfo]],
|
medias: Optional[CatalogResults],
|
||||||
) -> Tuple[MetaBase, List[MediaInfo]]:
|
) -> Tuple[MetaBase, CatalogResults]:
|
||||||
"""统一空结果与成功结果投影,并保持既有日志语义。"""
|
"""统一空结果与成功结果投影,并保持既有日志语义。"""
|
||||||
if not medias:
|
if not medias:
|
||||||
logger.warn(f"{request.meta.name} 没有找到对应的媒体信息!")
|
logger.warn(f"{request.meta.name} 没有找到对应的媒体信息!")
|
||||||
@@ -58,8 +65,9 @@ class MediaSearchOwner(_MediaOwnerBase):
|
|||||||
"""媒体搜索入口 owner。"""
|
"""媒体搜索入口 owner。"""
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self, title: str, media_source: Optional[MediaSourceSelection] = None
|
self, title: str, media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
mtype: Optional[MediaType] = None, limit: int = 20,
|
||||||
|
) -> Tuple[Optional[MetaBase], CatalogResults]:
|
||||||
"""
|
"""
|
||||||
搜索媒体/人物信息
|
搜索媒体/人物信息
|
||||||
|
|
||||||
@@ -67,17 +75,16 @@ class MediaSearchOwner(_MediaOwnerBase):
|
|||||||
:param media_source: 请求级搜索数据源
|
:param media_source: 请求级搜索数据源
|
||||||
:return: 识别元数据,媒体信息列表
|
:return: 识别元数据,媒体信息列表
|
||||||
"""
|
"""
|
||||||
request = _build_media_search_request(title)
|
request = _build_media_search_request(title, mtype)
|
||||||
logger.info(f"开始搜索媒体信息:{request.meta.name}")
|
logger.info(f"开始搜索媒体信息:{request.meta.name}")
|
||||||
medias: Optional[List[MediaInfo]] = self.search_medias(
|
medias = self._music_catalog().search(request.meta, limit=limit, media_source=media_source) \
|
||||||
meta=request.meta,
|
if isinstance(request.meta, MetaMusic) else self.search_medias(meta=request.meta, media_source=media_source)
|
||||||
media_source=media_source,
|
|
||||||
)
|
|
||||||
return _finish_media_search(request, medias)
|
return _finish_media_search(request, medias)
|
||||||
|
|
||||||
async def async_search(
|
async def async_search(
|
||||||
self, title: str, media_source: Optional[MediaSourceSelection] = None
|
self, title: str, media_source: Optional[MediaSourceSelection] = None,
|
||||||
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
mtype: Optional[MediaType] = None, limit: int = 20,
|
||||||
|
) -> Tuple[Optional[MetaBase], CatalogResults]:
|
||||||
"""
|
"""
|
||||||
搜索媒体/人物信息(异步版本)
|
搜索媒体/人物信息(异步版本)
|
||||||
|
|
||||||
@@ -85,10 +92,8 @@ class MediaSearchOwner(_MediaOwnerBase):
|
|||||||
:param media_source: 请求级搜索数据源
|
:param media_source: 请求级搜索数据源
|
||||||
:return: 识别元数据,媒体信息列表
|
:return: 识别元数据,媒体信息列表
|
||||||
"""
|
"""
|
||||||
request = _build_media_search_request(title)
|
request = _build_media_search_request(title, mtype)
|
||||||
logger.info(f"开始搜索媒体信息:{request.meta.name}")
|
logger.info(f"开始搜索媒体信息:{request.meta.name}")
|
||||||
medias: Optional[List[MediaInfo]] = await self.async_search_medias(
|
medias = await self._music_catalog().async_search(request.meta, limit=limit, media_source=media_source) \
|
||||||
meta=request.meta,
|
if isinstance(request.meta, MetaMusic) else await self.async_search_medias(meta=request.meta, media_source=media_source)
|
||||||
media_source=media_source,
|
|
||||||
)
|
|
||||||
return _finish_media_search(request, medias)
|
return _finish_media_search(request, medias)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""搜索 Chain 的惰性稳定公开入口。"""
|
"""搜索 Chain 的惰性稳定公开入口。"""
|
||||||
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.chain.search.facade import SearchChain
|
||||||
|
|
||||||
_EXPORTS = {
|
_EXPORTS = {
|
||||||
"SearchChain": ("app.chain.search.facade", "SearchChain"),
|
"SearchChain": ("app.chain.search.facade", "SearchChain"),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class SearchCacheOwner(_SearchOwnerBase):
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
result_type: Optional[str] = "torrent",
|
result_type: Optional[str] = "torrent",
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。
|
保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。
|
||||||
@@ -77,6 +78,7 @@ class SearchCacheOwner(_SearchOwnerBase):
|
|||||||
sites=sites,
|
sites=sites,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
result_type=result_type,
|
result_type=result_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_save_last_search_params(
|
async def async_save_last_search_params(
|
||||||
@@ -94,6 +96,7 @@ class SearchCacheOwner(_SearchOwnerBase):
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
result_type: Optional[str] = "torrent",
|
result_type: Optional[str] = "torrent",
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
异步保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。
|
异步保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。
|
||||||
@@ -111,6 +114,7 @@ class SearchCacheOwner(_SearchOwnerBase):
|
|||||||
sites=sites,
|
sites=sites,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
result_type=result_type,
|
result_type=result_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
def last_search_params(self) -> Optional[Dict[str, str]]:
|
def last_search_params(self) -> Optional[Dict[str, str]]:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""SearchChain owner 的静态组合合同。"""
|
"""SearchChain owner 的静态组合合同。"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
from app.chain.base import ChainBase
|
from app.chain.base import ChainBase
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.domain.context import Context
|
||||||
|
|
||||||
class _SearchOwnerBase(ChainBase):
|
class _SearchOwnerBase(ChainBase):
|
||||||
"""向类型检查器声明稳定 Facade 上的跨 owner 能力。"""
|
"""向类型检查器声明稳定 Facade 上的跨 owner 能力。"""
|
||||||
@@ -53,7 +54,7 @@ if TYPE_CHECKING:
|
|||||||
_normalize_ai_indices: Callable[..., Any]
|
_normalize_ai_indices: Callable[..., Any]
|
||||||
_normalize_music_match_text: Callable[..., Any]
|
_normalize_music_match_text: Callable[..., Any]
|
||||||
_normalize_search_params: Callable[..., Any]
|
_normalize_search_params: Callable[..., Any]
|
||||||
_parse_result: Callable[..., Any]
|
_parse_result: Callable[..., list[Context]]
|
||||||
_parse_subtitle_result: Callable[..., Any]
|
_parse_subtitle_result: Callable[..., Any]
|
||||||
_prepare_params: Callable[..., Any]
|
_prepare_params: Callable[..., Any]
|
||||||
_process_music: Callable[..., Any]
|
_process_music: Callable[..., Any]
|
||||||
@@ -77,7 +78,7 @@ if TYPE_CHECKING:
|
|||||||
async_last_search_params: Callable[..., Any]
|
async_last_search_params: Callable[..., Any]
|
||||||
async_last_search_results: Callable[..., Any]
|
async_last_search_results: Callable[..., Any]
|
||||||
async_last_subtitle_search_results: Callable[..., Any]
|
async_last_subtitle_search_results: Callable[..., Any]
|
||||||
async_process: Callable[..., Any]
|
async_process: Callable[..., Awaitable[list[Context]]]
|
||||||
async_process_stream: Callable[..., Any]
|
async_process_stream: Callable[..., Any]
|
||||||
async_save_last_search_params: Callable[..., Any]
|
async_save_last_search_params: Callable[..., Any]
|
||||||
async_search_by_id: Callable[..., Any]
|
async_search_by_id: Callable[..., Any]
|
||||||
@@ -96,7 +97,7 @@ if TYPE_CHECKING:
|
|||||||
last_search_results: Callable[..., Any]
|
last_search_results: Callable[..., Any]
|
||||||
matches_music_resource: Callable[..., Any]
|
matches_music_resource: Callable[..., Any]
|
||||||
music_site_keywords: Callable[..., Any]
|
music_site_keywords: Callable[..., Any]
|
||||||
process: Callable[..., Any]
|
process: Callable[..., list[Context]]
|
||||||
record_subscription_site_budget_failure: Callable[..., Any]
|
record_subscription_site_budget_failure: Callable[..., Any]
|
||||||
consume_subscription_site_budget_failures: Callable[..., Any]
|
consume_subscription_site_budget_failures: Callable[..., Any]
|
||||||
record_subscription_site_budget_deferred: Callable[..., Any]
|
record_subscription_site_budget_deferred: Callable[..., Any]
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""所有媒体类型共用的资源搜索状态机与同步、异步 I/O 驱动。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, AsyncIterator, Callable, Dict, Generator, Iterator, List, Literal, Optional, cast
|
||||||
|
|
||||||
|
from app.chain.media import MediaChain
|
||||||
|
from app.chain.search.contract import _SearchOwnerBase
|
||||||
|
from app.chain.search.plan import SearchPlanOwner
|
||||||
|
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||||
|
from app.domain.metainfo import MetaInfo
|
||||||
|
from app.runtime.execution import run_in_threadpool
|
||||||
|
from app.runtime.log import logger
|
||||||
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MediaSearchPlan:
|
||||||
|
"""冻结搜索业务输入,媒体类型只在关键词和匹配策略中产生差异。"""
|
||||||
|
|
||||||
|
mediainfo: MediaInfo | MusicInfo
|
||||||
|
keyword: Optional[str] = None
|
||||||
|
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None
|
||||||
|
sites: Optional[List[int]] = None
|
||||||
|
rule_groups: Optional[List[str]] = None
|
||||||
|
area: Optional[str] = "title"
|
||||||
|
custom_words: Optional[List[str]] = None
|
||||||
|
filter_params: Optional[Dict[str, str]] = None
|
||||||
|
include_candidates: bool = False
|
||||||
|
candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SearchStep:
|
||||||
|
"""请求驱动器执行一次 I/O 或 CPU 操作,不在外壳重复媒体业务决策。"""
|
||||||
|
|
||||||
|
kind: Literal["recognize", "supplement", "search", "parse"]
|
||||||
|
params: Dict[str, Any]
|
||||||
|
search_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SearchOutcome:
|
||||||
|
"""记录最终结果、原始候选数量及各阶段诊断。"""
|
||||||
|
|
||||||
|
contexts: List[Context]
|
||||||
|
candidate_count: int = 0
|
||||||
|
counts: Dict[str, int] = field(default_factory=dict)
|
||||||
|
recognition_failed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _result_params(plan: MediaSearchPlan, mediainfo: MediaInfo | MusicInfo,
|
||||||
|
torrents: List[TorrentInfo], season_episodes: Any, counts: Counter[str]) -> Dict[str, Any]:
|
||||||
|
"""为每次完整过滤建立统一参数,计数只描述当前累计候选而不重复叠加。"""
|
||||||
|
counts.clear()
|
||||||
|
return {
|
||||||
|
"torrents": torrents, "mediainfo": mediainfo, "keyword": plan.keyword,
|
||||||
|
"rule_groups": plan.rule_groups, "season_episodes": season_episodes,
|
||||||
|
"custom_words": plan.custom_words, "filter_params": plan.filter_params,
|
||||||
|
"include_candidates": plan.include_candidates, "diagnostics": counts,
|
||||||
|
"candidate_filter": plan.candidate_filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _search_resolution(owner: _SearchOwnerBase, plan: MediaSearchPlan) -> Generator[_SearchStep, Any, _SearchOutcome]:
|
||||||
|
"""统一准备、换词、最终过滤和提前停止,音乐没有独立的搜索循环。"""
|
||||||
|
mediainfo = SearchPlanOwner._prepare_media_input(owner._copy_media_input(plan.mediainfo))
|
||||||
|
logger.info(f"开始搜索资源,关键词:{plan.keyword or mediainfo.title} ...")
|
||||||
|
if SearchPlanOwner._needs_media_details(mediainfo):
|
||||||
|
mediainfo = yield _SearchStep("recognize", {
|
||||||
|
"mtype": mediainfo.type, **owner._media_recognize_kwargs(mediainfo),
|
||||||
|
})
|
||||||
|
if not mediainfo:
|
||||||
|
return _SearchOutcome([], recognition_failed=True)
|
||||||
|
mediainfo = (yield _SearchStep("supplement", {"mediainfo": mediainfo})) or mediainfo
|
||||||
|
prepare_params = {
|
||||||
|
"mediainfo": mediainfo, "keyword": plan.keyword, "no_exists": plan.no_exists,
|
||||||
|
}
|
||||||
|
if plan.include_candidates:
|
||||||
|
prepare_params["include_candidates"] = True
|
||||||
|
season_episodes, keywords = owner._prepare_params(**prepare_params)
|
||||||
|
torrents: List[TorrentInfo] = []
|
||||||
|
contexts: List[Context] = []
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
parsed = False
|
||||||
|
for index, keyword in enumerate(keywords):
|
||||||
|
batch = yield _SearchStep("search", {
|
||||||
|
"mediainfo": mediainfo, "keyword": keyword, "sites": plan.sites, "area": plan.area,
|
||||||
|
}, search_count=index)
|
||||||
|
if not batch:
|
||||||
|
continue
|
||||||
|
torrents.extend(batch)
|
||||||
|
contexts = yield _SearchStep("parse", _result_params(plan, mediainfo, torrents, season_episodes, counts))
|
||||||
|
parsed = True
|
||||||
|
confirmed = any(getattr(context, "match_status", None) in (None, "exact") for context in contexts)
|
||||||
|
if confirmed and not owner.runtime_config.search_multiple_name:
|
||||||
|
logger.info(f"共搜索到 {len(contexts)} 个可用资源,停止搜索")
|
||||||
|
break
|
||||||
|
if not parsed:
|
||||||
|
contexts = yield _SearchStep("parse", _result_params(plan, mediainfo, torrents, season_episodes, counts))
|
||||||
|
return _SearchOutcome(contexts, candidate_count=len(torrents), counts=dict(counts))
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_contexts(mediainfo: MediaInfo | MusicInfo, torrents: List[TorrentInfo]) -> List[Context]:
|
||||||
|
"""原始预览仅展示资源自身解析信息,不提前绑定未经匹配的目标媒体。"""
|
||||||
|
return [Context(
|
||||||
|
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mediainfo.type),
|
||||||
|
torrent_info=torrent, resource_source="search", media_info_is_target=False,
|
||||||
|
match_status="candidate", match_reason="unverified",
|
||||||
|
) for torrent in torrents]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchExecutionOwner:
|
||||||
|
"""通过相同的业务状态机驱动三种 I/O 模式,不按媒体类型重复实现。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run(owner: _SearchOwnerBase, plan: MediaSearchPlan, media_chain: MediaChain) -> List[Context]:
|
||||||
|
"""同步驱动统一状态机,网络请求和结果解析均在当前同步调用中执行。"""
|
||||||
|
flow = _search_resolution(owner, plan)
|
||||||
|
response: Any = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
step = flow.send(response)
|
||||||
|
except StopIteration as completed:
|
||||||
|
outcome = cast(_SearchOutcome, completed.value)
|
||||||
|
SearchExecutionOwner._log_outcome(outcome)
|
||||||
|
return outcome.contexts
|
||||||
|
if step.kind == "recognize":
|
||||||
|
response = media_chain.recognize_media(**step.params)
|
||||||
|
elif step.kind == "supplement":
|
||||||
|
response = media_chain.supplement_media_info(**step.params)
|
||||||
|
elif step.kind == "search":
|
||||||
|
if step.search_count:
|
||||||
|
time.sleep(random.randint(1, 10))
|
||||||
|
response = owner._SearchChain__search_all_sites(**step.params) or []
|
||||||
|
else:
|
||||||
|
response = owner._parse_result(**step.params)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_outcome(outcome: _SearchOutcome) -> None:
|
||||||
|
"""以一致口径报告搜索失败或原始召回与最终保留数量。"""
|
||||||
|
if outcome.recognition_failed:
|
||||||
|
logger.error("媒体信息识别失败!")
|
||||||
|
else:
|
||||||
|
logger.info(f"搜索返回 {outcome.candidate_count} 个候选,保留 {len(outcome.contexts)} 个,过滤匹配统计:{outcome.counts}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _provider_events(owner: _SearchOwnerBase, step: _SearchStep,
|
||||||
|
streaming: bool) -> AsyncIterator[Dict[str, Any]]:
|
||||||
|
"""只在 I/O 适配层区分普通请求和站点事件流。"""
|
||||||
|
if step.search_count:
|
||||||
|
await asyncio.sleep(random.randint(1, 10))
|
||||||
|
if streaming:
|
||||||
|
async for event in owner._SearchChain__async_search_all_sites_stream(**step.params):
|
||||||
|
yield event
|
||||||
|
else:
|
||||||
|
yield {"items": await owner._SearchChain__async_search_all_sites(**step.params) or []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def events(owner: _SearchOwnerBase, plan: MediaSearchPlan, media_chain: MediaChain,
|
||||||
|
streaming: bool = True) -> AsyncIterator[Dict[str, Any]]:
|
||||||
|
"""异步和 SSE 复用同一驱动器,只有是否向调用方发送中间进度不同。"""
|
||||||
|
flow = _search_resolution(owner, plan)
|
||||||
|
response: Any = None
|
||||||
|
raw_count = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
step = flow.send(response)
|
||||||
|
except StopIteration as completed:
|
||||||
|
for event in SearchExecutionOwner._completion_events(cast(_SearchOutcome, completed.value), streaming):
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
if step.kind == "recognize":
|
||||||
|
response = await media_chain.async_recognize_media(**step.params)
|
||||||
|
elif step.kind == "supplement":
|
||||||
|
response = await media_chain.async_supplement_media_info(**step.params)
|
||||||
|
elif step.kind == "search":
|
||||||
|
batch: List[TorrentInfo] = []
|
||||||
|
async for event in SearchExecutionOwner._provider_events(owner, step, streaming):
|
||||||
|
items = event.pop("items", []) or []
|
||||||
|
batch.extend(items)
|
||||||
|
raw_count += len(items)
|
||||||
|
if streaming:
|
||||||
|
previews = await run_in_threadpool(_candidate_contexts, step.params["mediainfo"], items)
|
||||||
|
yield {
|
||||||
|
**event, "type": "append", "stage": "searching",
|
||||||
|
"items": [context.to_dict() for context in previews],
|
||||||
|
"total_items": raw_count, "candidate_items": raw_count,
|
||||||
|
}
|
||||||
|
response = batch
|
||||||
|
else:
|
||||||
|
if streaming:
|
||||||
|
yield {
|
||||||
|
"type": "progress", "stage": "filtering", "value": 98,
|
||||||
|
"text": f"正在过滤匹配 {len(step.params['torrents'])} 个候选资源 ...",
|
||||||
|
}
|
||||||
|
response = await run_in_threadpool(owner._parse_result, **step.params)
|
||||||
|
@staticmethod
|
||||||
|
def _completion_events(outcome: _SearchOutcome, streaming: bool) -> Iterator[Dict[str, Any]]:
|
||||||
|
"""只在状态机正常完成后统一发布结果,失败和提前关闭不会伪造完成状态。"""
|
||||||
|
SearchExecutionOwner._log_outcome(outcome)
|
||||||
|
if outcome.recognition_failed:
|
||||||
|
yield {"type": "error", "success": False, "message": "媒体信息识别失败"}
|
||||||
|
return
|
||||||
|
summary = {
|
||||||
|
"items": [context.to_dict() for context in outcome.contexts],
|
||||||
|
"total_items": len(outcome.contexts), "candidate_items": outcome.candidate_count,
|
||||||
|
"match_counts": outcome.counts,
|
||||||
|
}
|
||||||
|
if streaming:
|
||||||
|
yield {
|
||||||
|
**summary, "type": "replace", "stage": "filtered", "value": 100,
|
||||||
|
"text": f"过滤匹配完成,共 {len(outcome.contexts)} 个资源",
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
**summary, "type": "done", "stage": "done", "contexts": outcome.contexts,
|
||||||
|
"text": f"搜索完成,共 {len(outcome.contexts)} 个资源",
|
||||||
|
}
|
||||||
+22
-27
@@ -20,7 +20,7 @@ from app.chain.search.result import SearchResultOwner
|
|||||||
from app.chain.search.site import SearchSiteOwner
|
from app.chain.search.site import SearchSiteOwner
|
||||||
from app.chain.search.subtitle import SearchSubtitleOwner
|
from app.chain.search.subtitle import SearchSubtitleOwner
|
||||||
from app.chain.search.title import SearchTitleOwner
|
from app.chain.search.title import SearchTitleOwner
|
||||||
from app.domain.context import Context, MediaInfo, SubtitleInfo
|
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo
|
||||||
from app.runtime.events import Event, eventmanager
|
from app.runtime.events import Event, eventmanager
|
||||||
from app.schemas.mediaserver import NotExistMediaInfo
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
from app.schemas.types import EventType, MediaSource, MediaType
|
from app.schemas.types import EventType, MediaSource, MediaType
|
||||||
@@ -184,29 +184,8 @@ class SearchChain(ChainBase):
|
|||||||
results=results,
|
results=results,
|
||||||
)
|
)
|
||||||
|
|
||||||
def search_by_id(
|
# 复用 owner 的方法描述符和完整运行时签名,避免重复维护身份搜索参数。
|
||||||
self,
|
search_by_id = cast(Callable[..., list[Context]], SearchMediaOwner.search_by_id)
|
||||||
media_source: MediaSource,
|
|
||||||
media_id: str,
|
|
||||||
mtype: Optional[MediaType] = None,
|
|
||||||
area: Optional[str] = "title",
|
|
||||||
season: Optional[int] = None,
|
|
||||||
sites: Optional[list[int]] = None,
|
|
||||||
cache_local: bool = False,
|
|
||||||
music_type: Optional[str] = None,
|
|
||||||
) -> list[Context]:
|
|
||||||
"""通过稳定 Facade 执行同步精确媒体搜索。"""
|
|
||||||
return SearchMediaOwner.search_by_id(
|
|
||||||
cast(SearchMediaOwner, self),
|
|
||||||
media_source=media_source,
|
|
||||||
media_id=media_id,
|
|
||||||
mtype=mtype,
|
|
||||||
area=area,
|
|
||||||
season=season,
|
|
||||||
sites=sites,
|
|
||||||
cache_local=cache_local,
|
|
||||||
music_type=music_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
def search_by_title(
|
def search_by_title(
|
||||||
self,
|
self,
|
||||||
@@ -334,6 +313,7 @@ class SearchChain(ChainBase):
|
|||||||
sites: Optional[list[int]] = None,
|
sites: Optional[list[int]] = None,
|
||||||
cache_local: bool = False,
|
cache_local: bool = False,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> list[Context]:
|
) -> list[Context]:
|
||||||
"""通过稳定 Facade 执行异步精确媒体搜索。"""
|
"""通过稳定 Facade 执行异步精确媒体搜索。"""
|
||||||
return await SearchMediaOwner.async_search_by_id(
|
return await SearchMediaOwner.async_search_by_id(
|
||||||
@@ -346,6 +326,7 @@ class SearchChain(ChainBase):
|
|||||||
sites=sites,
|
sites=sites,
|
||||||
cache_local=cache_local,
|
cache_local=cache_local,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_search_by_title(
|
async def async_search_by_title(
|
||||||
@@ -400,6 +381,7 @@ class SearchChain(ChainBase):
|
|||||||
sites: Optional[list[int]] = None,
|
sites: Optional[list[int]] = None,
|
||||||
cache_local: bool = False,
|
cache_local: bool = False,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
"""通过稳定 Facade 流式执行精确媒体搜索。"""
|
"""通过稳定 Facade 流式执行精确媒体搜索。"""
|
||||||
async for event in SearchMediaOwner.async_search_by_id_stream(
|
async for event in SearchMediaOwner.async_search_by_id_stream(
|
||||||
@@ -412,6 +394,7 @@ class SearchChain(ChainBase):
|
|||||||
sites=sites,
|
sites=sites,
|
||||||
cache_local=cache_local,
|
cache_local=cache_local,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
):
|
):
|
||||||
yield event
|
yield event
|
||||||
_prepare_params = staticmethod(SearchPlanOwner._prepare_params)
|
_prepare_params = staticmethod(SearchPlanOwner._prepare_params)
|
||||||
@@ -426,7 +409,7 @@ class SearchChain(ChainBase):
|
|||||||
|
|
||||||
def process(
|
def process(
|
||||||
self,
|
self,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[list[int]] = None,
|
sites: Optional[list[int]] = None,
|
||||||
@@ -434,6 +417,8 @@ class SearchChain(ChainBase):
|
|||||||
area: Optional[str] = "title",
|
area: Optional[str] = "title",
|
||||||
custom_words: Optional[list[str]] = None,
|
custom_words: Optional[list[str]] = None,
|
||||||
filter_params: Optional[dict[str, str]] = None,
|
filter_params: Optional[dict[str, str]] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
|
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
|
||||||
) -> list[Context]:
|
) -> list[Context]:
|
||||||
"""通过稳定 Facade 调用精确媒体搜索 owner,保留公开类型合同。"""
|
"""通过稳定 Facade 调用精确媒体搜索 owner,保留公开类型合同。"""
|
||||||
return SearchMediaOwner.process(
|
return SearchMediaOwner.process(
|
||||||
@@ -446,11 +431,13 @@ class SearchChain(ChainBase):
|
|||||||
area=area,
|
area=area,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
filter_params=filter_params,
|
filter_params=filter_params,
|
||||||
|
include_candidates=include_candidates,
|
||||||
|
candidate_filter=candidate_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_process(
|
async def async_process(
|
||||||
self,
|
self,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[list[int]] = None,
|
sites: Optional[list[int]] = None,
|
||||||
@@ -458,6 +445,8 @@ class SearchChain(ChainBase):
|
|||||||
area: Optional[str] = "title",
|
area: Optional[str] = "title",
|
||||||
custom_words: Optional[list[str]] = None,
|
custom_words: Optional[list[str]] = None,
|
||||||
filter_params: Optional[dict[str, str]] = None,
|
filter_params: Optional[dict[str, str]] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
|
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
|
||||||
) -> list[Context]:
|
) -> list[Context]:
|
||||||
"""通过稳定 Facade 执行异步媒体搜索编排。"""
|
"""通过稳定 Facade 执行异步媒体搜索编排。"""
|
||||||
return await SearchMediaOwner.async_process(
|
return await SearchMediaOwner.async_process(
|
||||||
@@ -470,11 +459,13 @@ class SearchChain(ChainBase):
|
|||||||
area=area,
|
area=area,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
filter_params=filter_params,
|
filter_params=filter_params,
|
||||||
|
include_candidates=include_candidates,
|
||||||
|
candidate_filter=candidate_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_process_stream(
|
async def async_process_stream(
|
||||||
self,
|
self,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[dict[str, dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[list[int]] = None,
|
sites: Optional[list[int]] = None,
|
||||||
@@ -482,6 +473,8 @@ class SearchChain(ChainBase):
|
|||||||
area: Optional[str] = "title",
|
area: Optional[str] = "title",
|
||||||
custom_words: Optional[list[str]] = None,
|
custom_words: Optional[list[str]] = None,
|
||||||
filter_params: Optional[dict[str, str]] = None,
|
filter_params: Optional[dict[str, str]] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
|
candidate_filter: Optional[Callable[[list[Context]], list[Context]]] = None,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
"""通过稳定 Facade 流式执行媒体搜索编排。"""
|
"""通过稳定 Facade 流式执行媒体搜索编排。"""
|
||||||
async for event in SearchMediaOwner.async_process_stream(
|
async for event in SearchMediaOwner.async_process_stream(
|
||||||
@@ -494,6 +487,8 @@ class SearchChain(ChainBase):
|
|||||||
area=area,
|
area=area,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
filter_params=filter_params,
|
filter_params=filter_params,
|
||||||
|
include_candidates=include_candidates,
|
||||||
|
candidate_filter=candidate_filter,
|
||||||
):
|
):
|
||||||
yield event
|
yield event
|
||||||
_build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes)
|
_build_subtitle_season_episodes = staticmethod(SearchSubtitleOwner._build_subtitle_season_episodes)
|
||||||
|
|||||||
+60
-615
@@ -1,13 +1,9 @@
|
|||||||
"""精确媒体搜索与同步异步编排 owner。"""
|
"""精确媒体搜索与同步异步编排 owner。"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
Awaitable,
|
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
Generator,
|
Generator,
|
||||||
@@ -19,10 +15,9 @@ from typing import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase
|
from app.chain.search.contract import _SearchOwnerBase
|
||||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
from app.chain.search.execution import MediaSearchPlan, SearchExecutionOwner
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.context import Context, MediaInfo, MusicInfo
|
||||||
from app.runtime.execution import run_in_threadpool
|
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.media import build_media_key, resolve_media_identity
|
from app.schemas.media import build_media_key, resolve_media_identity
|
||||||
from app.schemas.mediaserver import NotExistMediaInfo
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
@@ -40,6 +35,7 @@ def _build_id_search_params(
|
|||||||
season: Optional[int],
|
season: Optional[int],
|
||||||
sites: Optional[List[int]],
|
sites: Optional[List[int]],
|
||||||
music_type: Optional[str],
|
music_type: Optional[str],
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
"""构造同步、异步和流式 ID 搜索共享的识别与缓存参数。"""
|
"""构造同步、异步和流式 ID 搜索共享的识别与缓存参数。"""
|
||||||
recognition_params = {
|
recognition_params = {
|
||||||
@@ -54,6 +50,8 @@ def _build_id_search_params(
|
|||||||
"season": season,
|
"season": season,
|
||||||
"sites": sites,
|
"sites": sites,
|
||||||
}
|
}
|
||||||
|
if include_candidates:
|
||||||
|
cache_params["include_candidates"] = True
|
||||||
return recognition_params, cache_params
|
return recognition_params, cache_params
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +144,8 @@ def _id_search_resolution(
|
|||||||
"sites": sites,
|
"sites": sites,
|
||||||
"area": area,
|
"area": area,
|
||||||
"no_exists": _build_missing_media_map(mediainfo, season),
|
"no_exists": _build_missing_media_map(mediainfo, season),
|
||||||
|
**({"include_candidates": True} if isinstance(mediainfo, MusicInfo)
|
||||||
|
and cache_params.get("include_candidates") else {}),
|
||||||
}
|
}
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -154,294 +154,6 @@ def _id_search_resolution(
|
|||||||
return _IdSearchResult(contexts=contexts)
|
return _IdSearchResult(contexts=contexts)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_media_search_input(mediainfo: MediaInfo) -> MediaInfo:
|
|
||||||
"""归一化非 TMDB 输入标题,并保留调用方对象由外层复制的所有权约束。"""
|
|
||||||
if not mediainfo.tmdb_id:
|
|
||||||
meta = MetaInfo(title=mediainfo.title)
|
|
||||||
mediainfo.title = meta.name
|
|
||||||
mediainfo.season = cast(int, meta.begin_season)
|
|
||||||
return mediainfo
|
|
||||||
|
|
||||||
|
|
||||||
def _should_stop_keyword_search(
|
|
||||||
search_multiple_name: bool,
|
|
||||||
torrents: List[TorrentInfo],
|
|
||||||
) -> bool:
|
|
||||||
"""统一判断首个有效关键字结果是否终止后续搜索。"""
|
|
||||||
return not search_multiple_name and bool(torrents)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _KeywordSearchRequest:
|
|
||||||
"""描述共享关键字状态机交给真实 I/O 边界的一次请求。"""
|
|
||||||
|
|
||||||
keyword: str
|
|
||||||
search_count: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _KeywordSearchResult:
|
|
||||||
"""冻结关键字状态机聚合结果及其提前停止决策。"""
|
|
||||||
|
|
||||||
torrents: List[TorrentInfo]
|
|
||||||
stopped_early: bool
|
|
||||||
|
|
||||||
|
|
||||||
_KeywordSearchResolution = Generator[
|
|
||||||
_KeywordSearchRequest, List[TorrentInfo], _KeywordSearchResult
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _keyword_search_resolution(
|
|
||||||
keywords: List[str],
|
|
||||||
search_multiple_name: bool,
|
|
||||||
) -> _KeywordSearchResolution:
|
|
||||||
"""统一推进关键字顺序、结果聚合和首个有效结果短路。"""
|
|
||||||
torrents: List[TorrentInfo] = []
|
|
||||||
for search_count, keyword in enumerate(keywords):
|
|
||||||
torrents.extend(
|
|
||||||
(yield _KeywordSearchRequest(
|
|
||||||
keyword=keyword,
|
|
||||||
search_count=search_count,
|
|
||||||
))
|
|
||||||
)
|
|
||||||
if _should_stop_keyword_search(search_multiple_name, torrents):
|
|
||||||
return _KeywordSearchResult(torrents=torrents, stopped_early=True)
|
|
||||||
return _KeywordSearchResult(torrents=torrents, stopped_early=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_keyword_search_sync(
|
|
||||||
resolution: _KeywordSearchResolution,
|
|
||||||
execute: Callable[[_KeywordSearchRequest], List[TorrentInfo]],
|
|
||||||
) -> _KeywordSearchResult:
|
|
||||||
"""通过同步 provider 驱动共享关键字状态机。"""
|
|
||||||
try:
|
|
||||||
request = next(resolution)
|
|
||||||
except StopIteration as outcome:
|
|
||||||
return cast(_KeywordSearchResult, outcome.value)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
request = resolution.send(execute(request))
|
|
||||||
except StopIteration as outcome:
|
|
||||||
return cast(_KeywordSearchResult, outcome.value)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_keyword_search_async(
|
|
||||||
resolution: _KeywordSearchResolution,
|
|
||||||
execute: Callable[[_KeywordSearchRequest], Awaitable[List[TorrentInfo]]],
|
|
||||||
) -> _KeywordSearchResult:
|
|
||||||
"""通过异步 provider 驱动共享关键字状态机。"""
|
|
||||||
try:
|
|
||||||
request = next(resolution)
|
|
||||||
except StopIteration as outcome:
|
|
||||||
return cast(_KeywordSearchResult, outcome.value)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
request = resolution.send(await execute(request))
|
|
||||||
except StopIteration as outcome:
|
|
||||||
return cast(_KeywordSearchResult, outcome.value)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaProcessPlan:
|
|
||||||
"""冻结媒体资源处理入口的业务输入。"""
|
|
||||||
|
|
||||||
mediainfo: MediaInfo
|
|
||||||
keyword: Optional[str]
|
|
||||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]]
|
|
||||||
sites: Optional[List[int]]
|
|
||||||
rule_groups: Optional[List[str]]
|
|
||||||
area: Optional[str]
|
|
||||||
custom_words: Optional[List[str]]
|
|
||||||
filter_params: Optional[Dict[str, str]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaMusicProcessRequest:
|
|
||||||
"""请求执行音乐资源搜索外壳。"""
|
|
||||||
|
|
||||||
params: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaRecognizeRequest:
|
|
||||||
"""请求补齐缺失名称的媒体信息。"""
|
|
||||||
|
|
||||||
params: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaSupplementRequest:
|
|
||||||
"""请求聚合已启用媒体来源的附加信息。"""
|
|
||||||
|
|
||||||
mediainfo: MediaInfo
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaKeywordProcessRequest:
|
|
||||||
"""请求按共享关键字计划执行 provider I/O。"""
|
|
||||||
|
|
||||||
mediainfo: MediaInfo
|
|
||||||
keywords: List[str]
|
|
||||||
sites: Optional[List[int]]
|
|
||||||
area: Optional[str]
|
|
||||||
search_multiple_name: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaParseRequest:
|
|
||||||
"""请求在同步或线程池 CPU 外壳中解析搜索结果。"""
|
|
||||||
|
|
||||||
params: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaLogRequest:
|
|
||||||
"""请求记录共享状态机决定的运行日志。"""
|
|
||||||
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _MediaProcessResult:
|
|
||||||
"""冻结媒体处理结果与识别失败状态。"""
|
|
||||||
|
|
||||||
contexts: List[Context]
|
|
||||||
recognition_failed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
_MediaProcessRequest = Union[
|
|
||||||
_MediaMusicProcessRequest,
|
|
||||||
_MediaRecognizeRequest,
|
|
||||||
_MediaSupplementRequest,
|
|
||||||
_MediaKeywordProcessRequest,
|
|
||||||
_MediaParseRequest,
|
|
||||||
_MediaLogRequest,
|
|
||||||
]
|
|
||||||
_MediaProcessResolution = Generator[
|
|
||||||
_MediaProcessRequest, object, _MediaProcessResult
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _media_process_resolution(
|
|
||||||
plan: _MediaProcessPlan,
|
|
||||||
search_multiple_name: Callable[[], bool],
|
|
||||||
copy_media: Callable[[MediaInfo], MediaInfo],
|
|
||||||
recognize_kwargs: Callable[[MediaInfo], Dict[str, Any]],
|
|
||||||
prepare_params: Callable[..., Tuple[Optional[Dict[int, List[int]]], List[str]]],
|
|
||||||
) -> _MediaProcessResolution:
|
|
||||||
"""统一媒体处理的类型路由、识别、补充、搜索和解析状态。"""
|
|
||||||
if plan.mediainfo.type == MediaType.MUSIC:
|
|
||||||
contexts = cast(
|
|
||||||
List[Context],
|
|
||||||
(yield _MediaMusicProcessRequest(
|
|
||||||
params={
|
|
||||||
"mediainfo": cast(MusicInfo, plan.mediainfo),
|
|
||||||
"keyword": plan.keyword,
|
|
||||||
"sites": plan.sites,
|
|
||||||
"rule_groups": plan.rule_groups,
|
|
||||||
"filter_params": plan.filter_params,
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
return _MediaProcessResult(contexts=contexts)
|
|
||||||
|
|
||||||
mediainfo = _normalize_media_search_input(copy_media(plan.mediainfo))
|
|
||||||
yield _MediaLogRequest(
|
|
||||||
message=f"开始搜索资源,关键词:{plan.keyword or mediainfo.title} ..."
|
|
||||||
)
|
|
||||||
if not mediainfo.names:
|
|
||||||
recognized_media = cast(
|
|
||||||
Optional[MediaInfo],
|
|
||||||
(yield _MediaRecognizeRequest(
|
|
||||||
params={
|
|
||||||
"mtype": mediainfo.type,
|
|
||||||
**recognize_kwargs(mediainfo),
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
if not recognized_media:
|
|
||||||
return _MediaProcessResult(contexts=[], recognition_failed=True)
|
|
||||||
mediainfo = recognized_media
|
|
||||||
|
|
||||||
mediainfo = cast(
|
|
||||||
Optional[MediaInfo],
|
|
||||||
(yield _MediaSupplementRequest(mediainfo=mediainfo)),
|
|
||||||
) or mediainfo
|
|
||||||
season_episodes, keywords = prepare_params(
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=plan.keyword,
|
|
||||||
no_exists=plan.no_exists,
|
|
||||||
)
|
|
||||||
outcome = cast(
|
|
||||||
_KeywordSearchResult,
|
|
||||||
(yield _MediaKeywordProcessRequest(
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keywords=keywords,
|
|
||||||
sites=plan.sites,
|
|
||||||
area=plan.area,
|
|
||||||
search_multiple_name=search_multiple_name(),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
if outcome.stopped_early:
|
|
||||||
yield _MediaLogRequest(
|
|
||||||
message=f"共搜索到 {len(outcome.torrents)} 个资源,停止搜索"
|
|
||||||
)
|
|
||||||
contexts = cast(
|
|
||||||
List[Context],
|
|
||||||
(yield _MediaParseRequest(
|
|
||||||
params=_build_result_params(
|
|
||||||
torrents=outcome.torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=plan.keyword,
|
|
||||||
rule_groups=plan.rule_groups,
|
|
||||||
season_episodes=season_episodes,
|
|
||||||
custom_words=plan.custom_words,
|
|
||||||
filter_params=plan.filter_params,
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
return _MediaProcessResult(contexts=contexts)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_result_params(
|
|
||||||
torrents: List[TorrentInfo],
|
|
||||||
mediainfo: MediaInfo,
|
|
||||||
keyword: Optional[str],
|
|
||||||
rule_groups: Optional[List[str]],
|
|
||||||
season_episodes: Optional[Dict[int, List[int]]],
|
|
||||||
custom_words: Optional[List[str]],
|
|
||||||
filter_params: Optional[Dict[str, str]],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""构造同步、异步和流式结果解析共享的参数快照。"""
|
|
||||||
return {
|
|
||||||
"torrents": torrents,
|
|
||||||
"mediainfo": mediainfo,
|
|
||||||
"keyword": keyword,
|
|
||||||
"rule_groups": rule_groups,
|
|
||||||
"season_episodes": season_episodes,
|
|
||||||
"custom_words": custom_words,
|
|
||||||
"filter_params": filter_params,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_candidate_contexts(
|
|
||||||
mediainfo: MediaInfo,
|
|
||||||
torrents: List[TorrentInfo],
|
|
||||||
) -> List[Context]:
|
|
||||||
"""将流式站点候选映射为尚未精确过滤的搜索上下文。"""
|
|
||||||
return [
|
|
||||||
Context(
|
|
||||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
|
||||||
media_info=mediainfo,
|
|
||||||
torrent_info=torrent,
|
|
||||||
resource_source="search",
|
|
||||||
media_info_is_target=True,
|
|
||||||
)
|
|
||||||
for torrent in torrents
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class SearchMediaOwner(_SearchOwnerBase):
|
class SearchMediaOwner(_SearchOwnerBase):
|
||||||
"""精确媒体搜索与同步异步编排 owner。"""
|
"""精确媒体搜索与同步异步编排 owner。"""
|
||||||
|
|
||||||
@@ -499,6 +211,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
cache_local: bool = False,
|
cache_local: bool = False,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据数据源媒体 ID 搜索资源,精确匹配,不过滤本地存在的资源
|
根据数据源媒体 ID 搜索资源,精确匹配,不过滤本地存在的资源
|
||||||
@@ -519,6 +232,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
season=season,
|
season=season,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
result = SearchMediaOwner._run_id_search_sync(
|
result = SearchMediaOwner._run_id_search_sync(
|
||||||
self,
|
self,
|
||||||
@@ -548,6 +262,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
cache_local: bool = False,
|
cache_local: bool = False,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""
|
"""
|
||||||
根据数据源媒体 ID 异步搜索资源,精确匹配,不过滤本地存在的资源
|
根据数据源媒体 ID 异步搜索资源,精确匹配,不过滤本地存在的资源
|
||||||
@@ -568,6 +283,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
season=season,
|
season=season,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
result = await SearchMediaOwner._run_id_search_async(
|
result = await SearchMediaOwner._run_id_search_async(
|
||||||
self,
|
self,
|
||||||
@@ -597,6 +313,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None,
|
||||||
cache_local: bool = False,
|
cache_local: bool = False,
|
||||||
music_type: Optional[str] = None,
|
music_type: Optional[str] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> AsyncIterator[Dict[str, Any]]:
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果
|
根据数据源媒体 ID 渐进式搜索资源,先返回站点原始候选,再返回过滤匹配后的最终结果
|
||||||
@@ -609,6 +326,7 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
season=season,
|
season=season,
|
||||||
sites=sites,
|
sites=sites,
|
||||||
music_type=music_type,
|
music_type=music_type,
|
||||||
|
include_candidates=include_candidates,
|
||||||
)
|
)
|
||||||
if cache_local:
|
if cache_local:
|
||||||
self.cancel_ai_recommend()
|
self.cancel_ai_recommend()
|
||||||
@@ -623,7 +341,11 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
no_exists = _build_missing_media_map(mediainfo, season)
|
no_exists = _build_missing_media_map(mediainfo, season)
|
||||||
|
|
||||||
contexts: List[Context] = []
|
contexts: List[Context] = []
|
||||||
async for event in self.async_process_stream(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists):
|
candidate_params: Dict[str, Any] = {"include_candidates": True} if include_candidates else {}
|
||||||
|
async for event in self.async_process_stream(
|
||||||
|
mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists,
|
||||||
|
**candidate_params,
|
||||||
|
):
|
||||||
if event.get("type") == "done":
|
if event.get("type") == "done":
|
||||||
contexts = event.get("contexts") or []
|
contexts = event.get("contexts") or []
|
||||||
event = {key: value for key, value in event.items() if key != "contexts"}
|
event = {key: value for key, value in event.items() if key != "contexts"}
|
||||||
@@ -632,335 +354,58 @@ class SearchMediaOwner(_SearchOwnerBase):
|
|||||||
if cache_local:
|
if cache_local:
|
||||||
await self._async_save_results(contexts)
|
await self._async_save_results(contexts)
|
||||||
|
|
||||||
def _run_media_process_sync(
|
|
||||||
self, resolution: _MediaProcessResolution
|
|
||||||
) -> _MediaProcessResult:
|
|
||||||
"""用同步 provider 与 CPU 外壳驱动共享媒体处理状态机。"""
|
|
||||||
response: object = None
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
request = resolution.send(response)
|
|
||||||
except StopIteration as completed:
|
|
||||||
return cast(_MediaProcessResult, completed.value)
|
|
||||||
if isinstance(request, _MediaMusicProcessRequest):
|
|
||||||
response = self._process_music(**request.params)
|
|
||||||
elif isinstance(request, _MediaRecognizeRequest):
|
|
||||||
response = MediaChain().recognize_media(**request.params)
|
|
||||||
elif isinstance(request, _MediaSupplementRequest):
|
|
||||||
response = MediaChain().supplement_media_info(request.mediainfo)
|
|
||||||
elif isinstance(request, _MediaKeywordProcessRequest):
|
|
||||||
|
|
||||||
def execute_search(
|
|
||||||
keyword_request: _KeywordSearchRequest,
|
|
||||||
) -> List[TorrentInfo]:
|
|
||||||
"""执行共享关键字请求的同步站点 I/O。"""
|
|
||||||
if keyword_request.search_count > 0:
|
|
||||||
logger.info(
|
|
||||||
f"已搜索 {keyword_request.search_count} 次,"
|
|
||||||
"强制休眠 1-10 秒 ..."
|
|
||||||
)
|
|
||||||
time.sleep(random.randint(1, 10))
|
|
||||||
return (
|
|
||||||
self._SearchChain__search_all_sites(
|
|
||||||
mediainfo=request.mediainfo,
|
|
||||||
keyword=keyword_request.keyword,
|
|
||||||
sites=request.sites,
|
|
||||||
area=request.area,
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
|
||||||
|
|
||||||
response = _run_keyword_search_sync(
|
|
||||||
_keyword_search_resolution(
|
|
||||||
request.keywords, request.search_multiple_name
|
|
||||||
),
|
|
||||||
execute_search,
|
|
||||||
)
|
|
||||||
elif isinstance(request, _MediaParseRequest):
|
|
||||||
response = self._parse_result(**request.params)
|
|
||||||
else:
|
|
||||||
logger.info(request.message)
|
|
||||||
response = None
|
|
||||||
|
|
||||||
async def _run_media_process_async(
|
|
||||||
self, resolution: _MediaProcessResolution
|
|
||||||
) -> _MediaProcessResult:
|
|
||||||
"""用异步 provider 与线程池 CPU 外壳驱动共享媒体处理状态机。"""
|
|
||||||
response: object = None
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
request = resolution.send(response)
|
|
||||||
except StopIteration as completed:
|
|
||||||
return cast(_MediaProcessResult, completed.value)
|
|
||||||
if isinstance(request, _MediaMusicProcessRequest):
|
|
||||||
response = await self._async_process_music(**request.params)
|
|
||||||
elif isinstance(request, _MediaRecognizeRequest):
|
|
||||||
response = await MediaChain().async_recognize_media(
|
|
||||||
**request.params
|
|
||||||
)
|
|
||||||
elif isinstance(request, _MediaSupplementRequest):
|
|
||||||
response = await MediaChain().async_supplement_media_info(
|
|
||||||
request.mediainfo
|
|
||||||
)
|
|
||||||
elif isinstance(request, _MediaKeywordProcessRequest):
|
|
||||||
|
|
||||||
async def execute_search(
|
|
||||||
keyword_request: _KeywordSearchRequest,
|
|
||||||
) -> List[TorrentInfo]:
|
|
||||||
"""执行共享关键字请求的异步站点 I/O。"""
|
|
||||||
if keyword_request.search_count > 0:
|
|
||||||
logger.info(
|
|
||||||
f"已搜索 {keyword_request.search_count} 次,"
|
|
||||||
"强制休眠 1-10 秒 ..."
|
|
||||||
)
|
|
||||||
await asyncio.sleep(random.randint(1, 10))
|
|
||||||
return (
|
|
||||||
await self._SearchChain__async_search_all_sites(
|
|
||||||
mediainfo=request.mediainfo,
|
|
||||||
keyword=keyword_request.keyword,
|
|
||||||
sites=request.sites,
|
|
||||||
area=request.area,
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await _run_keyword_search_async(
|
|
||||||
_keyword_search_resolution(
|
|
||||||
request.keywords, request.search_multiple_name
|
|
||||||
),
|
|
||||||
execute_search,
|
|
||||||
)
|
|
||||||
elif isinstance(request, _MediaParseRequest):
|
|
||||||
response = await run_in_threadpool(
|
|
||||||
self._parse_result, **request.params
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(request.message)
|
|
||||||
response = None
|
|
||||||
|
|
||||||
def process(
|
def process(
|
||||||
self,
|
self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None,
|
||||||
mediainfo: MediaInfo,
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None,
|
||||||
rule_groups: Optional[List[str]] = None,
|
area: Optional[str] = "title", custom_words: Optional[List[str]] = None,
|
||||||
area: Optional[str] = "title",
|
filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False,
|
||||||
custom_words: Optional[List[str]] = None,
|
candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None,
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""
|
"""通过所有媒体共用的状态机同步搜索,人工候选必须显式请求。"""
|
||||||
根据媒体信息搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
return SearchExecutionOwner.run(
|
||||||
:param mediainfo: 媒体信息
|
self, MediaSearchPlan(
|
||||||
:param keyword: 搜索关键词
|
mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites,
|
||||||
:param no_exists: 缺失的媒体信息
|
rule_groups=rule_groups, area=area, custom_words=custom_words,
|
||||||
:param sites: 站点ID列表,为空时搜索所有站点
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
:param rule_groups: 过滤规则组名称列表
|
candidate_filter=candidate_filter,
|
||||||
:param area: 搜索范围,title or imdbid
|
), MediaChain(),
|
||||||
:param custom_words: 自定义识别词列表
|
|
||||||
:param filter_params: 过滤参数
|
|
||||||
"""
|
|
||||||
result = SearchMediaOwner._run_media_process_sync(
|
|
||||||
self,
|
|
||||||
_media_process_resolution(
|
|
||||||
plan=_MediaProcessPlan(
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=keyword,
|
|
||||||
no_exists=no_exists,
|
|
||||||
sites=sites,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
area=area,
|
|
||||||
custom_words=custom_words,
|
|
||||||
filter_params=filter_params,
|
|
||||||
),
|
|
||||||
search_multiple_name=(
|
|
||||||
lambda: self.runtime_config.search_multiple_name
|
|
||||||
),
|
|
||||||
copy_media=self._copy_media_input,
|
|
||||||
recognize_kwargs=self._media_recognize_kwargs,
|
|
||||||
prepare_params=self._prepare_params,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if result.recognition_failed:
|
|
||||||
logger.error("媒体信息识别失败!")
|
|
||||||
return result.contexts
|
|
||||||
|
|
||||||
async def async_process(
|
async def async_process(
|
||||||
self,
|
self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None,
|
||||||
mediainfo: MediaInfo,
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None,
|
||||||
rule_groups: Optional[List[str]] = None,
|
area: Optional[str] = "title", custom_words: Optional[List[str]] = None,
|
||||||
area: Optional[str] = "title",
|
filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False,
|
||||||
custom_words: Optional[List[str]] = None,
|
candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None,
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""
|
"""异步执行同一搜索状态机,站点查询使用非流式异步端口。"""
|
||||||
根据媒体信息异步搜索种子资源,精确匹配,应用过滤规则,同时根据no_exists过滤本地已存在的资源
|
plan = MediaSearchPlan(
|
||||||
:param mediainfo: 媒体信息
|
mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites,
|
||||||
:param keyword: 搜索关键词
|
rule_groups=rule_groups, area=area, custom_words=custom_words,
|
||||||
:param no_exists: 缺失的媒体信息
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
:param sites: 站点ID列表,为空时搜索所有站点
|
candidate_filter=candidate_filter,
|
||||||
:param rule_groups: 过滤规则组名称列表
|
|
||||||
:param area: 搜索范围,title or imdbid
|
|
||||||
:param custom_words: 自定义识别词列表
|
|
||||||
:param filter_params: 过滤参数
|
|
||||||
"""
|
|
||||||
result = await SearchMediaOwner._run_media_process_async(
|
|
||||||
self,
|
|
||||||
_media_process_resolution(
|
|
||||||
plan=_MediaProcessPlan(
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=keyword,
|
|
||||||
no_exists=no_exists,
|
|
||||||
sites=sites,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
area=area,
|
|
||||||
custom_words=custom_words,
|
|
||||||
filter_params=filter_params,
|
|
||||||
),
|
|
||||||
search_multiple_name=(
|
|
||||||
lambda: self.runtime_config.search_multiple_name
|
|
||||||
),
|
|
||||||
copy_media=self._copy_media_input,
|
|
||||||
recognize_kwargs=self._media_recognize_kwargs,
|
|
||||||
prepare_params=self._prepare_params,
|
|
||||||
)
|
)
|
||||||
)
|
async for event in SearchExecutionOwner.events(self, plan, MediaChain(), streaming=False):
|
||||||
if result.recognition_failed:
|
if event["type"] == "done":
|
||||||
logger.error("媒体信息识别失败!")
|
return cast(List[Context], event["contexts"])
|
||||||
return result.contexts
|
return []
|
||||||
|
|
||||||
async def async_process_stream(
|
async def async_process_stream(
|
||||||
self,
|
self, mediainfo: MediaInfo | MusicInfo, keyword: Optional[str] = None,
|
||||||
mediainfo: MediaInfo,
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
no_exists: Optional[Dict[str, Dict[int, NotExistMediaInfo]]] = None,
|
||||||
sites: Optional[List[int]] = None,
|
sites: Optional[List[int]] = None, rule_groups: Optional[List[str]] = None,
|
||||||
rule_groups: Optional[List[str]] = None,
|
area: Optional[str] = "title", custom_words: Optional[List[str]] = None,
|
||||||
area: Optional[str] = "title",
|
filter_params: Optional[Dict[str, str]] = None, include_candidates: bool = False,
|
||||||
custom_words: Optional[List[str]] = None,
|
candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None,
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> AsyncIterator[Dict[str, Any]]:
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""
|
"""逐站点发布统一搜索进度,并以同一结果处理器完成过滤与匹配。"""
|
||||||
根据媒体信息渐进式搜索种子资源,先返回站点候选,再返回过滤匹配后的最终结果
|
plan = MediaSearchPlan(
|
||||||
"""
|
mediainfo=mediainfo, keyword=keyword, no_exists=no_exists, sites=sites,
|
||||||
|
rule_groups=rule_groups, area=area, custom_words=custom_words,
|
||||||
if mediainfo.type == MediaType.MUSIC:
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
async for event in self._async_process_music_stream(
|
candidate_filter=candidate_filter,
|
||||||
mediainfo=cast(MusicInfo, mediainfo),
|
)
|
||||||
keyword=keyword,
|
async for event in SearchExecutionOwner.events(self, plan, MediaChain()):
|
||||||
sites=sites,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
filter_params=filter_params,
|
|
||||||
):
|
|
||||||
yield event
|
yield event
|
||||||
return
|
|
||||||
|
|
||||||
mediainfo = _normalize_media_search_input(self._copy_media_input(mediainfo))
|
|
||||||
logger.info(f"开始渐进式搜索资源,关键词:{keyword or mediainfo.title} ...")
|
|
||||||
|
|
||||||
# 补充媒体信息
|
|
||||||
if not mediainfo.names:
|
|
||||||
recognized_media = await MediaChain().async_recognize_media(
|
|
||||||
mtype=mediainfo.type,
|
|
||||||
**self._media_recognize_kwargs(mediainfo),
|
|
||||||
)
|
|
||||||
if not recognized_media:
|
|
||||||
logger.error("媒体信息识别失败!")
|
|
||||||
yield {"type": "error", "success": False, "message": "媒体信息识别失败"}
|
|
||||||
return
|
|
||||||
mediainfo = recognized_media
|
|
||||||
|
|
||||||
mediainfo = cast(
|
|
||||||
MediaInfo,
|
|
||||||
await MediaChain().async_supplement_media_info(mediainfo) or mediainfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 准备搜索参数
|
|
||||||
season_episodes, keywords = self._prepare_params(mediainfo=mediainfo, keyword=keyword, no_exists=no_exists)
|
|
||||||
|
|
||||||
candidate_contexts: List[Context] = []
|
|
||||||
resolution = _keyword_search_resolution(
|
|
||||||
keywords, self.runtime_config.search_multiple_name
|
|
||||||
)
|
|
||||||
outcome = _KeywordSearchResult(torrents=[], stopped_early=False)
|
|
||||||
try:
|
|
||||||
request = next(resolution)
|
|
||||||
except StopIteration as completed:
|
|
||||||
outcome = cast(_KeywordSearchResult, completed.value)
|
|
||||||
else:
|
|
||||||
while True:
|
|
||||||
if request.search_count > 0:
|
|
||||||
logger.info(
|
|
||||||
f"已搜索 {request.search_count} 次,强制休眠 1-10 秒 ..."
|
|
||||||
)
|
|
||||||
await asyncio.sleep(random.randint(1, 10))
|
|
||||||
search_results: List[TorrentInfo] = []
|
|
||||||
async for event in self._SearchChain__async_search_all_sites_stream(
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=request.keyword,
|
|
||||||
sites=sites,
|
|
||||||
area=area,
|
|
||||||
):
|
|
||||||
result = event.pop("items", []) or []
|
|
||||||
search_results.extend(result)
|
|
||||||
batch_contexts = _build_candidate_contexts(mediainfo, result)
|
|
||||||
candidate_contexts.extend(batch_contexts)
|
|
||||||
yield {
|
|
||||||
**event,
|
|
||||||
"type": "append",
|
|
||||||
"stage": "searching",
|
|
||||||
"items": [
|
|
||||||
cast(Any, context).to_dict()
|
|
||||||
for context in batch_contexts
|
|
||||||
],
|
|
||||||
"total_items": len(candidate_contexts),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
request = resolution.send(search_results)
|
|
||||||
except StopIteration as completed:
|
|
||||||
outcome = cast(_KeywordSearchResult, completed.value)
|
|
||||||
break
|
|
||||||
if outcome.stopped_early:
|
|
||||||
logger.info(f"共搜索到 {len(outcome.torrents)} 个资源,停止搜索")
|
|
||||||
|
|
||||||
yield {
|
|
||||||
"type": "progress",
|
|
||||||
"stage": "filtering",
|
|
||||||
"value": 98,
|
|
||||||
"text": f"正在过滤匹配 {len(outcome.torrents)} 个候选资源 ...",
|
|
||||||
}
|
|
||||||
|
|
||||||
contexts = await run_in_threadpool(
|
|
||||||
self._parse_result,
|
|
||||||
**_build_result_params(
|
|
||||||
torrents=outcome.torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
keyword=keyword,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
season_episodes=season_episodes,
|
|
||||||
custom_words=custom_words,
|
|
||||||
filter_params=filter_params,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
final_items = [context.to_dict() for context in contexts]
|
|
||||||
yield {
|
|
||||||
"type": "replace",
|
|
||||||
"stage": "filtered",
|
|
||||||
"value": 100,
|
|
||||||
"text": f"过滤匹配完成,共 {len(contexts)} 个资源",
|
|
||||||
"items": final_items,
|
|
||||||
"total_items": len(contexts),
|
|
||||||
"candidate_items": len(candidate_contexts),
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
"type": "done",
|
|
||||||
"stage": "done",
|
|
||||||
"text": f"搜索完成,共 {len(contexts)} 个资源",
|
|
||||||
"items": final_items,
|
|
||||||
"total_items": len(contexts),
|
|
||||||
"candidate_items": len(candidate_contexts),
|
|
||||||
"contexts": contexts,
|
|
||||||
}
|
|
||||||
|
|||||||
+88
-265
@@ -1,307 +1,130 @@
|
|||||||
"""音乐关键词、资源匹配与搜索编排 owner。"""
|
"""音乐关键词、资源匹配与搜索编排 owner。"""
|
||||||
|
|
||||||
import asyncio
|
import copy
|
||||||
import random
|
from collections import Counter
|
||||||
import re
|
from typing import Any, AsyncIterator, Dict, Iterable, List, Optional
|
||||||
import time
|
|
||||||
from typing import Any, AsyncIterator, Callable, Dict, Iterable, List, Optional, cast
|
|
||||||
from unicodedata import normalize
|
|
||||||
|
|
||||||
from app.application.configuration import (
|
from app.chain.search.contract import _SearchOwnerBase
|
||||||
get_configured_system_config,
|
|
||||||
)
|
|
||||||
from app.application.torrent.download import TorrentHelper
|
|
||||||
from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase
|
|
||||||
from app.domain.context import Context, MusicInfo, TorrentInfo
|
from app.domain.context import Context, MusicInfo, TorrentInfo
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.foundation.text import convert as zhconv_convert
|
from app.domain.music import (
|
||||||
from app.runtime.execution import run_in_threadpool
|
match_music_resource,
|
||||||
from app.schemas.types import (
|
music_artists,
|
||||||
MUSIC_ENTITY_ALBUM,
|
music_base_title,
|
||||||
MediaType,
|
music_text_key,
|
||||||
SystemConfigKey,
|
music_titles,
|
||||||
|
unique_music_texts,
|
||||||
)
|
)
|
||||||
|
from app.foundation.text import convert as zhconv_convert
|
||||||
|
from app.schemas.types import MUSIC_ENTITY_ALBUM
|
||||||
|
|
||||||
|
_MAX_MUSIC_KEYWORDS = 12
|
||||||
|
|
||||||
|
|
||||||
class SearchMusicOwner(_SearchOwnerBase):
|
class SearchMusicOwner(_SearchOwnerBase):
|
||||||
"""音乐关键词、资源匹配与搜索编排 owner。"""
|
"""音乐搜索共用严格身份规则,人工候选通过显式参数单独开放。"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
|
def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
|
||||||
"""按实体生成站点关键词,繁体字段优先使用简体写法扩大召回。"""
|
"""依次查询主名称、艺术家组合和可信别名,保留原文且不单搜艺术家。"""
|
||||||
artists = music.artists or []
|
info = MusicInfo.from_meta(music) if isinstance(music, MetaMusic) else music
|
||||||
artist = artists[0] if artists else music.album_artist
|
artists = music_artists(info)
|
||||||
values: list[Optional[str]] = []
|
artist = info.artists[0] if info.artists else info.album_artist
|
||||||
if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
titles = music_titles(info)
|
||||||
album = music.album or music.title
|
values: list[Optional[str]] = list(titles)
|
||||||
values.extend([album, f"{artist} {album}" if artist and album else None])
|
for title in titles:
|
||||||
else:
|
values.append(f"{artist} {title}" if artist else None)
|
||||||
values.extend(
|
for title in titles:
|
||||||
[
|
base = music_base_title(title)
|
||||||
music.title,
|
if base != title:
|
||||||
f"{artist} {music.title}" if artist and music.title else None,
|
values.extend([base, f"{artist} {base}" if artist else None])
|
||||||
]
|
if titles:
|
||||||
)
|
values.extend(f"{alias} {titles[0]}" for alias in artists if alias != artist)
|
||||||
search_values: list[Optional[str]] = []
|
search_values: list[Optional[str]] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
search_values.extend(
|
search_values.extend([zhconv_convert(value, "zh-hans") if value else None, value])
|
||||||
[
|
return cls._unique_music_texts(search_values)[:_MAX_MUSIC_KEYWORDS]
|
||||||
zhconv_convert(value, "zh-hans") if value else None,
|
|
||||||
value,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return cls._unique_music_texts(search_values)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def matches_music_resource(
|
def matches_music_resource(
|
||||||
cls,
|
cls, music: MusicInfo, resource_title: str, resource_description: Optional[str] = None,
|
||||||
music: MusicInfo,
|
|
||||||
resource_title: str,
|
|
||||||
resource_description: Optional[str] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""校验标题与副标题同时命中目标专辑/曲名和艺术家。"""
|
"""只返回可用于自动订阅的精确身份命中,不放行待确认或关联专辑。"""
|
||||||
normalized_resource = cls._normalize_music_match_text(f"{resource_title or ''} {resource_description or ''}")
|
return match_music_resource(music, resource_title, resource_description).status == "exact"
|
||||||
if not normalized_resource:
|
|
||||||
return False
|
|
||||||
if music.music_type == MUSIC_ENTITY_ALBUM:
|
|
||||||
candidates = cls._unique_music_texts(
|
|
||||||
[
|
|
||||||
music.album or music.title,
|
|
||||||
*(music.names or []),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
candidates = cls._unique_music_texts([music.title])
|
|
||||||
normalized_candidates = [cls._normalize_music_match_text(candidate) for candidate in candidates]
|
|
||||||
if not any(candidate in normalized_resource for candidate in normalized_candidates if candidate):
|
|
||||||
return False
|
|
||||||
artists = cls._unique_music_texts(
|
|
||||||
[
|
|
||||||
music.artist,
|
|
||||||
music.album_artist,
|
|
||||||
*(music.artists or []),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
normalized_artists = [cls._normalize_music_match_text(artist) for artist in artists]
|
|
||||||
return bool(normalized_artists) and any(
|
|
||||||
artist in normalized_resource for artist in normalized_artists if artist
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_music_match_text(value: Optional[str]) -> str:
|
def _normalize_music_match_text(value: Optional[str]) -> str:
|
||||||
"""去除音乐名称干扰字符并转换为简体小写文本。"""
|
"""保留公开门面的名称归一化契约。"""
|
||||||
compact_text = "".join(char for char in normalize("NFKC", str(value or "")).casefold() if char.isalnum())
|
return music_text_key(value)
|
||||||
return str(zhconv_convert(compact_text, "zh-hans"))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]:
|
def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]:
|
||||||
"""按清理后的文本去重,并保留站点搜索词原始顺序。"""
|
"""保留公开门面的有序关键词去重契约。"""
|
||||||
results: list[str] = []
|
return unique_music_texts(values)
|
||||||
seen: set[str] = set()
|
|
||||||
for value in values:
|
@staticmethod
|
||||||
normalized = re.sub(r"\s+", " ", str(value or "")).strip()
|
def _music_keywords(mediainfo: MusicInfo, keyword: Optional[str], include_candidates: bool) -> list[str]:
|
||||||
identity = normalized.casefold()
|
"""人工单曲搜索在严格关键词之后追加所属专辑,仍由匹配结果区分实体。"""
|
||||||
if not normalized or identity in seen:
|
if keyword:
|
||||||
continue
|
return [keyword]
|
||||||
seen.add(identity)
|
keywords = SearchMusicOwner.music_site_keywords(mediainfo)
|
||||||
results.append(normalized)
|
if include_candidates and mediainfo.music_type != MUSIC_ENTITY_ALBUM and mediainfo.album:
|
||||||
return results
|
album = copy.copy(mediainfo)
|
||||||
|
album.music_type = MUSIC_ENTITY_ALBUM
|
||||||
|
album.title = mediainfo.album
|
||||||
|
album.title_aliases = list(mediainfo.album_aliases or [])
|
||||||
|
album.names = []
|
||||||
|
keywords.extend(SearchMusicOwner.music_site_keywords(album))
|
||||||
|
return unique_music_texts(keywords)
|
||||||
|
|
||||||
def _build_music_contexts(
|
def _build_music_contexts(
|
||||||
self,
|
self, torrents: List[TorrentInfo], mediainfo: MusicInfo,
|
||||||
torrents: List[TorrentInfo],
|
rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None,
|
||||||
mediainfo: MusicInfo,
|
include_candidates: bool = False, diagnostics: Optional[Counter[str]] = None,
|
||||||
rule_groups: Optional[List[str]] = None,
|
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""过滤音乐分类资源并组装携带目标音乐身份的下载上下文。"""
|
"""保留音乐兼容入口,全部结果交给通用资源过滤、匹配和上下文构造流程。"""
|
||||||
torrents = self._matching_music_torrents(torrents, mediainfo)
|
return self._parse_result(
|
||||||
if filter_params:
|
torrents=torrents, mediainfo=mediainfo, rule_groups=rule_groups,
|
||||||
torrents = [torrent for torrent in torrents if TorrentHelper.filter_torrent(torrent, filter_params)]
|
filter_params=filter_params, include_candidates=include_candidates, diagnostics=diagnostics,
|
||||||
if rule_groups is None:
|
|
||||||
rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
|
||||||
if rule_groups and torrents:
|
|
||||||
filter_torrents = cast(
|
|
||||||
Callable[..., List[TorrentInfo]],
|
|
||||||
self.filter_torrents,
|
|
||||||
)
|
|
||||||
torrents = (
|
|
||||||
filter_torrents(
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
torrent_list=torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
|
||||||
|
|
||||||
contexts: List[Context] = []
|
|
||||||
for torrent in torrents:
|
|
||||||
meta = MetaMusic.from_music_info(mediainfo)
|
|
||||||
meta.org_string = torrent.title
|
|
||||||
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
|
|
||||||
contexts.append(
|
|
||||||
Context(
|
|
||||||
torrent_info=torrent,
|
|
||||||
media_info=mediainfo,
|
|
||||||
meta_info=meta,
|
|
||||||
resource_source="search",
|
|
||||||
match_source=str(mediainfo.media_source or "title"),
|
|
||||||
candidate_recognized=False,
|
|
||||||
media_info_is_target=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return cast(
|
|
||||||
List[Context],
|
|
||||||
self._remove_duplicate(TorrentHelper.sort_torrents(contexts)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _matching_music_torrents(
|
def _matching_music_torrents(torrents: Optional[List[TorrentInfo]], mediainfo: MusicInfo) -> List[TorrentInfo]:
|
||||||
torrents: Optional[List[TorrentInfo]],
|
"""保留严格音乐候选筛选入口;底层匹配消费资源解析结果。"""
|
||||||
mediainfo: MusicInfo,
|
return [torrent for torrent in torrents or [] if match_music_resource(
|
||||||
) -> List[TorrentInfo]:
|
mediainfo, torrent.title, torrent.description, torrent.category,
|
||||||
"""筛出音乐分类且标题、副标题匹配目标名称与艺术家的站点资源。"""
|
).status == "exact"]
|
||||||
return [
|
|
||||||
torrent
|
|
||||||
for torrent in torrents or []
|
|
||||||
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
|
|
||||||
and SearchMusicOwner.matches_music_resource(
|
|
||||||
mediainfo,
|
|
||||||
torrent.title,
|
|
||||||
torrent.description,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _process_music(
|
def _process_music(
|
||||||
self,
|
self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None,
|
||||||
mediainfo: MusicInfo,
|
rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None,
|
||||||
keyword: Optional[str] = None,
|
include_candidates: bool = False,
|
||||||
sites: Optional[List[int]] = None,
|
|
||||||
rule_groups: Optional[List[str]] = None,
|
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""按音乐元数据生成站点关键词并执行同步资源搜索。"""
|
"""兼容同步音乐入口,不再拥有独立站点查询和停止循环。"""
|
||||||
keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo)
|
return self.process(
|
||||||
torrents: List[TorrentInfo] = []
|
mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups,
|
||||||
for index, search_word in enumerate(keywords or [mediainfo.title]):
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
if index:
|
|
||||||
time.sleep(random.randint(1, 10))
|
|
||||||
matched_torrents = self._matching_music_torrents(
|
|
||||||
self._SearchChain__search_all_sites(
|
|
||||||
keyword=search_word,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
sites=sites,
|
|
||||||
mtype=MediaType.MUSIC,
|
|
||||||
),
|
|
||||||
mediainfo,
|
|
||||||
)
|
|
||||||
torrents.extend(matched_torrents)
|
|
||||||
if matched_torrents and not self.runtime_config.search_multiple_name:
|
|
||||||
break
|
|
||||||
return self._build_music_contexts(
|
|
||||||
torrents=torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
filter_params=filter_params,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_process_music(
|
async def _async_process_music(
|
||||||
self,
|
self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None,
|
||||||
mediainfo: MusicInfo,
|
rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None,
|
||||||
keyword: Optional[str] = None,
|
include_candidates: bool = False,
|
||||||
sites: Optional[List[int]] = None,
|
|
||||||
rule_groups: Optional[List[str]] = None,
|
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""按音乐元数据生成站点关键词并执行异步资源搜索。"""
|
"""兼容异步音乐入口,复用所有媒体共用的异步搜索状态机。"""
|
||||||
keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo)
|
return await self.async_process(
|
||||||
torrents: List[TorrentInfo] = []
|
mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups,
|
||||||
for index, search_word in enumerate(keywords or [mediainfo.title]):
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
if index:
|
|
||||||
await asyncio.sleep(random.randint(1, 10))
|
|
||||||
matched_torrents = self._matching_music_torrents(
|
|
||||||
await self._SearchChain__async_search_all_sites(
|
|
||||||
keyword=search_word,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
sites=sites,
|
|
||||||
mtype=MediaType.MUSIC,
|
|
||||||
),
|
|
||||||
mediainfo,
|
|
||||||
)
|
|
||||||
torrents.extend(matched_torrents)
|
|
||||||
if matched_torrents and not self.runtime_config.search_multiple_name:
|
|
||||||
break
|
|
||||||
return cast(
|
|
||||||
List[Context],
|
|
||||||
await run_in_threadpool(
|
|
||||||
self._build_music_contexts,
|
|
||||||
torrents=torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
filter_params=filter_params,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_process_music_stream(
|
async def _async_process_music_stream(
|
||||||
self,
|
self, mediainfo: MusicInfo, keyword: Optional[str] = None, sites: Optional[List[int]] = None,
|
||||||
mediainfo: MusicInfo,
|
rule_groups: Optional[List[str]] = None, filter_params: Optional[Dict[str, str]] = None,
|
||||||
keyword: Optional[str] = None,
|
include_candidates: bool = False,
|
||||||
sites: Optional[List[int]] = None,
|
|
||||||
rule_groups: Optional[List[str]] = None,
|
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
|
||||||
) -> AsyncIterator[Dict[str, Any]]:
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""
|
"""兼容音乐 SSE 入口,复用相同的候选预览、过滤、统计及完成事件。"""
|
||||||
按音乐元数据渐进式搜索资源,逐站点输出进度并在结束时返回过滤后的完整结果。
|
async for event in self.async_process_stream(
|
||||||
|
mediainfo=mediainfo, keyword=keyword, sites=sites, rule_groups=rule_groups,
|
||||||
音乐候选需要同时匹配名称、艺术家和音乐分类,因此站点批次只负责推进搜索进度,
|
filter_params=filter_params, include_candidates=include_candidates,
|
||||||
最终结果仍统一交给音乐上下文构造逻辑过滤、排序和去重。
|
|
||||||
"""
|
|
||||||
keywords = [keyword] if keyword else type(self).music_site_keywords(mediainfo)
|
|
||||||
torrents: List[TorrentInfo] = []
|
|
||||||
for index, search_word in enumerate(keywords or [mediainfo.title]):
|
|
||||||
if index:
|
|
||||||
await asyncio.sleep(random.randint(1, 10))
|
|
||||||
keyword_matched = False
|
|
||||||
async for event in self._SearchChain__async_search_all_sites_stream(
|
|
||||||
keyword=search_word, mediainfo=mediainfo, sites=sites, mtype=MediaType.MUSIC
|
|
||||||
):
|
):
|
||||||
result = event.pop("items", []) or []
|
yield event
|
||||||
matched_torrents = self._matching_music_torrents(result, mediainfo)
|
|
||||||
if matched_torrents:
|
|
||||||
keyword_matched = True
|
|
||||||
torrents.extend(matched_torrents)
|
|
||||||
yield {
|
|
||||||
**event,
|
|
||||||
"type": "append",
|
|
||||||
"items": [],
|
|
||||||
"total_items": len(torrents),
|
|
||||||
}
|
|
||||||
if keyword_matched and not self.runtime_config.search_multiple_name:
|
|
||||||
break
|
|
||||||
|
|
||||||
contexts = await run_in_threadpool(
|
|
||||||
self._build_music_contexts,
|
|
||||||
torrents=torrents,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
rule_groups=rule_groups,
|
|
||||||
filter_params=filter_params,
|
|
||||||
)
|
|
||||||
items = [context.to_dict() for context in contexts]
|
|
||||||
yield {
|
|
||||||
"type": "replace",
|
|
||||||
"stage": "filtered",
|
|
||||||
"value": 100,
|
|
||||||
"text": f"过滤匹配完成,共 {len(contexts)} 个资源",
|
|
||||||
"items": items,
|
|
||||||
"total_items": len(contexts),
|
|
||||||
"candidate_items": len(torrents),
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
"type": "done",
|
|
||||||
"stage": "done",
|
|
||||||
"text": f"搜索完成,共 {len(contexts)} 个资源",
|
|
||||||
"items": items,
|
|
||||||
"total_items": len(contexts),
|
|
||||||
"candidate_items": len(torrents),
|
|
||||||
"contexts": contexts,
|
|
||||||
}
|
|
||||||
|
|||||||
+30
-10
@@ -1,13 +1,15 @@
|
|||||||
"""搜索身份、关键词和缺集计划 owner。"""
|
"""搜索身份、关键词和缺集计划 owner。"""
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||||
|
|
||||||
from app.application.configuration import (
|
from app.application.configuration import (
|
||||||
get_chain_runtime_config_snapshot,
|
get_chain_runtime_config_snapshot,
|
||||||
)
|
)
|
||||||
from app.chain.search.contract import _SearchOwnerBase
|
from app.chain.search.contract import _SearchOwnerBase
|
||||||
from app.domain.context import MediaInfo
|
from app.chain.search.music import SearchMusicOwner
|
||||||
|
from app.domain.context import MediaInfo, MusicInfo
|
||||||
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.schemas.media import build_media_key, resolve_media_identity
|
from app.schemas.media import build_media_key, resolve_media_identity
|
||||||
from app.schemas.mediaserver import NotExistMediaInfo
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
@@ -19,6 +21,12 @@ SeasonEpisodes = Dict[int, List[int]]
|
|||||||
RecognitionArgs = Dict[str, Optional[Union[MediaSource, str]]]
|
RecognitionArgs = Dict[str, Optional[Union[MediaSource, str]]]
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_search_names(keywords: List[str], explicit_keyword: Optional[str]) -> List[str]:
|
||||||
|
"""统一遵守用户设置的名称查询上限,显式关键词不受名称展开策略影响。"""
|
||||||
|
max_names = get_chain_runtime_config_snapshot().max_search_name_limit
|
||||||
|
return keywords[:max_names] if max_names and not explicit_keyword else keywords
|
||||||
|
|
||||||
|
|
||||||
class SearchPlanOwner(_SearchOwnerBase):
|
class SearchPlanOwner(_SearchOwnerBase):
|
||||||
"""搜索身份、关键词和缺集计划 owner。"""
|
"""搜索身份、关键词和缺集计划 owner。"""
|
||||||
|
|
||||||
@@ -40,19 +48,36 @@ class SearchPlanOwner(_SearchOwnerBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _copy_media_input(mediainfo: MediaInfo) -> MediaInfo:
|
def _copy_media_input(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo:
|
||||||
"""复制调用方媒体快照,避免搜索归一化污染共享领域对象。"""
|
"""复制调用方媒体快照,避免搜索归一化污染共享领域对象。"""
|
||||||
return deepcopy(mediainfo)
|
return deepcopy(mediainfo)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_media_input(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo:
|
||||||
|
"""只规范化影视输入的标题和季号,音乐保留其实体字段及原始名称。"""
|
||||||
|
if not isinstance(mediainfo, MusicInfo) and not mediainfo.tmdb_id:
|
||||||
|
meta = MetaInfo(title=mediainfo.title)
|
||||||
|
mediainfo.title = meta.name
|
||||||
|
mediainfo.season = cast(int, meta.begin_season)
|
||||||
|
return mediainfo
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _needs_media_details(mediainfo: MediaInfo | MusicInfo) -> bool:
|
||||||
|
"""音乐已有主名称即可匹配,影视别名为空时仍保留既有详情补全策略。"""
|
||||||
|
return not mediainfo.names and not (isinstance(mediainfo, MusicInfo) and mediainfo.title)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _prepare_params(
|
def _prepare_params(
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
no_exists: Optional[MissingMediaMap] = None,
|
no_exists: Optional[MissingMediaMap] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
) -> Tuple[Optional[SeasonEpisodes], List[str]]:
|
) -> Tuple[Optional[SeasonEpisodes], List[str]]:
|
||||||
"""
|
"""
|
||||||
准备搜索参数
|
准备搜索参数
|
||||||
"""
|
"""
|
||||||
|
if isinstance(mediainfo, MusicInfo):
|
||||||
|
return None, _limit_search_names(SearchMusicOwner._music_keywords(mediainfo, keyword, include_candidates), keyword)
|
||||||
# 缺失的季集
|
# 缺失的季集
|
||||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||||
mediakey = build_media_key(media_source, media_id)
|
mediakey = build_media_key(media_source, media_id)
|
||||||
@@ -87,9 +112,4 @@ class SearchPlanOwner(_SearchOwnerBase):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# 限制搜索关键词数量
|
return season_episodes, _limit_search_names(keywords, keyword)
|
||||||
max_names = get_chain_runtime_config_snapshot().max_search_name_limit
|
|
||||||
if max_names:
|
|
||||||
keywords = keywords[:max_names]
|
|
||||||
|
|
||||||
return season_episodes, keywords
|
|
||||||
|
|||||||
+71
-19
@@ -1,15 +1,19 @@
|
|||||||
"""资源过滤、匹配、投影与去重 owner。"""
|
"""资源过滤、匹配、投影与去重 owner。"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Dict, List, Optional, Tuple, cast
|
from typing import Callable, Dict, List, Optional, Tuple, cast
|
||||||
|
|
||||||
from app.application.configuration import get_configured_system_config
|
from app.application.configuration import get_configured_system_config
|
||||||
from app.application.torrent.download import TorrentHelper
|
from app.application.torrent.download import TorrentHelper
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search.contract import _SearchOwnerBase
|
from app.chain.search.contract import _SearchOwnerBase
|
||||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
|
from app.domain.music import MusicMatch, match_music_resource
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.runtime.progress import ProgressHelper
|
from app.runtime.progress import ProgressHelper
|
||||||
from app.runtime.stop import runtime_stop_state
|
from app.runtime.stop import runtime_stop_state
|
||||||
@@ -17,10 +21,19 @@ from app.schemas.media import resolve_media_identity
|
|||||||
from app.schemas.types import MediaSource, ProgressKey, SystemConfigKey
|
from app.schemas.types import MediaSource, ProgressKey, SystemConfigKey
|
||||||
|
|
||||||
SiteKey = Tuple[Optional[int], Optional[str]]
|
SiteKey = Tuple[Optional[int], Optional[str]]
|
||||||
MatchedTorrent = Tuple[TorrentInfo, MetaBase, str]
|
|
||||||
DisambiguationCache = Dict[Tuple[str, str, str], Optional[MediaInfo]]
|
DisambiguationCache = Dict[Tuple[str, str, str], Optional[MediaInfo]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MatchedTorrent:
|
||||||
|
"""保存资源解析证据和身份匹配结果,不从目标媒体反向生成元数据。"""
|
||||||
|
|
||||||
|
torrent: TorrentInfo
|
||||||
|
meta: MetaBase
|
||||||
|
source: str
|
||||||
|
music_match: Optional[MusicMatch] = None
|
||||||
|
|
||||||
|
|
||||||
def _site_torrents(torrents: List[TorrentInfo]) -> Dict[SiteKey, List[TorrentInfo]]:
|
def _site_torrents(torrents: List[TorrentInfo]) -> Dict[SiteKey, List[TorrentInfo]]:
|
||||||
"""按站点归集资源,并保留站点及资源的首次出现顺序。"""
|
"""按站点归集资源,并保留站点及资源的首次出现顺序。"""
|
||||||
grouped: Dict[SiteKey, List[TorrentInfo]] = {}
|
grouped: Dict[SiteKey, List[TorrentInfo]] = {}
|
||||||
@@ -32,16 +45,19 @@ def _site_torrents(torrents: List[TorrentInfo]) -> Dict[SiteKey, List[TorrentInf
|
|||||||
def _filter_site_torrents(
|
def _filter_site_torrents(
|
||||||
owner: _SearchOwnerBase,
|
owner: _SearchOwnerBase,
|
||||||
torrents: List[TorrentInfo],
|
torrents: List[TorrentInfo],
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
rule_groups: List[str],
|
rule_groups: List[str],
|
||||||
filter_params: Dict[str, str],
|
filter_params: Dict[str, str],
|
||||||
|
diagnostics: Counter[str],
|
||||||
) -> List[TorrentInfo]:
|
) -> List[TorrentInfo]:
|
||||||
"""按一个站点执行附加参数和优先级规则过滤。"""
|
"""按一个站点执行附加参数和优先级规则过滤。"""
|
||||||
filtered = torrents
|
filtered = torrents
|
||||||
if filter_params:
|
if filter_params:
|
||||||
helper = cast(Callable[[], TorrentHelper], TorrentHelper)()
|
helper = cast(Callable[[], TorrentHelper], TorrentHelper)()
|
||||||
filtered = [torrent for torrent in filtered if helper.filter_torrent(torrent, filter_params)]
|
filtered = [torrent for torrent in filtered if helper.filter_torrent(torrent, filter_params)]
|
||||||
|
diagnostics["filter_params"] += len(torrents) - len(filtered)
|
||||||
if rule_groups and filtered:
|
if rule_groups and filtered:
|
||||||
|
count = len(filtered)
|
||||||
filtered = (
|
filtered = (
|
||||||
owner.filter_torrents(
|
owner.filter_torrents(
|
||||||
rule_groups=rule_groups,
|
rule_groups=rule_groups,
|
||||||
@@ -50,16 +66,18 @@ def _filter_site_torrents(
|
|||||||
)
|
)
|
||||||
or []
|
or []
|
||||||
)
|
)
|
||||||
|
diagnostics["filter_rules"] += count - len(filtered)
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def _filter_torrents(
|
def _filter_torrents(
|
||||||
owner: _SearchOwnerBase,
|
owner: _SearchOwnerBase,
|
||||||
torrents: List[TorrentInfo],
|
torrents: List[TorrentInfo],
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
rule_groups: List[str],
|
rule_groups: List[str],
|
||||||
filter_params: Dict[str, str],
|
filter_params: Dict[str, str],
|
||||||
progress: ProgressHelper,
|
progress: ProgressHelper,
|
||||||
|
diagnostics: Counter[str],
|
||||||
) -> List[TorrentInfo]:
|
) -> List[TorrentInfo]:
|
||||||
"""逐站点过滤资源,避免在调用方工作线程内再创建线程池。"""
|
"""逐站点过滤资源,避免在调用方工作线程内再创建线程池。"""
|
||||||
if not filter_params and not rule_groups:
|
if not filter_params and not rule_groups:
|
||||||
@@ -77,6 +95,7 @@ def _filter_torrents(
|
|||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
rule_groups=rule_groups,
|
rule_groups=rule_groups,
|
||||||
filter_params=filter_params,
|
filter_params=filter_params,
|
||||||
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
progress.update(
|
progress.update(
|
||||||
@@ -86,12 +105,13 @@ def _filter_torrents(
|
|||||||
return [torrent for torrent in torrents if id(torrent) in retained_ids]
|
return [torrent for torrent in torrents if id(torrent) in retained_ids]
|
||||||
|
|
||||||
|
|
||||||
def _torrent_meta(torrent: TorrentInfo, custom_words: List[str]) -> MetaBase:
|
def _torrent_meta(torrent: TorrentInfo, custom_words: List[str], mediainfo: MediaInfo | MusicInfo) -> MetaBase:
|
||||||
"""解析一条资源的元数据,并记录识别词改写结果。"""
|
"""解析一条资源的元数据,并记录识别词改写结果。"""
|
||||||
meta = MetaInfo(
|
meta = MetaInfo(
|
||||||
title=torrent.title,
|
title=torrent.title,
|
||||||
subtitle=torrent.description,
|
subtitle=torrent.description,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
|
mtype=mediainfo.type,
|
||||||
)
|
)
|
||||||
if torrent.title != meta.org_string:
|
if torrent.title != meta.org_string:
|
||||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}")
|
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}")
|
||||||
@@ -160,13 +180,15 @@ def _match_source(
|
|||||||
|
|
||||||
def _match_torrents(
|
def _match_torrents(
|
||||||
torrents: List[TorrentInfo],
|
torrents: List[TorrentInfo],
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
season_episodes: Dict[int, List[int]],
|
season_episodes: Dict[int, List[int]],
|
||||||
custom_words: List[str],
|
custom_words: List[str],
|
||||||
progress: ProgressHelper,
|
progress: ProgressHelper,
|
||||||
|
include_candidates: bool,
|
||||||
|
diagnostics: Counter[str],
|
||||||
) -> List[MatchedTorrent]:
|
) -> List[MatchedTorrent]:
|
||||||
"""按输入顺序匹配资源,并复用同名候选的识别结果。"""
|
"""按输入顺序匹配资源,并复用同名候选的识别结果。"""
|
||||||
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
logger.info(f"开始匹配结果 类型:{mediainfo.type.value},标题:{mediainfo.title},别名:{mediainfo.names}")
|
||||||
progress.update(value=51, text=f"开始匹配,总 {len(torrents)} 个资源 ...")
|
progress.update(value=51, text=f"开始匹配,总 {len(torrents)} 个资源 ...")
|
||||||
matches: List[MatchedTorrent] = []
|
matches: List[MatchedTorrent] = []
|
||||||
cache: DisambiguationCache = {}
|
cache: DisambiguationCache = {}
|
||||||
@@ -179,13 +201,26 @@ def _match_torrents(
|
|||||||
text=f"正在匹配 {torrent.site_name},已完成 {count} / {total} ...",
|
text=f"正在匹配 {torrent.site_name},已完成 {count} / {total} ...",
|
||||||
)
|
)
|
||||||
if not torrent.title:
|
if not torrent.title:
|
||||||
|
diagnostics["title_missing"] += 1
|
||||||
continue
|
continue
|
||||||
meta = _torrent_meta(torrent=torrent, custom_words=custom_words)
|
meta = _torrent_meta(torrent=torrent, custom_words=custom_words, mediainfo=mediainfo)
|
||||||
if season_episodes and not TorrentHelper.match_season_episodes(
|
if season_episodes and not TorrentHelper.match_season_episodes(
|
||||||
torrent=torrent,
|
torrent=torrent,
|
||||||
meta=meta,
|
meta=meta,
|
||||||
season_episodes=season_episodes,
|
season_episodes=season_episodes,
|
||||||
):
|
):
|
||||||
|
diagnostics["scope_mismatch"] += 1
|
||||||
|
continue
|
||||||
|
if isinstance(mediainfo, MusicInfo):
|
||||||
|
match = match_music_resource(
|
||||||
|
mediainfo, torrent.title, torrent.description, torrent.category,
|
||||||
|
meta=cast(MetaMusic, meta),
|
||||||
|
)
|
||||||
|
diagnostics[match.reason] += 1
|
||||||
|
if match.status == "exact" or (include_candidates and match.status != "rejected"):
|
||||||
|
matches.append(MatchedTorrent(torrent, meta, "title", match))
|
||||||
|
else:
|
||||||
|
logger.debug(f"音乐资源 {torrent.site_name} - {torrent.title} 未通过匹配:{match.reason}")
|
||||||
continue
|
continue
|
||||||
source = _match_source(
|
source = _match_source(
|
||||||
torrent=torrent,
|
torrent=torrent,
|
||||||
@@ -194,33 +229,38 @@ def _match_torrents(
|
|||||||
cache=cache,
|
cache=cache,
|
||||||
)
|
)
|
||||||
if source:
|
if source:
|
||||||
matches.append((torrent, meta, source))
|
diagnostics["matched"] += 1
|
||||||
|
matches.append(MatchedTorrent(torrent, meta, source))
|
||||||
|
else:
|
||||||
|
diagnostics["identity_mismatch"] += 1
|
||||||
logger.info(f"匹配完成,共匹配到 {len(matches)} 个资源")
|
logger.info(f"匹配完成,共匹配到 {len(matches)} 个资源")
|
||||||
progress.update(value=97, text=f"匹配完成,共匹配到 {len(matches)} 个资源")
|
progress.update(value=97, text=f"匹配完成,共匹配到 {len(matches)} 个资源")
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
def _context_media(mediainfo: MediaInfo) -> MediaInfo:
|
def _context_media(mediainfo: MediaInfo | MusicInfo) -> MediaInfo | MusicInfo:
|
||||||
"""复制并裁剪上下文媒体信息,避免修改调用方持有的目标对象。"""
|
"""复制并裁剪上下文媒体信息,避免修改调用方持有的目标对象。"""
|
||||||
context_media = copy.copy(mediainfo)
|
context_media = copy.copy(mediainfo)
|
||||||
context_media.clear()
|
context_media.clear()
|
||||||
return context_media
|
return context_media
|
||||||
|
|
||||||
|
|
||||||
def _build_contexts(matches: List[MatchedTorrent], mediainfo: MediaInfo) -> List[Context]:
|
def _build_contexts(matches: List[MatchedTorrent], mediainfo: MediaInfo | MusicInfo) -> List[Context]:
|
||||||
"""将匹配结果投影为搜索上下文。"""
|
"""将匹配结果投影为搜索上下文。"""
|
||||||
context_media = _context_media(mediainfo)
|
context_media = _context_media(mediainfo)
|
||||||
return [
|
return [
|
||||||
Context(
|
Context(
|
||||||
torrent_info=torrent,
|
torrent_info=match.torrent,
|
||||||
media_info=context_media,
|
media_info=context_media if match.music_match is None or match.music_match.status == "exact" else None,
|
||||||
meta_info=meta,
|
meta_info=match.meta,
|
||||||
resource_source="search",
|
resource_source="search",
|
||||||
match_source=source,
|
match_source=match.source,
|
||||||
candidate_recognized=False,
|
candidate_recognized=False,
|
||||||
media_info_is_target=True,
|
media_info_is_target=match.music_match is None or match.music_match.status == "exact",
|
||||||
|
match_status="exact" if match.music_match is None or match.music_match.status == "exact" else "candidate",
|
||||||
|
match_reason=match.music_match.reason if match.music_match else "matched",
|
||||||
)
|
)
|
||||||
for torrent, meta, source in matches
|
for match in matches
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -230,19 +270,23 @@ class SearchResultOwner(_SearchOwnerBase):
|
|||||||
def _parse_result(
|
def _parse_result(
|
||||||
self,
|
self,
|
||||||
torrents: List[TorrentInfo],
|
torrents: List[TorrentInfo],
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo | MusicInfo,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
rule_groups: Optional[List[str]] = None,
|
rule_groups: Optional[List[str]] = None,
|
||||||
season_episodes: Optional[Dict[int, List[int]]] = None,
|
season_episodes: Optional[Dict[int, List[int]]] = None,
|
||||||
custom_words: Optional[List[str]] = None,
|
custom_words: Optional[List[str]] = None,
|
||||||
filter_params: Optional[Dict[str, str]] = None,
|
filter_params: Optional[Dict[str, str]] = None,
|
||||||
|
include_candidates: bool = False,
|
||||||
|
diagnostics: Optional[Counter[str]] = None,
|
||||||
|
candidate_filter: Optional[Callable[[List[Context]], List[Context]]] = None,
|
||||||
) -> List[Context]:
|
) -> List[Context]:
|
||||||
"""过滤并匹配搜索结果,不修改调用方持有的媒体信息和资源容器。"""
|
"""过滤并匹配搜索结果,不修改调用方持有的媒体信息和资源容器。"""
|
||||||
if not torrents:
|
if not torrents:
|
||||||
logger.warning(f"{keyword or mediainfo.title} 未搜索到资源")
|
logger.warning(f"{keyword or mediainfo.title} 未搜索到资源")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
source_torrents = list(torrents)
|
source_torrents = [copy.copy(torrent) for torrent in torrents]
|
||||||
|
counts = diagnostics if diagnostics is not None else Counter()
|
||||||
effective_rules = rule_groups
|
effective_rules = rule_groups
|
||||||
if effective_rules is None:
|
if effective_rules is None:
|
||||||
effective_rules = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups)
|
effective_rules = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups)
|
||||||
@@ -262,6 +306,7 @@ class SearchResultOwner(_SearchOwnerBase):
|
|||||||
rule_groups=effective_rules or [],
|
rule_groups=effective_rules or [],
|
||||||
filter_params=filter_params or {},
|
filter_params=filter_params or {},
|
||||||
progress=progress,
|
progress=progress,
|
||||||
|
diagnostics=counts,
|
||||||
)
|
)
|
||||||
if effective_rules and not filtered:
|
if effective_rules and not filtered:
|
||||||
logger.warning(f"{keyword or mediainfo.title} 没有符合过滤规则的资源")
|
logger.warning(f"{keyword or mediainfo.title} 没有符合过滤规则的资源")
|
||||||
@@ -276,10 +321,17 @@ class SearchResultOwner(_SearchOwnerBase):
|
|||||||
season_episodes=season_episodes or {},
|
season_episodes=season_episodes or {},
|
||||||
custom_words=custom_words or [],
|
custom_words=custom_words or [],
|
||||||
progress=progress,
|
progress=progress,
|
||||||
|
include_candidates=include_candidates,
|
||||||
|
diagnostics=counts,
|
||||||
)
|
)
|
||||||
contexts = _build_contexts(matches=matches, mediainfo=mediainfo)
|
contexts = _build_contexts(matches=matches, mediainfo=mediainfo)
|
||||||
|
if candidate_filter is not None:
|
||||||
|
before_filter = len(contexts)
|
||||||
|
contexts = candidate_filter(contexts)
|
||||||
|
counts["caller_filter"] += before_filter - len(contexts)
|
||||||
progress.update(value=99, text=f"正在对 {len(contexts)} 个资源进行排序,请稍候...")
|
progress.update(value=99, text=f"正在对 {len(contexts)} 个资源进行排序,请稍候...")
|
||||||
contexts = TorrentHelper.sort_torrents(contexts)
|
contexts = TorrentHelper.sort_torrents(contexts)
|
||||||
|
contexts.sort(key=lambda context: not context.media_info_is_target)
|
||||||
contexts = self._remove_duplicate(contexts)
|
contexts = self._remove_duplicate(contexts)
|
||||||
logger.info(f"搜索完成,共 {len(contexts)} 个资源")
|
logger.info(f"搜索完成,共 {len(contexts)} 个资源")
|
||||||
progress.update(value=100, text=f"搜索完成,共 {len(contexts)} 个资源")
|
progress.update(value=100, text=f"搜索完成,共 {len(contexts)} 个资源")
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from app.application.configuration import (
|
|||||||
)
|
)
|
||||||
from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase
|
from app.chain.search.contract import _SearchOwnerBase as _SearchOwnerBase
|
||||||
from app.domain.context import Context, TorrentInfo
|
from app.domain.context import Context, TorrentInfo
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.runtime.execution import run_in_threadpool
|
from app.runtime.execution import run_in_threadpool
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
@@ -415,14 +414,7 @@ class SearchTitleOwner(_SearchOwnerBase):
|
|||||||
mtype: Optional[MediaType],
|
mtype: Optional[MediaType],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""根据限定媒体类型构造模糊搜索结果的上下文元数据。"""
|
"""根据限定媒体类型构造模糊搜索结果的上下文元数据。"""
|
||||||
if mtype == MediaType.MUSIC:
|
return MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=mtype)
|
||||||
meta = MetaMusic(
|
|
||||||
org_string=torrent.title,
|
|
||||||
title=torrent.title,
|
|
||||||
)
|
|
||||||
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}")
|
|
||||||
return meta
|
|
||||||
return MetaInfo(title=torrent.title, subtitle=torrent.description)
|
|
||||||
|
|
||||||
def _filter_title_search_torrents(
|
def _filter_title_search_torrents(
|
||||||
self, torrents: List[TorrentInfo], rule_groups: Optional[List[str]] = None
|
self, torrents: List[TorrentInfo], rule_groups: Optional[List[str]] = None
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ if TYPE_CHECKING:
|
|||||||
"""异步发送订阅通知。"""
|
"""异步发送订阅通知。"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
check_and_handle_existing_media: Callable[..., Any]
|
check_and_handle_existing_media: Callable[..., Any]
|
||||||
|
_prepare_music_subscribe: Callable[..., Any]
|
||||||
|
_download_music_subscribe: Callable[..., Any]
|
||||||
check_and_reconcile: Callable[..., Any]
|
check_and_reconcile: Callable[..., Any]
|
||||||
filter_torrents: Callable[..., Any]
|
filter_torrents: Callable[..., Any]
|
||||||
finish_subscribe_or_not: Callable[..., Any]
|
finish_subscribe_or_not: Callable[..., Any]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""订阅单条元数据刷新与完成对账协作者。"""
|
"""订阅单条元数据刷新与完成对账协作者。"""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional, TypeVar, cast
|
from typing import Any, Optional, TypeVar, cast
|
||||||
|
|
||||||
from app.application.classification.reference import (
|
from app.application.classification.reference import (
|
||||||
@@ -16,13 +17,57 @@ from app.chain.media import MediaChain
|
|||||||
from app.chain.subscribe.contract import _SubscribeOwnerBase
|
from app.chain.subscribe.contract import _SubscribeOwnerBase
|
||||||
from app.chain.subscribe.identity import subscribe_recognize_kwargs
|
from app.chain.subscribe.identity import subscribe_recognize_kwargs
|
||||||
from app.domain.context import MediaInfo, MusicInfo
|
from app.domain.context import MediaInfo, MusicInfo
|
||||||
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.media import resolve_media_identity
|
from app.schemas.media import resolve_media_identity
|
||||||
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||||
|
|
||||||
SubscriptionMediaT = TypeVar("SubscriptionMediaT", MediaInfo, MusicInfo)
|
SubscriptionMediaT = TypeVar("SubscriptionMediaT", MediaInfo, MusicInfo)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SubscriptionSearchTarget:
|
||||||
|
"""所有媒体订阅进入同一搜索编排前的目标和缺失范围快照。"""
|
||||||
|
|
||||||
|
subscribe: SubscriptionSnapshot
|
||||||
|
meta: MetaBase
|
||||||
|
media: MediaInfo | MusicInfo
|
||||||
|
missing: dict[str, dict[int, NotExistMediaInfo]]
|
||||||
|
media_key: Optional[str | int]
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_search_target(owner: _SubscribeOwnerBase, subscribe: SubscriptionSnapshot,
|
||||||
|
media_chain: MediaChain, ensure_active: Callable[[], None]) -> Optional[SubscriptionSearchTarget]:
|
||||||
|
"""在准备阶段保留实体差异,之后统一复用搜索、预算和交付流程。"""
|
||||||
|
if subscribe.type == MediaType.MUSIC.value:
|
||||||
|
target = owner._prepare_music_subscribe(subscribe)
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
current, media, meta = target
|
||||||
|
ensure_active()
|
||||||
|
return SubscriptionSearchTarget(current, meta, media, {}, subscribe_media_key(current))
|
||||||
|
try:
|
||||||
|
meta = build_subscribe_meta(subscribe)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"订阅《{subscribe.name}》的媒体类型不受支持,暂时无法搜索")
|
||||||
|
return None
|
||||||
|
media = media_chain.recognize_media(
|
||||||
|
meta=meta, mtype=meta.type, **subscribe_recognize_kwargs(subscribe),
|
||||||
|
episode_group=subscribe.episode_group, cache=False,
|
||||||
|
)
|
||||||
|
if not media:
|
||||||
|
logger.warning(f"未识别到媒体信息,标题:{subscribe.name},媒体来源:{subscribe.media_source},媒体 ID:{subscribe.media_id}")
|
||||||
|
return None
|
||||||
|
ensure_active()
|
||||||
|
media = apply_subscription_classification(media, subscribe)
|
||||||
|
key = subscribe_media_key(subscribe)
|
||||||
|
exists, missing = owner.check_and_handle_existing_media(
|
||||||
|
subscribe=subscribe, meta=meta, mediainfo=media, mediakey=key,
|
||||||
|
)
|
||||||
|
return None if exists else SubscriptionSearchTarget(subscribe, meta, media, missing, key)
|
||||||
|
|
||||||
|
|
||||||
def apply_subscription_classification(
|
def apply_subscription_classification(
|
||||||
media: SubscriptionMediaT,
|
media: SubscriptionMediaT,
|
||||||
subscribe: SubscriptionSnapshot,
|
subscribe: SubscriptionSnapshot,
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from app.application.configuration import get_configured_system_config
|
|||||||
from app.application.subscription.contract import (
|
from app.application.subscription.contract import (
|
||||||
SubscriptionRepository,
|
SubscriptionRepository,
|
||||||
SubscriptionSnapshot,
|
SubscriptionSnapshot,
|
||||||
build_subscribe_meta,
|
|
||||||
subscribe_media_key,
|
|
||||||
)
|
)
|
||||||
from app.application.subscription.execution import (
|
from app.application.subscription.execution import (
|
||||||
SearchBatchSnapshot,
|
SearchBatchSnapshot,
|
||||||
@@ -38,14 +36,14 @@ from app.application.subscription.sitebudget import (
|
|||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search.facade import SearchChain
|
from app.chain.search.facade import SearchChain
|
||||||
from app.chain.subscribe.contract import _SubscribeOwnerBase
|
from app.chain.subscribe.contract import _SubscribeOwnerBase
|
||||||
from app.chain.subscribe.identity import subscribe_recognize_kwargs
|
from app.chain.subscribe.metadata import apply_subscription_classification, prepare_search_target
|
||||||
from app.chain.subscribe.metadata import apply_subscription_classification
|
|
||||||
from app.chain.subscribe.searchtask import (
|
from app.chain.subscribe.searchtask import (
|
||||||
SubscriptionSearchTaskRunner,
|
SubscriptionSearchTaskRunner,
|
||||||
retry_at_after,
|
retry_at_after,
|
||||||
)
|
)
|
||||||
from app.domain.context import (
|
from app.domain.context import (
|
||||||
MediaInfo,
|
MediaInfo,
|
||||||
|
MusicInfo,
|
||||||
)
|
)
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
@@ -679,44 +677,21 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
|
|||||||
def _process_search_subscription(
|
def _process_search_subscription(
|
||||||
self,
|
self,
|
||||||
subscribe: SubscriptionSnapshot,
|
subscribe: SubscriptionSnapshot,
|
||||||
searchchain: SearchChain,
|
searchchain: Optional[SearchChain],
|
||||||
execution_context: Optional[SubscriptionExecutionContext] = None,
|
execution_context: Optional[SubscriptionExecutionContext] = None,
|
||||||
) -> Optional[SubscriptionSnapshot]:
|
) -> Optional[SubscriptionSnapshot]:
|
||||||
"""处理单个订阅,并返回下载后重新读取的状态快照。"""
|
"""处理单个订阅,并返回下载后重新读取的状态快照。"""
|
||||||
_ensure_execution_active(execution_context)
|
_ensure_execution_active(execution_context)
|
||||||
logger.debug(f"开始搜索订阅,标题:{subscribe.name} ...")
|
logger.debug(f"开始搜索订阅,标题:{subscribe.name} ...")
|
||||||
if subscribe.type == MediaType.MUSIC.value:
|
target = prepare_search_target(
|
||||||
self._search_music_subscribe(subscribe, execution_context=execution_context)
|
self, subscribe, MediaChain(), partial(_ensure_execution_active, execution_context),
|
||||||
return subscribe
|
|
||||||
try:
|
|
||||||
meta = build_subscribe_meta(subscribe)
|
|
||||||
except ValueError:
|
|
||||||
logger.error(f"订阅《{subscribe.name}》的媒体类型不受支持,暂时无法搜索")
|
|
||||||
return subscribe
|
|
||||||
mediainfo: MediaInfo = MediaChain().recognize_media(
|
|
||||||
meta=meta,
|
|
||||||
mtype=meta.type,
|
|
||||||
**subscribe_recognize_kwargs(subscribe),
|
|
||||||
episode_group=subscribe.episode_group,
|
|
||||||
cache=False,
|
|
||||||
)
|
|
||||||
if not mediainfo:
|
|
||||||
logger.warning(
|
|
||||||
f"未识别到媒体信息,标题:{subscribe.name},"
|
|
||||||
f"媒体来源:{subscribe.media_source},媒体 ID:{subscribe.media_id}"
|
|
||||||
)
|
)
|
||||||
|
if target is None:
|
||||||
return subscribe
|
return subscribe
|
||||||
_ensure_execution_active(execution_context)
|
subscribe, meta, mediainfo = target.subscribe, target.meta, target.media
|
||||||
mediainfo = apply_subscription_classification(mediainfo, subscribe)
|
mediakey, no_exists = target.media_key, target.missing
|
||||||
mediakey = subscribe_media_key(subscribe)
|
if searchchain is None:
|
||||||
exists, no_exists = self.check_and_handle_existing_media(
|
searchchain = SearchChain()
|
||||||
subscribe=subscribe,
|
|
||||||
meta=meta,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
mediakey=mediakey,
|
|
||||||
)
|
|
||||||
if exists:
|
|
||||||
return subscribe
|
|
||||||
rule_key = (
|
rule_key = (
|
||||||
SystemConfigKey.BestVersionFilterRuleGroups
|
SystemConfigKey.BestVersionFilterRuleGroups
|
||||||
if subscribe.best_version
|
if subscribe.best_version
|
||||||
@@ -733,6 +708,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
|
|||||||
area="imdbid" if subscribe.search_imdbid and mediainfo.imdb_id else "title",
|
area="imdbid" if subscribe.search_imdbid and mediainfo.imdb_id else "title",
|
||||||
custom_words=subscribe.custom_words.split("\n") if subscribe.custom_words else None,
|
custom_words=subscribe.custom_words.split("\n") if subscribe.custom_words else None,
|
||||||
filter_params=self.get_params(subscribe),
|
filter_params=self.get_params(subscribe),
|
||||||
|
candidate_filter=partial(self._filter_search_contexts, subscribe),
|
||||||
)
|
)
|
||||||
site_budget_failures = searchchain.consume_subscription_site_budget_failures(
|
site_budget_failures = searchchain.consume_subscription_site_budget_failures(
|
||||||
has_results=bool(contexts),
|
has_results=bool(contexts),
|
||||||
@@ -751,19 +727,16 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
|
|||||||
)
|
)
|
||||||
raise_subscription_site_budget_failures(site_budget_failures)
|
raise_subscription_site_budget_failures(site_budget_failures)
|
||||||
return subscribe
|
return subscribe
|
||||||
matched = self._filter_search_contexts(subscribe, contexts)
|
|
||||||
if not matched:
|
|
||||||
logger.debug(f"订阅 {subscribe.name} 没有符合过滤条件的资源")
|
|
||||||
if not site_budget_failures:
|
|
||||||
raise_subscription_site_budget_deferral(site_budget_deferrals, execution_context)
|
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, lefts=no_exists)
|
|
||||||
raise_subscription_site_budget_failures(site_budget_failures)
|
|
||||||
return subscribe
|
|
||||||
if execution_context:
|
if execution_context:
|
||||||
execution_context.report_phase("preparing")
|
execution_context.report_phase("preparing")
|
||||||
_ensure_execution_active(execution_context)
|
_ensure_execution_active(execution_context)
|
||||||
|
if isinstance(mediainfo, MusicInfo):
|
||||||
|
self._download_music_subscribe(subscribe, mediainfo, contexts, execution_context=execution_context)
|
||||||
|
raise_subscription_site_budget_failures(site_budget_failures)
|
||||||
|
raise_subscription_site_budget_deferral(site_budget_deferrals, execution_context)
|
||||||
|
return cast(Optional[SubscriptionSnapshot], self.subscription_repository.get(subscribe.id))
|
||||||
downloads, lefts = self._SubscribeChain__download_best_version_with_full_pack_first(
|
downloads, lefts = self._SubscribeChain__download_best_version_with_full_pack_first(
|
||||||
contexts=matched,
|
contexts=contexts,
|
||||||
no_exists=no_exists,
|
no_exists=no_exists,
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
mediakey=mediakey,
|
mediakey=mediakey,
|
||||||
@@ -800,6 +773,10 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
|
|||||||
torrent_meta = context.meta_info
|
torrent_meta = context.meta_info
|
||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
media = context.media_info
|
media = context.media_info
|
||||||
|
if getattr(context, "match_status", None) == "candidate" or media is None:
|
||||||
|
continue
|
||||||
|
if isinstance(media, MusicInfo) and subscribe.best_version:
|
||||||
|
torrent_info.pri_order = torrent_info.pri_order or torrent_meta.audio_quality_score
|
||||||
if subscribe.best_version and media.type == MediaType.TV:
|
if subscribe.best_version and media.type == MediaType.TV:
|
||||||
if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe):
|
if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe):
|
||||||
logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源")
|
logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Callable, Dict, List, Optional, Union
|
from typing import Callable, Dict, List, Optional, Union, cast
|
||||||
|
|
||||||
from app.application.configuration import get_configured_system_config
|
from app.application.configuration import get_configured_system_config
|
||||||
from app.application.rss import RssHelper
|
from app.application.rss import RssHelper
|
||||||
@@ -495,14 +495,8 @@ class TorrentsChain(ChainBase):
|
|||||||
meta: MetaBase
|
meta: MetaBase
|
||||||
mediainfo: MediaInfo | MusicInfo
|
mediainfo: MediaInfo | MusicInfo
|
||||||
if torrent.category == MediaType.MUSIC.value:
|
if torrent.category == MediaType.MUSIC.value:
|
||||||
meta = MetaMusic.parse_query(torrent.title)
|
meta = MetaInfo(title=torrent.title, subtitle=torrent.description, mtype=MediaType.MUSIC)
|
||||||
mediainfo = MusicInfo(
|
mediainfo = MusicInfo.from_meta(cast(MetaMusic, meta))
|
||||||
title=meta.title,
|
|
||||||
artists=list(meta.artists),
|
|
||||||
album=meta.album,
|
|
||||||
year=meta.year,
|
|
||||||
names=[meta.title] if meta.title else [],
|
|
||||||
)
|
|
||||||
candidate_recognized = False
|
candidate_recognized = False
|
||||||
match_source = "unknown"
|
match_source = "unknown"
|
||||||
else:
|
else:
|
||||||
|
|||||||
+30
-3
@@ -598,6 +598,10 @@ class MusicInfo:
|
|||||||
tags: list[str] = field(default_factory=list)
|
tags: list[str] = field(default_factory=list)
|
||||||
artist_country: str | None = None
|
artist_country: str | None = None
|
||||||
names: list[str] = field(default_factory=list)
|
names: list[str] = field(default_factory=list)
|
||||||
|
# 来源提供的同一实体别名及展示转换前的原文,不混入所属专辑或无关联艺术家。
|
||||||
|
title_aliases: list[str] = field(default_factory=list)
|
||||||
|
album_aliases: list[str] = field(default_factory=list)
|
||||||
|
artist_aliases: list[str] = field(default_factory=list)
|
||||||
detail_link: str | None = None
|
detail_link: str | None = None
|
||||||
listen_count: int | None = None
|
listen_count: int | None = None
|
||||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -605,6 +609,12 @@ class MusicInfo:
|
|||||||
# 显式声明以保留 getattr(..., False) 的默认值语义(__getattr__ 兜底会覆盖它)
|
# 显式声明以保留 getattr(..., False) 的默认值语义(__getattr__ 兜底会覆盖它)
|
||||||
recognize_cache_hit = False
|
recognize_cache_hit = False
|
||||||
|
|
||||||
|
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||||
|
"""恢复旧版本音乐缓存时补齐新增别名字段,避免列表字段被缺失属性兜底为 None。"""
|
||||||
|
self.__dict__.update(state)
|
||||||
|
for name in ("title_aliases", "album_aliases", "artist_aliases"):
|
||||||
|
self.__dict__.setdefault(name, [])
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""规范化媒体身份,并兼容拆分旧音乐分类字段。"""
|
"""规范化媒体身份,并兼容拆分旧音乐分类字段。"""
|
||||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||||
@@ -731,7 +741,7 @@ class MusicInfo:
|
|||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""清理不参与队列展示和持久化的上游原始响应。"""
|
"""清理不参与队列展示和持久化的上游原始响应。"""
|
||||||
self.raw_data.clear()
|
self.raw_data = {}
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""转换为统一媒体身份的 Context 外层字典。"""
|
"""转换为统一媒体身份的 Context 外层字典。"""
|
||||||
@@ -768,7 +778,7 @@ class MusicInfo:
|
|||||||
values["media_source"] = normalize_media_source(values.get("media_source"))
|
values["media_source"] = normalize_media_source(values.get("media_source"))
|
||||||
values["artists"] = _music_string_list(values.get("artists") or data.get("artist"))
|
values["artists"] = _music_string_list(values.get("artists") or data.get("artist"))
|
||||||
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
|
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
|
||||||
for key in ("secondary_types", "genres", "tags"):
|
for key in ("secondary_types", "genres", "tags", "title_aliases", "album_aliases", "artist_aliases"):
|
||||||
values[key] = _music_string_list(values.get(key))
|
values[key] = _music_string_list(values.get(key))
|
||||||
values["names"] = _music_string_list(values.get("names"))
|
values["names"] = _music_string_list(values.get("names"))
|
||||||
values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING)
|
values["music_type"] = str(values.get("music_type") or MUSIC_ENTITY_RECORDING)
|
||||||
@@ -885,6 +895,8 @@ class MusicAlbumInfo:
|
|||||||
title: str | None = None
|
title: str | None = None
|
||||||
artists: list[str] = field(default_factory=list)
|
artists: list[str] = field(default_factory=list)
|
||||||
artist_ids: list[str] = field(default_factory=list)
|
artist_ids: list[str] = field(default_factory=list)
|
||||||
|
title_aliases: list[str] = field(default_factory=list)
|
||||||
|
artist_aliases: list[str] = field(default_factory=list)
|
||||||
# 专辑主类型:Album、EP、Single、Broadcast、Other
|
# 专辑主类型:Album、EP、Single、Broadcast、Other
|
||||||
album_type: str | None = None
|
album_type: str | None = None
|
||||||
# 专辑副类型:Live、Compilation、Soundtrack、Remix 等
|
# 专辑副类型:Live、Compilation、Soundtrack、Remix 等
|
||||||
@@ -910,6 +922,12 @@ class MusicAlbumInfo:
|
|||||||
releases: list[MusicRelease] = field(default_factory=list)
|
releases: list[MusicRelease] = field(default_factory=list)
|
||||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||||
|
"""恢复旧版专辑缓存时补齐别名列表,保留既有媒体身份及曲目数据。"""
|
||||||
|
self.__dict__.update(state)
|
||||||
|
for name in ("title_aliases", "artist_aliases"):
|
||||||
|
self.__dict__.setdefault(name, [])
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""规范化媒体身份,并补全专辑描述分类和分类结果。"""
|
"""规范化媒体身份,并补全专辑描述分类和分类结果。"""
|
||||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||||
@@ -1024,7 +1042,7 @@ class MusicAlbumInfo:
|
|||||||
values = _music_init_values(cls, data)
|
values = _music_init_values(cls, data)
|
||||||
values["classification"] = _classification_result(values.get("classification"))
|
values["classification"] = _classification_result(values.get("classification"))
|
||||||
values["media_source"] = normalize_media_source(values.get("media_source"))
|
values["media_source"] = normalize_media_source(values.get("media_source"))
|
||||||
for key in ("artists", "secondary_types", "genres", "tags"):
|
for key in ("artists", "secondary_types", "genres", "tags", "title_aliases", "artist_aliases"):
|
||||||
values[key] = _music_string_list(values.get(key))
|
values[key] = _music_string_list(values.get(key))
|
||||||
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
|
values["artist_ids"] = _music_aligned_list(values.get("artist_ids"))
|
||||||
values["rating"] = _music_optional_float(values.get("rating"))
|
values["rating"] = _music_optional_float(values.get("rating"))
|
||||||
@@ -1073,6 +1091,9 @@ class MusicAlbumInfo:
|
|||||||
album=self.title,
|
album=self.title,
|
||||||
album_artist=self.artist or None,
|
album_artist=self.artist or None,
|
||||||
album_id=self.media_id,
|
album_id=self.media_id,
|
||||||
|
title_aliases=list(self.title_aliases),
|
||||||
|
album_aliases=list(self.title_aliases),
|
||||||
|
artist_aliases=list(self.artist_aliases),
|
||||||
album_type=self.album_type,
|
album_type=self.album_type,
|
||||||
secondary_types=list(self.secondary_types),
|
secondary_types=list(self.secondary_types),
|
||||||
year=self.year,
|
year=self.year,
|
||||||
@@ -1221,6 +1242,7 @@ class MusicArtistInfo:
|
|||||||
tags=list(self.tags),
|
tags=list(self.tags),
|
||||||
artist_country=self.country,
|
artist_country=self.country,
|
||||||
names=[name for name in [self.name, *self.aliases] if name],
|
names=[name for name in [self.name, *self.aliases] if name],
|
||||||
|
title_aliases=list(self.aliases),
|
||||||
detail_link=self.detail_link,
|
detail_link=self.detail_link,
|
||||||
raw_data=dict(self.raw_data),
|
raw_data=dict(self.raw_data),
|
||||||
)
|
)
|
||||||
@@ -1918,6 +1940,9 @@ class Context:
|
|||||||
candidate_recognized: bool = False
|
candidate_recognized: bool = False
|
||||||
# 当前 media_info 是否为目标媒体回填,而不是候选自身识别结果。
|
# 当前 media_info 是否为目标媒体回填,而不是候选自身识别结果。
|
||||||
media_info_is_target: bool = False
|
media_info_is_target: bool = False
|
||||||
|
# 音乐资源的匹配等级及原因;待确认项不得绑定目标媒体身份。
|
||||||
|
match_status: Optional[str] = None
|
||||||
|
match_reason: Optional[str] = None
|
||||||
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
||||||
allowed_episodes: Optional[Set[int]] = None
|
allowed_episodes: Optional[Set[int]] = None
|
||||||
# 下载链实际提交的剧集集合;None 表示尚未执行下载选择。
|
# 下载链实际提交的剧集集合;None 表示尚未执行下载选择。
|
||||||
@@ -1938,6 +1963,8 @@ class Context:
|
|||||||
"match_source": self.match_source,
|
"match_source": self.match_source,
|
||||||
"candidate_recognized": self.candidate_recognized,
|
"candidate_recognized": self.candidate_recognized,
|
||||||
"media_info_is_target": self.media_info_is_target,
|
"media_info_is_target": self.media_info_is_target,
|
||||||
|
"match_status": getattr(self, "match_status", None),
|
||||||
|
"match_reason": getattr(self, "match_reason", None),
|
||||||
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
||||||
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
|
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
|
||||||
"selected_episodes": self.selected_episodes,
|
"selected_episodes": self.selected_episodes,
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.schemas.types import MediaSource, MediaType
|
|
||||||
from app.schemas.media import resolve_media_identity
|
|
||||||
from app.domain.meta.runtime import get_metainfo_accelerator
|
from app.domain.meta.runtime import get_metainfo_accelerator
|
||||||
|
from app.schemas.media import resolve_media_identity
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
_AUDIO_FORMAT_PATTERN = re.compile(
|
_AUDIO_FORMAT_PATTERN = re.compile(
|
||||||
r"(?<![A-Z])(?P<format>DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
|
r"(?<![A-Z])(?P<format>DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
|
||||||
@@ -708,6 +707,55 @@ class MetaMusic(MetaBase):
|
|||||||
"""把用户输入或资源标题解析为音乐元数据。"""
|
"""把用户输入或资源标题解析为音乐元数据。"""
|
||||||
return cls(org_string=query, title=query, parse_title=True)
|
return cls(org_string=query, title=query, parse_title=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_resource(cls, title: str, subtitle: Optional[str] = None) -> "MetaMusic":
|
||||||
|
"""合并资源标题与副标题的独立证据,不使用搜索目标补写作品身份。
|
||||||
|
|
||||||
|
标题解析仍复用 Python/Rust 公共入口;副标题只补缺失字段,保留标题中
|
||||||
|
已有的署名。专辑、曲序、曲名的明确多段格式在资源层统一补充。
|
||||||
|
"""
|
||||||
|
meta = cls.parse_query(title)
|
||||||
|
if not meta.title:
|
||||||
|
# 整个作品名位于中文展示括号内时,旧解析器可能把它误当发布标签删除。
|
||||||
|
bracket = re.match(r"^\s*[【《「]([^】》」]+)[】》」]", title)
|
||||||
|
if bracket:
|
||||||
|
meta.apply_title(bracket.group(1))
|
||||||
|
meta.apply_audio_quality(title)
|
||||||
|
if meta.artists and meta.title:
|
||||||
|
track = re.fullmatch(r"(.+?)\s+-\s+(\d{1,3})\s+-\s+(.+)", meta.title)
|
||||||
|
if track:
|
||||||
|
meta.album, number, meta.title = track.groups()
|
||||||
|
meta.track_number = int(number)
|
||||||
|
if subtitle:
|
||||||
|
secondary = cls.parse_query(subtitle)
|
||||||
|
artist = re.search(
|
||||||
|
r"(?:^|[;;\n])\s*(?:艺术家|藝術家|藝人|歌手|演唱|专辑艺人|專輯藝人|artist|performer)"
|
||||||
|
r"\s*[::]\s*([^;;\n]+)", subtitle, re.I,
|
||||||
|
)
|
||||||
|
if not meta.artists:
|
||||||
|
meta.artists = cls._split_artists(artist.group(1)) if artist else list(secondary.artists)
|
||||||
|
album = re.search(
|
||||||
|
r"(?:^|[;;\n])\s*(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|album)\s*[::]\s*([^;;\n]+)",
|
||||||
|
subtitle, re.I,
|
||||||
|
)
|
||||||
|
if album and not meta.album:
|
||||||
|
meta.album = album.group(1).strip()
|
||||||
|
elif not meta.album and secondary.artists and secondary.title != meta.title:
|
||||||
|
if {cls.compact_text(item) for item in meta.artists} & {cls.compact_text(item) for item in secondary.artists}:
|
||||||
|
meta.album = secondary.title
|
||||||
|
if not meta.year:
|
||||||
|
meta.year = secondary.year
|
||||||
|
meta.apply_audio_quality(subtitle)
|
||||||
|
if not meta.version:
|
||||||
|
version = re.search(
|
||||||
|
r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|"
|
||||||
|
r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*)[\]))】]",
|
||||||
|
title, re.I,
|
||||||
|
)
|
||||||
|
if version:
|
||||||
|
meta.version = version.group(1).strip()
|
||||||
|
return meta
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_music_info(cls, info: Any) -> "MetaMusic":
|
def from_music_info(cls, info: Any) -> "MetaMusic":
|
||||||
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
|
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
|
||||||
|
|||||||
+14
-12
@@ -1,31 +1,30 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Mapping, Tuple, List, Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
import regex as re
|
import regex as re
|
||||||
|
|
||||||
from app.domain.meta.metaanime import MetaAnime
|
from app.domain.meta.customization import CustomizationMatcher, get_customization
|
||||||
from app.domain.meta.metabase import MetaBase
|
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
|
||||||
from app.domain.meta.metavideo import MetaVideo
|
|
||||||
from app.domain.meta.infopath import (
|
from app.domain.meta.infopath import (
|
||||||
clear_parsed_title_for_parent_merge,
|
clear_parsed_title_for_parent_merge,
|
||||||
should_use_parent_title_for_file_stem,
|
should_use_parent_title_for_file_stem,
|
||||||
)
|
)
|
||||||
from app.domain.meta.words import WordsMatcher, get_custom_words
|
from app.domain.meta.metaanime import MetaAnime
|
||||||
from app.domain.meta.customization import CustomizationMatcher, get_customization
|
from app.domain.meta.metabase import MetaBase
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
|
from app.domain.meta.metavideo import MetaVideo
|
||||||
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
|
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
|
||||||
from app.domain.meta.runtime import (
|
from app.domain.meta.runtime import (
|
||||||
get_audio_extensions,
|
get_audio_extensions,
|
||||||
get_media_extensions,
|
get_media_extensions,
|
||||||
get_metainfo_accelerator,
|
get_metainfo_accelerator,
|
||||||
)
|
)
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.domain.meta.words import WordsMatcher, get_custom_words
|
||||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
_ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE)
|
_ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE)
|
||||||
_ANIME_DASH_EPISODE_RE = re.compile(r'\s+-\s+[\dv]{1,4}\s+', re.IGNORECASE)
|
_ANIME_DASH_EPISODE_RE = re.compile(r'\s+-\s+[\dv]{1,4}\s+', re.IGNORECASE)
|
||||||
@@ -493,18 +492,21 @@ def _requires_python_metainfo(
|
|||||||
|
|
||||||
|
|
||||||
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None,
|
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None,
|
||||||
force_video: bool = False) -> MetaBase:
|
force_video: bool = False, mtype: Optional[MediaType] = None) -> MetaBase:
|
||||||
"""
|
"""
|
||||||
根据标题和副标题识别元数据
|
根据标题和副标题识别元数据
|
||||||
:param title: 标题、种子名、文件名
|
:param title: 标题、种子名、文件名
|
||||||
:param subtitle: 副标题、描述
|
:param subtitle: 副标题、描述
|
||||||
:param custom_words: 自定义识别词列表
|
:param custom_words: 自定义识别词列表
|
||||||
:param force_video: 音频后缀的影视附加轨(如评论音轨)强制按视频解析,用于影视整理场景
|
:param force_video: 音频后缀的影视附加轨(如评论音轨)强制按视频解析,用于影视整理场景
|
||||||
|
:param mtype: 已由调用方确定的媒体类型;音乐资源无文件后缀时也使用音乐解析器
|
||||||
:return: MetaAnime、MetaVideo、MetaMusic
|
:return: MetaAnime、MetaVideo、MetaMusic
|
||||||
"""
|
"""
|
||||||
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析
|
# 音频文件名直接走音乐分支,避免进入影视季集解析,但影视附加音轨强制走视频解析
|
||||||
audio_suffix = Path(title).suffix.lower() if title else ""
|
audio_suffix = Path(title).suffix.lower() if title else ""
|
||||||
if not force_video and audio_suffix in get_audio_extensions():
|
if not force_video and mtype == MediaType.MUSIC:
|
||||||
|
return MetaMusic.parse_resource(title, subtitle)
|
||||||
|
if not force_video and mtype not in (MediaType.MOVIE, MediaType.TV) and audio_suffix in get_audio_extensions():
|
||||||
return MetaMusic(
|
return MetaMusic(
|
||||||
org_string=title,
|
org_string=title,
|
||||||
title=Path(title).stem,
|
title=Path(title).stem,
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""音乐名称、版本与站点候选匹配的纯业务规则。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Literal, Optional
|
||||||
|
from unicodedata import combining, normalize
|
||||||
|
|
||||||
|
from app.domain.context import MusicInfo
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
|
from app.foundation.text import convert as zhconv_convert
|
||||||
|
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||||
|
|
||||||
|
_EDITION = re.compile(
|
||||||
|
r"\s*[\[((【](?:[^\]))】]*\b(?:deluxe|expanded|special|limited|anniversary|remaster(?:ed)?)\b"
|
||||||
|
r"[^\]))】]*|[^\]))】]*(?:豪华版|典藏版|纪念版|重制版|周年版)[^\]))】]*)[\]))】]",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_VERSIONS = {
|
||||||
|
"live": r"\blive\b|现场|現場|演唱会|演唱會",
|
||||||
|
"remix": r"\bremix(?:ed)?\b|混音",
|
||||||
|
"instrumental": r"\binstrumental\b|\bkaraoke\b|伴奏|纯音乐|純音樂",
|
||||||
|
"acoustic": r"\bacoustic\b|\bunplugged\b|不插电|不插電",
|
||||||
|
"demo": r"\bdemo\b",
|
||||||
|
}
|
||||||
|
_VERSION_SUFFIX = re.compile(
|
||||||
|
r"\s*[\[((【][^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|"
|
||||||
|
r"\bunplugged\b|\bdemo\b|\bkaraoke\b|现场|現場|混音|伴奏|不插电|不插電)[^\]))】]*[\]))】]",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_BARE_VERSION_SUFFIX = re.compile(
|
||||||
|
r"\s+[-/]\s+(?:live\b|remix\b|instrumental\b|acoustic\b|unplugged\b|demo\b|karaoke\b|"
|
||||||
|
r"现场|現場|混音|伴奏|不插电|不插電).*$", re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TITLE_LABEL = re.compile(r"^(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|曲名|歌曲|album|title)\s*[::]\s*", re.I)
|
||||||
|
_COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人", "眾藝人")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MusicMatch:
|
||||||
|
"""区分可自动采用的精确命中、仅可人工确认的候选和无关资源。"""
|
||||||
|
|
||||||
|
status: Literal["exact", "candidate", "album", "rejected"]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def unique_music_texts(values: Iterable[Optional[str]]) -> list[str]:
|
||||||
|
"""保留原始文字和顺序,仅合并空白及大小写相同的名称。"""
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||||||
|
if text and text.casefold() not in seen:
|
||||||
|
seen.add(text.casefold())
|
||||||
|
result.append(text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def music_text_key(value: Optional[str]) -> str:
|
||||||
|
"""统一繁简、大小写、全半角和拉丁变音符,忽略名称排版符号。"""
|
||||||
|
text = normalize("NFKD", str(value or "")).casefold()
|
||||||
|
return str(zhconv_convert("".join(char for char in text if char.isalnum() and not combining(char)), "zh-hans"))
|
||||||
|
|
||||||
|
|
||||||
|
def music_titles(music: MusicInfo, *, album: bool = False) -> list[str]:
|
||||||
|
"""返回同一作品的可信名称,单曲绝不消费兼容 names 中的专辑名。"""
|
||||||
|
if album or music.music_type == MUSIC_ENTITY_ALBUM:
|
||||||
|
return unique_music_texts([
|
||||||
|
music.album or (music.title if music.music_type == MUSIC_ENTITY_ALBUM else None),
|
||||||
|
*(music.album_aliases or []),
|
||||||
|
*((music.title_aliases or []) if music.music_type == MUSIC_ENTITY_ALBUM else ()),
|
||||||
|
*((music.names or []) if music.music_type == MUSIC_ENTITY_ALBUM else ()),
|
||||||
|
])
|
||||||
|
return unique_music_texts([music.title, *(music.title_aliases or [])])
|
||||||
|
|
||||||
|
|
||||||
|
def music_artists(music: MusicInfo) -> list[str]:
|
||||||
|
"""合并实体艺术家和来源别名,仅为合辑署名扩展通用缩写。"""
|
||||||
|
album_artist = music.album_artist if music.music_type == MUSIC_ENTITY_ALBUM or not music.artists else None
|
||||||
|
artists = unique_music_texts([album_artist, *(music.artists or []), *(music.artist_aliases or [])])
|
||||||
|
collective_keys = {music_text_key(item) for item in _COLLECTIVE_ARTISTS}
|
||||||
|
if music.music_type != MUSIC_ENTITY_ALBUM and any(music_text_key(artist) not in collective_keys for artist in music.artists):
|
||||||
|
artists = [artist for artist in artists if music_text_key(artist) not in collective_keys]
|
||||||
|
if any(music_text_key(artist) in collective_keys for artist in artists):
|
||||||
|
artists = unique_music_texts([*artists, *_COLLECTIVE_ARTISTS])
|
||||||
|
return artists
|
||||||
|
|
||||||
|
|
||||||
|
def music_base_title(value: Optional[str]) -> str:
|
||||||
|
"""仅剥离已知发行版本后缀,保留未知括号和属于作品本身的文字。"""
|
||||||
|
text = _VERSION_SUFFIX.sub("", _EDITION.sub("", str(value or "")))
|
||||||
|
return _BARE_VERSION_SUFFIX.sub("", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_artist(text: str, artist: str) -> bool:
|
||||||
|
"""匹配完整署名,避免短拉丁艺名命中另一个人名的子串。"""
|
||||||
|
normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans"))
|
||||||
|
normalized = "".join(char for char in normalized if not combining(char))
|
||||||
|
key = music_text_key(artist)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
pattern = r"[\W_]*".join(re.escape(char) for char in key)
|
||||||
|
return bool(re.search(r"(?<![a-z0-9])" + pattern + r"(?![a-z0-9])", normalized))
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = False,
|
||||||
|
album_suffixes: Optional[list[str]] = None) -> list[str]:
|
||||||
|
"""复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。"""
|
||||||
|
names: list[str] = []
|
||||||
|
artist_keys = [music_text_key(item) for item in artists if item]
|
||||||
|
suffix_keys = {music_text_key(item) for item in album_suffixes or []}
|
||||||
|
for value in (primary.title, primary.album if album else None):
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
parts = re.split(r"\s+[-|/]\s+|[;;]", value)
|
||||||
|
# 只有可核验为所属专辑的尾段才允许剥离,未知连字符后缀仍属于作品本身。
|
||||||
|
variants = [value]
|
||||||
|
if len(parts) > 1 and all(music_text_key(part) in suffix_keys for part in parts[1:]):
|
||||||
|
variants.append(parts[0])
|
||||||
|
for part in variants:
|
||||||
|
key = music_text_key(music_base_title(_TITLE_LABEL.sub("", part.strip())))
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
names.append(key)
|
||||||
|
for artist in artist_keys:
|
||||||
|
if key.startswith(artist) and key != artist:
|
||||||
|
names.append(key[len(artist):])
|
||||||
|
if key.endswith(artist) and key != artist:
|
||||||
|
names.append(key[:-len(artist)])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _version_markers(text: str) -> set[str]:
|
||||||
|
"""识别会改变录音身份的版本标记,普通发行后缀单独处理。"""
|
||||||
|
return {name for name, pattern in _VERSIONS.items() if re.search(pattern, text, re.I)}
|
||||||
|
|
||||||
|
|
||||||
|
def match_music_resource(
|
||||||
|
music: MusicInfo,
|
||||||
|
title: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
category: Optional[str] = MediaType.MUSIC.value,
|
||||||
|
*,
|
||||||
|
meta: Optional[MetaMusic] = None,
|
||||||
|
) -> MusicMatch:
|
||||||
|
"""以作品名称为基础验证艺术家、分类和版本,并保留可供人工确认的关联候选。"""
|
||||||
|
if category not in (None, "", MediaType.UNKNOWN, MediaType.UNKNOWN.value, MediaType.MUSIC, MediaType.MUSIC.value):
|
||||||
|
return MusicMatch("rejected", "category_mismatch")
|
||||||
|
description = description or ""
|
||||||
|
resource = meta or MetaMusic.parse_resource(title, description)
|
||||||
|
artists = music_artists(music)
|
||||||
|
albums = music_titles(music, album=True)
|
||||||
|
names = _resource_names(resource, artists, album=music.music_type == MUSIC_ENTITY_ALBUM,
|
||||||
|
album_suffixes=albums if music.music_type != MUSIC_ENTITY_ALBUM else None)
|
||||||
|
titles = music_titles(music)
|
||||||
|
title_matched = any(music_text_key(music_base_title(item)) in names for item in titles)
|
||||||
|
content = f"{title} {description}"
|
||||||
|
resource_artist_keys = {music_text_key(artist) for artist in resource.artists}
|
||||||
|
if len(resource.artists) > 1 and any(any(separator in artist for separator in ("/", "&", ",")) for artist in artists):
|
||||||
|
# 带分隔符的完整艺名可能被解析成多个片段,保留整段署名参与比较,不拼接无分隔符艺名。
|
||||||
|
resource_artist_keys.add(music_text_key(resource.artist))
|
||||||
|
artist_matched = bool(resource_artist_keys & {music_text_key(artist) for artist in artists}) if resource.artists \
|
||||||
|
else any(_contains_artist(content, artist) for artist in artists)
|
||||||
|
if not title_matched:
|
||||||
|
if music.music_type != MUSIC_ENTITY_ALBUM and artist_matched and any(
|
||||||
|
music_text_key(music_base_title(item)) in names
|
||||||
|
for item in music_titles(music, album=True)
|
||||||
|
):
|
||||||
|
return MusicMatch("album", "related_album")
|
||||||
|
return MusicMatch("rejected", "title_mismatch")
|
||||||
|
if not artists or (music.music_type != MUSIC_ENTITY_ALBUM and not music.artists):
|
||||||
|
return MusicMatch("candidate", "target_artist_missing")
|
||||||
|
if not artist_matched:
|
||||||
|
return MusicMatch("candidate", "artist_unverified")
|
||||||
|
if category not in (MediaType.MUSIC, MediaType.MUSIC.value):
|
||||||
|
return MusicMatch("candidate", "category_unknown")
|
||||||
|
if music.music_type == MUSIC_ENTITY_ALBUM:
|
||||||
|
if resource.track_number and resource.album:
|
||||||
|
return MusicMatch("candidate", "partial_album")
|
||||||
|
if music.year and resource.year and str(music.year) != str(resource.year):
|
||||||
|
return MusicMatch("candidate", "year_mismatch")
|
||||||
|
target_title = music.album or music.title if music.music_type == MUSIC_ENTITY_ALBUM else music.title
|
||||||
|
expected_version = _version_markers(f"{target_title or ''} {music.version or ''}")
|
||||||
|
if expected_version != _version_markers(f"{resource.title or ''} {resource.version or ''}"):
|
||||||
|
return MusicMatch("candidate", "version_mismatch")
|
||||||
|
if _EDITION.search(music.title or "") and not any(music_text_key(item) in music_text_key(content) for item in titles):
|
||||||
|
return MusicMatch("candidate", "edition_unverified")
|
||||||
|
return MusicMatch("exact", "matched")
|
||||||
@@ -18,6 +18,7 @@ from app.domain.context import (
|
|||||||
from app.domain.media import is_media_source_selected
|
from app.domain.media import is_media_source_selected
|
||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
|
from app.domain.music import music_text_key, unique_music_texts
|
||||||
from app.foundation.text import convert as zhconv_convert
|
from app.foundation.text import convert as zhconv_convert
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
from app.modules.musicbrainz.cache import MusicBrainzCache
|
from app.modules.musicbrainz.cache import MusicBrainzCache
|
||||||
@@ -292,9 +293,11 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
if not is_media_source_selected(media_source, self._source):
|
if not is_media_source_selected(media_source, self._source):
|
||||||
return None
|
return None
|
||||||
normalized_limit = max(1, min(limit, 100))
|
normalized_limit = max(1, min(limit, 100))
|
||||||
recordings = self._search_recordings(meta, limit=normalized_limit)
|
# 中文逐字召回可能包含很多局部命中,扩大单次窗口后按完整名称重新排序。
|
||||||
albums = self._search_albums(meta, limit=normalized_limit)
|
fetch_limit = min(100, normalized_limit * 3) if self._QUERY_CJK_RE.search(meta.title or "") else normalized_limit
|
||||||
artists = self._search_artists(meta, limit=normalized_limit)
|
recordings = self._rank_search_candidates(meta, self._search_recordings(meta, limit=fetch_limit))
|
||||||
|
albums = self._rank_search_candidates(meta, self._search_albums(meta, limit=fetch_limit))
|
||||||
|
artists = self._rank_search_candidates(meta, self._search_artists(meta, limit=fetch_limit))
|
||||||
return self._interleave_results(
|
return self._interleave_results(
|
||||||
recordings,
|
recordings,
|
||||||
albums,
|
albums,
|
||||||
@@ -302,6 +305,26 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
limit=normalized_limit,
|
limit=normalized_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rank_search_candidates(meta: MetaMusic, candidates: list[MusicInfo]) -> list[MusicInfo]:
|
||||||
|
"""按完整作品名及输入署名排序浏览候选,不把逐字 OR 命中视为精确身份。"""
|
||||||
|
expected = music_text_key(meta.title)
|
||||||
|
expected_artists = {music_text_key(artist) for artist in meta.artists}
|
||||||
|
|
||||||
|
def score(info: MusicInfo) -> tuple[bool, float, bool]:
|
||||||
|
"""整名优先;普通组合输入同时比较艺术家与作品名,原始顺序用于同分稳定排序。"""
|
||||||
|
names = [info.title, *(info.title_aliases or [])]
|
||||||
|
if not meta.artists:
|
||||||
|
names.extend(f"{artist} {info.title or ''}" for artist in [*info.artists, *(info.artist_aliases or [])])
|
||||||
|
keys = [music_text_key(name) for name in names if name]
|
||||||
|
artist_match = bool(expected_artists & {
|
||||||
|
music_text_key(artist) for artist in [*info.artists, *(info.artist_aliases or [])]
|
||||||
|
})
|
||||||
|
similarity = max((SequenceMatcher(None, expected, key).ratio() for key in keys), default=0.0)
|
||||||
|
return expected in keys, similarity, artist_match
|
||||||
|
|
||||||
|
return sorted(candidates, key=score, reverse=True)
|
||||||
|
|
||||||
def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
|
def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
|
||||||
"""按音频标签条件搜索 Recording,供全局搜索和文件识别复用。"""
|
"""按音频标签条件搜索 Recording,供全局搜索和文件识别复用。"""
|
||||||
for query in self._recording_queries(meta):
|
for query in self._recording_queries(meta):
|
||||||
@@ -365,11 +388,13 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
for query in [
|
for query in [
|
||||||
cls._build_query(meta),
|
cls._build_query(meta),
|
||||||
f"recording:{cls._query_phrase(title)}" if title else None,
|
f"recording:{cls._query_phrase(title)}" if title else None,
|
||||||
f'recording:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"'
|
f'recording:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}'
|
||||||
if artist and bare_title and bare_title != title else None,
|
if artist and bare_title and bare_title != title else None,
|
||||||
# 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索,
|
# 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索,
|
||||||
# 候选挑选阶段要求艺术家命中兜住同名异曲
|
# 候选挑选阶段要求艺术家命中兜住同名异曲
|
||||||
f"recording:{cls._query_phrase(bare_title)}" if bare_title else None,
|
f"recording:{cls._query_phrase(bare_title)}" if bare_title else None,
|
||||||
|
f"recording:{cls._query_phrase(bare_title or title, loose=True)}"
|
||||||
|
if cls._QUERY_CJK_RE.search(title) else None,
|
||||||
]:
|
]:
|
||||||
if query and query not in queries:
|
if query and query not in queries:
|
||||||
queries.append(query)
|
queries.append(query)
|
||||||
@@ -510,6 +535,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _album_queries(cls, meta: MetaMusic) -> list[str]:
|
def _album_queries(cls, meta: MetaMusic) -> list[str]:
|
||||||
"""构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。"""
|
"""构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。"""
|
||||||
|
original_title = cls._search_title(meta.album or meta.title, preserve_script=True)
|
||||||
title = cls._search_title(meta.album or meta.title)
|
title = cls._search_title(meta.album or meta.title)
|
||||||
if not title:
|
if not title:
|
||||||
return []
|
return []
|
||||||
@@ -528,16 +554,20 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
soundtrack_body = ""
|
soundtrack_body = ""
|
||||||
queries: list[str] = []
|
queries: list[str] = []
|
||||||
for query in [
|
for query in [
|
||||||
f'releasegroup:{cls._query_phrase(title)} AND artist:"{cls._escape_query(artist)}"'
|
f'releasegroup:{cls._query_phrase(original_title)} AND artist:{cls._query_phrase(artist)}'
|
||||||
|
if artist else f"releasegroup:{cls._query_phrase(original_title)}",
|
||||||
|
f'releasegroup:{cls._query_phrase(title)} AND artist:{cls._query_phrase(artist)}'
|
||||||
if artist else None,
|
if artist else None,
|
||||||
f"releasegroup:{cls._query_phrase(title)}" if title else None,
|
f"releasegroup:{cls._query_phrase(title)}" if title else None,
|
||||||
f'releasegroup:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"'
|
f'releasegroup:{cls._query_phrase(bare_title)} AND artist:{cls._query_phrase(artist)}'
|
||||||
if artist and bare_title and bare_title != title else None,
|
if artist and bare_title and bare_title != title else None,
|
||||||
f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:"{cls._escape_query(artist)}"'
|
f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:{cls._query_phrase(artist)}'
|
||||||
if artist and soundtrack_body else None,
|
if artist and soundtrack_body else None,
|
||||||
f"releasegroup:{cls._query_phrase(soundtrack_body)}" if soundtrack_body else None,
|
f"releasegroup:{cls._query_phrase(soundtrack_body)}" if soundtrack_body else None,
|
||||||
# 署名变体兜底:仅按去注释专辑名检索,挑选阶段要求艺术家同时命中
|
# 署名变体兜底:仅按去注释专辑名检索,挑选阶段要求艺术家同时命中
|
||||||
f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None,
|
f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None,
|
||||||
|
f"releasegroup:{cls._query_phrase(bare_title or title, loose=True)}"
|
||||||
|
if cls._QUERY_CJK_RE.search(title) else None,
|
||||||
]:
|
]:
|
||||||
if query and query not in queries:
|
if query and query not in queries:
|
||||||
queries.append(query)
|
queries.append(query)
|
||||||
@@ -1376,7 +1406,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
# MusicBrainz 中文条目以简体为主,资源标题可能是繁体,比对前统一转简体
|
# 仅归一化本地比较;实际查询使用原文和完整繁简变体。
|
||||||
try:
|
try:
|
||||||
return zhconv_convert(text, "zh-hans")
|
return zhconv_convert(text, "zh-hans")
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
@@ -1394,25 +1424,27 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _search_title(cls, value: Optional[str]) -> str:
|
def _search_title(cls, value: Optional[str], *, preserve_script: bool = False) -> str:
|
||||||
"""剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。"""
|
"""剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。"""
|
||||||
text = cls._quality_token_pattern.sub(" ", str(value or ""))
|
text = cls._quality_token_pattern.sub(" ", str(value or ""))
|
||||||
|
normalize_text = (lambda value: re.sub(r"\s+", " ", str(value or "")).strip()) \
|
||||||
|
if preserve_script else cls._normalize_text
|
||||||
# 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语
|
# 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语
|
||||||
text = text.replace("_", " ")
|
text = text.replace("_", " ")
|
||||||
# 规格剥离后可能残留悬空分隔符,统一修剪
|
# 规格剥离后可能残留悬空分隔符,统一修剪
|
||||||
text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", cls._normalize_text(text))
|
text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", normalize_text(text))
|
||||||
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB),整体剔除
|
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB),整体剔除
|
||||||
text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text)
|
text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text)
|
||||||
# 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取),
|
# 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取),
|
||||||
# 反复剥离尾部年份:场景命名可能重复携带(Live At Montreux 2011 2011)
|
# 反复剥离尾部年份:场景命名可能重复携带(Live At Montreux 2011 2011)
|
||||||
text = cls._normalize_text(text)
|
text = normalize_text(text)
|
||||||
while True:
|
while True:
|
||||||
# 仅剔除空白分隔的尾部年份,纯年份标题(1999)无前导空白不受影响
|
# 仅剔除空白分隔的尾部年份,纯年份标题(1999)无前导空白不受影响
|
||||||
stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text)
|
stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text)
|
||||||
if stripped == text:
|
if stripped == text:
|
||||||
break
|
break
|
||||||
text = stripped
|
text = stripped
|
||||||
return cls._normalize_text(text)
|
return normalize_text(text)
|
||||||
|
|
||||||
def recognize_music(
|
def recognize_music(
|
||||||
self,
|
self,
|
||||||
@@ -1429,12 +1461,13 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
f"/recording/{plan.require_media_id()}",
|
f"/recording/{plan.require_media_id()}",
|
||||||
params={
|
params={
|
||||||
"inc": "artists+releases+release-groups+isrcs+genres",
|
"inc": "artists+releases+release-groups+isrcs+genres+aliases",
|
||||||
"fmt": "json",
|
"fmt": "json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = self._project_recording_detail(payload)
|
result = self._project_recording_detail(payload)
|
||||||
if result:
|
if result:
|
||||||
|
result.artist_aliases = self._lookup_artist_aliases(result.artist_ids, result.artist_aliases)
|
||||||
return result
|
return result
|
||||||
if not self._should_probe_album(plan, result):
|
if not self._should_probe_album(plan, result):
|
||||||
return None
|
return None
|
||||||
@@ -1457,12 +1490,13 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
payload = await self._async_request_json(
|
payload = await self._async_request_json(
|
||||||
f"/recording/{plan.require_media_id()}",
|
f"/recording/{plan.require_media_id()}",
|
||||||
params={
|
params={
|
||||||
"inc": "artists+releases+release-groups+isrcs+genres",
|
"inc": "artists+releases+release-groups+isrcs+genres+aliases",
|
||||||
"fmt": "json",
|
"fmt": "json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = self._project_recording_detail(payload)
|
result = self._project_recording_detail(payload)
|
||||||
if result:
|
if result:
|
||||||
|
result.artist_aliases = await self._async_lookup_artist_aliases(result.artist_ids, result.artist_aliases)
|
||||||
return result
|
return result
|
||||||
if not self._should_probe_album(plan, result):
|
if not self._should_probe_album(plan, result):
|
||||||
return None
|
return None
|
||||||
@@ -1514,7 +1548,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
payload = await self._async_request_json(
|
payload = await self._async_request_json(
|
||||||
f"/release-group/{media_id}",
|
f"/release-group/{media_id}",
|
||||||
params={
|
params={
|
||||||
"inc": "artists+releases+media+genres+tags+ratings",
|
"inc": "artists+releases+media+genres+tags+ratings+aliases",
|
||||||
"fmt": "json",
|
"fmt": "json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1525,6 +1559,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
payload.get("releases") or []
|
payload.get("releases") or []
|
||||||
)
|
)
|
||||||
album.tracks = self._project_album_tracks(album, tracks_payload)
|
album.tracks = self._project_album_tracks(album, tracks_payload)
|
||||||
|
album.artist_aliases = await self._async_lookup_artist_aliases(album.artist_ids, album.artist_aliases)
|
||||||
return album
|
return album
|
||||||
|
|
||||||
def music_album(
|
def music_album(
|
||||||
@@ -1538,7 +1573,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
f"/release-group/{media_id}",
|
f"/release-group/{media_id}",
|
||||||
params={
|
params={
|
||||||
"inc": "artists+releases+media+genres+tags+ratings",
|
"inc": "artists+releases+media+genres+tags+ratings+aliases",
|
||||||
"fmt": "json",
|
"fmt": "json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1547,8 +1582,39 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
tracks_payload = self._album_tracks_payload(payload.get("releases") or [])
|
tracks_payload = self._album_tracks_payload(payload.get("releases") or [])
|
||||||
album.tracks = self._project_album_tracks(album, tracks_payload)
|
album.tracks = self._project_album_tracks(album, tracks_payload)
|
||||||
|
album.artist_aliases = self._lookup_artist_aliases(album.artist_ids, album.artist_aliases)
|
||||||
return album
|
return album
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _alias_artist_ids(artist_ids: list[str]) -> list[str]:
|
||||||
|
"""限制补充查询预算,仅使用来源返回的有效 MusicBrainz 艺术家 UUID。"""
|
||||||
|
return list(dict.fromkeys(artist_id for artist_id in artist_ids if re.fullmatch(
|
||||||
|
r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", artist_id,
|
||||||
|
)))[:3]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _artist_alias_values(cls, payload: Optional[dict[str, Any]], artist_id: str) -> list[str]:
|
||||||
|
"""只采信精确艺术家 ID 响应中的名称及别名,避免串入搜索得到的同名艺人。"""
|
||||||
|
if not payload or payload.get("id") != artist_id:
|
||||||
|
return []
|
||||||
|
return unique_music_texts([payload.get("name"), *cls._names_of(payload.get("aliases"))])
|
||||||
|
|
||||||
|
def _lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]:
|
||||||
|
"""利用现有请求缓存补全已识别艺术家的可信别名,不按文字猜测其他艺人。"""
|
||||||
|
values = list(aliases)
|
||||||
|
for artist_id in self._alias_artist_ids(artist_ids):
|
||||||
|
payload = self._request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"})
|
||||||
|
values.extend(self._artist_alias_values(payload, artist_id))
|
||||||
|
return unique_music_texts(values)
|
||||||
|
|
||||||
|
async def _async_lookup_artist_aliases(self, artist_ids: list[str], aliases: list[str]) -> list[str]:
|
||||||
|
"""原生异步补全同一艺术家别名,保留站点客户端的限流和请求缓存。"""
|
||||||
|
values = list(aliases)
|
||||||
|
for artist_id in self._alias_artist_ids(artist_ids):
|
||||||
|
payload = await self._async_request_json(f"/artist/{artist_id}", params={"inc": "aliases", "fmt": "json"})
|
||||||
|
values.extend(self._artist_alias_values(payload, artist_id))
|
||||||
|
return unique_music_texts(values)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _project_album_detail(
|
def _project_album_detail(
|
||||||
cls, payload: Optional[dict[str, Any]]
|
cls, payload: Optional[dict[str, Any]]
|
||||||
@@ -1628,13 +1694,13 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
"""构造 MusicBrainz Recording 搜索表达式。"""
|
"""构造 MusicBrainz Recording 搜索表达式。"""
|
||||||
clauses = []
|
clauses = []
|
||||||
# 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中
|
# 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中
|
||||||
title = cls._search_title(meta.title)
|
title = cls._search_title(meta.title, preserve_script=True)
|
||||||
if title:
|
if title:
|
||||||
clauses.append(f"recording:{cls._query_phrase(title)}")
|
clauses.append(f"recording:{cls._query_phrase(title)}")
|
||||||
if meta.artists:
|
if meta.artists:
|
||||||
clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"')
|
clauses.append(f'artist:{cls._query_phrase(meta.artists[0])}')
|
||||||
if meta.album:
|
if meta.album:
|
||||||
clauses.append(f'release:"{cls._escape_query(meta.album)}"')
|
clauses.append(f'release:{cls._query_phrase(meta.album)}')
|
||||||
if meta.isrc:
|
if meta.isrc:
|
||||||
clauses.append(f'isrc:"{cls._escape_query(meta.isrc)}"')
|
clauses.append(f'isrc:"{cls._escape_query(meta.isrc)}"')
|
||||||
return " AND ".join(clauses)
|
return " AND ".join(clauses)
|
||||||
@@ -1644,7 +1710,7 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
"""转义 MusicBrainz 查询中的引号和反斜线。"""
|
"""转义 MusicBrainz 查询中的引号和反斜线。"""
|
||||||
return value.replace("\\", "\\\\").replace('"', '\\"').strip()
|
return value.replace("\\", "\\\\").replace('"', '\\"').strip()
|
||||||
|
|
||||||
# 中日韩字符:Lucene 标准分词器不会切分连续 CJK,短语检索对中文标题永远零命中
|
# 中日韩名称优先完整短语,仅在前置查询无结果时启用逐字兜底。
|
||||||
_QUERY_CJK_RE = re.compile(
|
_QUERY_CJK_RE = re.compile(
|
||||||
r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF]")
|
r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF]")
|
||||||
# 检索词元切分:按空白、标点与括号拆分,保留 CJK 串与拉丁词(括号对逐字检索无意义)
|
# 检索词元切分:按空白、标点与括号拆分,保留 CJK 串与拉丁词(括号对逐字检索无意义)
|
||||||
@@ -1652,18 +1718,18 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
r"[\s\-–—−-。,、;:!?·.…()()「」『』【】\[\]《》,;]+")
|
r"[\s\-–—−-。,、;:!?·.…()()「」『』【】\[\]《》,;]+")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _query_phrase(cls, value: Optional[str]) -> Optional[str]:
|
def _query_phrase(cls, value: Optional[str], *, loose: bool = False) -> Optional[str]:
|
||||||
"""构造适配 Lucene 分词的检索表达式。
|
"""优先构造完整名称的繁简短语组,逐字 OR 只作为显式请求的末级兜底。"""
|
||||||
|
|
||||||
无 CJK 的普通文本返回带引号短语;含 CJK 的文本拆为词元后用 OR 交集检索,
|
|
||||||
MusicBrainz 索引中连续 CJK 是单一词元,逐字 OR 才能命中(「茹此精彩十三首」);
|
|
||||||
过宽的召回由候选挑选阶段的标题与艺术家比对收紧。
|
|
||||||
"""
|
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
if not cls._QUERY_CJK_RE.search(text):
|
if not cls._QUERY_CJK_RE.search(text):
|
||||||
return f'"{cls._escape_query(text)}"'
|
return f'"{cls._escape_query(text)}"'
|
||||||
|
if not loose:
|
||||||
|
variants = sorted(unique_music_texts([
|
||||||
|
text, zhconv_convert(text, "zh-hans"), zhconv_convert(text, "zh-hant"),
|
||||||
|
]))
|
||||||
|
return cls._or_group([f'"{cls._escape_query(variant)}"' for variant in variants])
|
||||||
tokens = [
|
tokens = [
|
||||||
token for token in cls._QUERY_TOKEN_SPLIT_RE.split(text) if token.strip()
|
token for token in cls._QUERY_TOKEN_SPLIT_RE.split(text) if token.strip()
|
||||||
]
|
]
|
||||||
@@ -1726,6 +1792,9 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
genres=cls._names_of(recording.get("genres")),
|
genres=cls._names_of(recording.get("genres")),
|
||||||
release_status=cls._stripped((release or {}).get("status")),
|
release_status=cls._stripped((release or {}).get("status")),
|
||||||
names=[name for name in (title, album) if name],
|
names=[name for name in (title, album) if name],
|
||||||
|
title_aliases=cls._names_of(recording.get("aliases")),
|
||||||
|
album_aliases=cls._names_of(release_group.get("aliases")),
|
||||||
|
artist_aliases=cls._credit_aliases(recording.get("artist-credit")),
|
||||||
detail_link=f"{cls._detail_url}/{media_id}",
|
detail_link=f"{cls._detail_url}/{media_id}",
|
||||||
raw_data=recording,
|
raw_data=recording,
|
||||||
)
|
)
|
||||||
@@ -1746,6 +1815,8 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
artists=artists,
|
artists=artists,
|
||||||
artist_ids=artist_ids,
|
artist_ids=artist_ids,
|
||||||
album_type=cls._stripped(release_group.get("primary-type")),
|
album_type=cls._stripped(release_group.get("primary-type")),
|
||||||
|
title_aliases=cls._names_of(release_group.get("aliases")),
|
||||||
|
artist_aliases=cls._credit_aliases(release_group.get("artist-credit")),
|
||||||
secondary_types=[cls._stripped(item) for item in release_group.get("secondary-types") or [] if cls._stripped(item)],
|
secondary_types=[cls._stripped(item) for item in release_group.get("secondary-types") or [] if cls._stripped(item)],
|
||||||
release_date=release_group.get("first-release-date") or None,
|
release_date=release_group.get("first-release-date") or None,
|
||||||
cover_url=cls._build_cover_url(media_id),
|
cover_url=cls._build_cover_url(media_id),
|
||||||
@@ -1798,6 +1869,17 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
ids.append(str(artist.get("id") or ""))
|
ids.append(str(artist.get("id") or ""))
|
||||||
return names, ids
|
return names, ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _credit_aliases(cls, credits: Optional[list[dict[str, Any]]]) -> list[str]:
|
||||||
|
"""保留 artist-credit 中同一人的实际署名与来源别名,不丢弃外文艺名。"""
|
||||||
|
names: list[str] = []
|
||||||
|
for credit in credits or []:
|
||||||
|
artist = credit.get("artist") or {}
|
||||||
|
names.extend(unique_music_texts([
|
||||||
|
credit.get("name"), artist.get("name"), *cls._names_of(artist.get("aliases")),
|
||||||
|
]))
|
||||||
|
return unique_music_texts(names)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _names_of(items: Optional[list[dict[str, Any]]]) -> list[str]:
|
def _names_of(items: Optional[list[dict[str, Any]]]) -> list[str]:
|
||||||
"""提取 MusicBrainz 风格、标签或别名列表的名称,热度高的排在前面。"""
|
"""提取 MusicBrainz 风格、标签或别名列表的名称,热度高的排在前面。"""
|
||||||
|
|||||||
@@ -499,6 +499,8 @@ class Context(BaseModel):
|
|||||||
candidate_recognized: Optional[bool] = False
|
candidate_recognized: Optional[bool] = False
|
||||||
# 当前 media_info 是否为目标媒体回填
|
# 当前 media_info 是否为目标媒体回填
|
||||||
media_info_is_target: Optional[bool] = False
|
media_info_is_target: Optional[bool] = False
|
||||||
|
match_status: Optional[str] = None
|
||||||
|
match_reason: Optional[str] = None
|
||||||
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源
|
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源
|
||||||
confirmed_full_coverage: Optional[bool] = False
|
confirmed_full_coverage: Optional[bool] = False
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
artist_country: Optional[str] = None
|
artist_country: Optional[str] = None
|
||||||
release_status: Optional[str] = None
|
release_status: Optional[str] = None
|
||||||
names: list[str] = Field(default_factory=list)
|
names: list[str] = Field(default_factory=list)
|
||||||
|
title_aliases: list[str] = Field(default_factory=list)
|
||||||
|
album_aliases: list[str] = Field(default_factory=list)
|
||||||
|
artist_aliases: list[str] = Field(default_factory=list)
|
||||||
detail_link: Optional[str] = None
|
detail_link: Optional[str] = None
|
||||||
listen_count: Optional[int] = None
|
listen_count: Optional[int] = None
|
||||||
raw_data: dict[str, JsonData] = Field(default_factory=dict)
|
raw_data: dict[str, JsonData] = Field(default_factory=dict)
|
||||||
@@ -154,6 +157,8 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
artists: list[str] = Field(default_factory=list)
|
artists: list[str] = Field(default_factory=list)
|
||||||
artist: Optional[str] = None
|
artist: Optional[str] = None
|
||||||
artist_ids: list[str] = Field(default_factory=list)
|
artist_ids: list[str] = Field(default_factory=list)
|
||||||
|
title_aliases: list[str] = Field(default_factory=list)
|
||||||
|
artist_aliases: list[str] = Field(default_factory=list)
|
||||||
album: Optional[str] = None
|
album: Optional[str] = None
|
||||||
album_type: Optional[str] = None
|
album_type: Optional[str] = None
|
||||||
secondary_types: list[str] = Field(default_factory=list)
|
secondary_types: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ flowchart LR
|
|||||||
| 指标 | 当前值 |
|
| 指标 | 当前值 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Python 模块 | 973 |
|
| Python 模块 | 973 |
|
||||||
| 内部导入边 | 8,239 |
|
| 内部导入边 | 8,259 |
|
||||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 音乐与影视搜索流程统一
|
||||||
|
|
||||||
|
## 目标与边界
|
||||||
|
|
||||||
|
电影、剧集和音乐共用识别、资源搜索、过滤、匹配与结果发布的编排。
|
||||||
|
媒体类型决定元数据解析器、关键词、身份和范围匹配规则,不拥有另一套
|
||||||
|
站点请求循环、提前停止条件、缓存、进度和上下文生命周期。
|
||||||
|
|
||||||
|
资源解析结果是资源自身的证据。目标媒体信息是用于比较和后续交付的目标,
|
||||||
|
不能先把目标名称、艺术家或 ID 写回资源元数据,再将其当作识别成功。
|
||||||
|
|
||||||
|
## 改造前调用链审计
|
||||||
|
|
||||||
|
基线为 `v3@76174b11d6b26793f1471e77e9453f3c7fbb54aa`。
|
||||||
|
|
||||||
|
| 环节 | 影视 | 音乐 | 判定 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 媒体身份识别 | `RecognitionChainMixin` 的识别计划与步骤 | 同一计划,原生动作选择音乐来源链 | 已共用主流程;保留来源适配差异 |
|
||||||
|
| 原生与插件回退 | `MediaRecognitionOwner` + `MediaPluginOwner` | 同一选择状态机,事件和字段不同 | 已共用;不复制状态机 |
|
||||||
|
| 路径证据 | 文件名、父目录、已有身份 | 指纹、音频标签、文件名及专辑目录 | 必要差异;每级仍复用统一媒体识别入口 |
|
||||||
|
| 元数据目录搜索 | `MediaSearchOwner.search` | `MediaCatalogOwner.search_music` | 统一上层入口与结果收口,来源模块 ABI 保持兼容 |
|
||||||
|
| 资源解析 | `MetaInfo(title, subtitle)` | 精确搜索未解析;标题搜索仅构造 `MetaMusic`;RSS 只解析标题 | 应统一至具备媒体类型意图的 `MetaInfo` 工厂 |
|
||||||
|
| 精确资源搜索 | `SearchMediaOwner` 的准备、请求、解析步骤 | 三个独立音乐搜索循环 | 应移除音乐旁路,使用同一搜索状态机 |
|
||||||
|
| 停止换词 | 影视收到原始资源即停止,之后才过滤 | 音乐通过字符串匹配即停止,之后才过滤 | 均应基于最终可用的精确结果停止 |
|
||||||
|
| 资源匹配 | 解析作品名/年份/季集/身份,必要时消歧 | 标题和副标题字符串包含作品名及艺术家 | 应共用匹配阶段;具体规则由媒体类型提供 |
|
||||||
|
| 上下文生成 | 资源元数据与目标媒体分开 | 用目标信息重新构造资源 `MetaMusic` | 应共用上下文生成,并保留原始证据 |
|
||||||
|
| 过滤与排序 | 搜索结果 owner 调用规则模块及种子工具 | 音乐重复实现同一规则调用与排序 | 应在同一结果处理 owner 收口 |
|
||||||
|
| 进度和缓存 | 原始候选及最终替换结果 | 仅统计已经匹配的候选 | 应统一原始召回、过滤结果、最终发布语义 |
|
||||||
|
| 主动订阅搜索 | 共用 `SearchChain.process` 并处理站点预算事实 | 自行循环 `search_by_title`,未经过同一预算结果处理 | 应复用同一订阅搜索编排 |
|
||||||
|
| RSS 订阅匹配 | 使用资源元数据、身份和订阅约束 | 单独字符串匹配并回填目标元数据 | 复用相同音乐解析/匹配规则,保留 RSS 不再查询站点的语义 |
|
||||||
|
| 下载提交 | 共用批量选择、失败冷却和单次提交 | 已与电影共用直接候选下载 | 保留共用提交;禁止人工候选自动下载 |
|
||||||
|
| 范围完成 | 剧集按缺集/全集覆盖 | 专辑按独立音轨数量和文件清单覆盖 | 必要差异,不将专辑等同于单曲或电影 |
|
||||||
|
|
||||||
|
审计入口:`app/chain/_recognition.py`、`app/chain/media/recognition.py`、
|
||||||
|
`app/chain/media/path.py`、`app/domain/metainfo.py`、`app/chain/search/media.py`、
|
||||||
|
`app/chain/search/result.py`、`app/chain/_music.py`、`app/chain/subscribe/search.py`、
|
||||||
|
`app/chain/torrents.py`、`app/chain/download/selection.py`。
|
||||||
|
|
||||||
|
## 统一后的资源流程
|
||||||
|
|
||||||
|
1. 校验来源身份并取得目标媒体快照;不改变显式来源和默认来源约定。
|
||||||
|
2. 复制目标,完成适用的附加信息补全,按媒体类型生成有序关键词与范围约束。
|
||||||
|
3. 通过现有 provider 层执行站点选择、分页、节流、预算和查询。
|
||||||
|
4. 对原始候选计数;通过统一 `MetaInfo` 工厂解析主副标题。
|
||||||
|
5. 在同一结果处理阶段应用规则和媒体匹配策略,记录被淘汰原因。
|
||||||
|
6. 构造保留资源自身证据的 `Context`,排序、去重。人工候选不绑定目标身份。
|
||||||
|
7. 应用调用方提供的候选约束(如订阅当前洗版优先级);有经过完整过滤的精确结果且没有要求多名称搜索时停止,否则继续换词。
|
||||||
|
8. 统一发布流式替换/完成事件并缓存最终结果。
|
||||||
|
|
||||||
|
同步、异步和流式入口只负责不同的 I/O 驱动,不各自决定媒体业务分支。
|
||||||
|
音乐 owner 保留关键词和兼容门面,不再拥有完整搜索或结果处理循环。
|
||||||
|
|
||||||
|
实现由 `app/chain/search/execution.py` 驱动统一状态机,`result.py` 统一处理候选;
|
||||||
|
`MediaChain.search` / `async_search` 统一元数据目录入口,来源服务只适配既有模块 ABI。
|
||||||
|
`app/chain/subscribe/metadata.py` 准备目标,`subscribe/search.py` 统一主动搜索和站点预算处理。
|
||||||
|
|
||||||
|
## 音乐策略
|
||||||
|
|
||||||
|
- `MetaMusic.parse_resource` 接受标题与副标题,复用现有 Python/Rust 标题解析,
|
||||||
|
补充明确艺术家/专辑字段、曲序结构和版本证据。缺少分隔且语义不明确时不猜测。
|
||||||
|
- 展示繁转简前保留名称原文。作品、所属专辑与艺术家别名分开保存;单曲不使用
|
||||||
|
兼容 `names` 中混入的专辑名称。别名仅来自同一实体的来源数据。
|
||||||
|
- 完整名称及署名优先;短名称不能仅凭子串命中另一首作品。署名冲突、未知分类、
|
||||||
|
不确定发行版本、不同录音版本或关联专辑只能成为显式请求的人工候选。
|
||||||
|
- 单曲所属专辑可以作为人工搜索的后续查询,但不能自动认定包含目标单曲。
|
||||||
|
- 元数据源各自排序和去重后公平合并,避免首个来源独占返回条数上限。
|
||||||
|
- MusicBrainz 以完整名称的繁简短语组优先,完整查询无结果才启用逐字兜底,
|
||||||
|
并按完整名称重排有界候选窗口。2026-09-05 公网查询
|
||||||
|
`recording:"晴天" AND artist:"周杰伦"` 返回 10 个录音候选,前三项均为标题
|
||||||
|
“晴天”、艺术家“周杰倫”,证明中文完整短语不是零命中。
|
||||||
|
查询字段与短语语义参考 [MusicBrainz 官方检索语法](https://musicbrainz.org/doc/Indexed_Search_Syntax)。
|
||||||
|
- 不根据展示文本臆造 MusicBrainz ID,不更改默认 MusicBrainz 或显式单来源行为。
|
||||||
|
|
||||||
|
## 订阅与下载
|
||||||
|
|
||||||
|
订阅共用搜索入口、取消检查和站点预算事实处理。音乐实体合法性、总曲目数、
|
||||||
|
音质洗版优先级和整专完成判定作为领域步骤保留;不额外执行另一套关键词循环。
|
||||||
|
RSS 复用解析和匹配策略,但不重新发起站点搜索。
|
||||||
|
|
||||||
|
人工候选必须有清晰状态,默认自动 API/订阅仍只采用精确结果。候选进入手动下载
|
||||||
|
确认时仅提交资源本身;自动批量选择也拒绝待确认项,防止缓存或其他调用路径绕过。
|
||||||
|
|
||||||
|
## 验证要求
|
||||||
|
|
||||||
|
- 电影、剧集、音乐参数化验证同一请求顺序、过滤后停止、空结果继续与最终缓存。
|
||||||
|
- 同步、异步和 SSE 的结果、范围、来源、候选计数和停止条件一致。
|
||||||
|
- 影视 IMDb/别名消歧、季集、用户过滤规则、分页与预算行为不回退。
|
||||||
|
- 音乐主副标题、繁简原文、可信别名、名称边界、版本、曲序和专辑范围回归。
|
||||||
|
- 音乐主动订阅与 RSS 使用一致的匹配依据,资源元数据不能被目标信息污染。
|
||||||
|
- 人工候选、缺少 ID 的部分元数据及旧缓存不能流入自动下载。
|
||||||
|
- 架构、复杂度、异步阻塞、类型、增量静态检查与后端全量测试通过。
|
||||||
|
- 前端完整测试及桌面/窄屏操作检查通过后,完成最终提交 CI 和现版本发布验收。
|
||||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
|||||||
|
|
||||||
| 指标 | 当前值 | 解释 |
|
| 指标 | 当前值 | 解释 |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| 宿主 Python 模块 / 内部依赖边 | 974 / 8,239 | `dependency-baseline.json` 当前快照 |
|
| 宿主 Python 模块 / 内部依赖边 | 976 / 8,259 | `dependency-baseline.json` 当前快照 |
|
||||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||||
@@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
|||||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||||
| 全量 mypy 历史债务 | 9,508 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
| 全量 mypy 历史债务 | 9,502 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||||
| Ruff 历史诊断 | 546 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
| Ruff 历史诊断 | 542 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||||
|
|
||||||
### 3.3 热点文件
|
### 3.3 热点文件
|
||||||
|
|||||||
+12
-1
@@ -293,7 +293,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
|||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype`、`area`、`season`、`sites`、`music_type` |
|
| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype`、`area`、`season`、`sites`、`music_type`、`include_candidates` |
|
||||||
| GET | `/api/v1/search/media/{media_id}/stream` | 按统一媒体身份渐进式搜索站点种子资源,返回 SSE,参数同上 |
|
| GET | `/api/v1/search/media/{media_id}/stream` | 按统一媒体身份渐进式搜索站点种子资源,返回 SSE,参数同上 |
|
||||||
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 |
|
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 |
|
||||||
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` |
|
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` |
|
||||||
@@ -326,6 +326,17 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
|||||||
|
|
||||||
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
|
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型。`music_type=recording` 表示单曲,`album` 表示包含多首曲目的完整专辑,`artist` 仅用于浏览;稳定身份分别使用对应的 `musicbrainz:<mbid>`。单曲和专辑可进入搜索、订阅、下载、整理、刮削和已配置音乐媒体服务器的入库检查,艺术家不能作为订阅或下载目标。
|
||||||
|
|
||||||
|
音乐与影视共用媒体搜索、资源查询、过滤、匹配和订阅搜索编排。资源 `meta_info` 来自
|
||||||
|
标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases`、`album_aliases`、
|
||||||
|
`artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。
|
||||||
|
|
||||||
|
音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`,
|
||||||
|
额外返回待确认资源及关联专辑:`match_status=candidate`、`match_reason` 描述原因,
|
||||||
|
且 `media_info` 为空、不绑定目标 ID;精确结果为 `match_status=exact`。自动订阅和批量下载
|
||||||
|
不采用待确认项。单曲的关联专辑不代表已经确认包含该单曲,专辑下载仍需检查曲目覆盖。
|
||||||
|
SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身份、分类及规则淘汰原因。
|
||||||
|
只有完整过滤后还有精确结果时才会按多名称设置提前停止;音乐元数据多来源结果先各自去重再公平合并。
|
||||||
|
|
||||||
音乐识别结果同时提供 `audio_format`、`audio_lossless`、`audio_quality`、`bit_depth`、`sample_rate`、`bitrate`、`audio_specs` 和 `audio_quality_score`。本地文件识别读取实际音频流参数,并使用 Chromaprint 的 `fpcalc` 在本地生成指纹后查询 AcoustID;音频文件本身不会上传。站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
|
音乐识别结果同时提供 `audio_format`、`audio_lossless`、`audio_quality`、`bit_depth`、`sample_rate`、`bitrate`、`audio_specs` 和 `audio_quality_score`。本地文件识别读取实际音频流参数,并使用 Chromaprint 的 `fpcalc` 在本地生成指纹后查询 AcoustID;音频文件本身不会上传。站点资源识别从标题和描述提取声明参数;码率、采样率的存储单位分别为 bps 和 Hz。
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@@ -629,6 +629,14 @@ The retired `app/chain/search.py` monolith, internal root re-exports and `source
|
|||||||
copies must not return. Search state normalization and persistence remain owned by
|
copies must not return. Search state normalization and persistence remain owned by
|
||||||
`app.application.search.state`; the Chain package only adapts its cache ports.
|
`app.application.search.state`; the Chain package only adapts its cache ports.
|
||||||
|
|
||||||
|
Movies, TV and music share the state machine in `search/execution.py` and the
|
||||||
|
filter/match/context pipeline in `search/result.py`. `search/music.py` owns only
|
||||||
|
music keyword policy and compatibility forwarding methods, not another provider
|
||||||
|
loop. `MetaInfo(..., mtype=...)` chooses the resource parser; `domain/music.py`
|
||||||
|
owns pure music identity rules. Unconfirmed candidates retain their own parsed
|
||||||
|
evidence and never receive the selected target's identity. Subscription search
|
||||||
|
uses the same SearchChain instance and site-budget result handling for all media.
|
||||||
|
|
||||||
Media recognition orchestration is owned by the same-named `app.chain.media`
|
Media recognition orchestration is owned by the same-named `app.chain.media`
|
||||||
package. Its root lazily exposes only the stable `MediaChain`; `facade.py` preserves
|
package. Its root lazily exposes only the stable `MediaChain`; `facade.py` preserves
|
||||||
the direct `MediaChain -> ChainBase` MRO, Singleton class identity and official
|
the direct `MediaChain -> ChainBase` MRO, Singleton class identity and official
|
||||||
@@ -1047,7 +1055,7 @@ driven workflow registration.
|
|||||||
| `app/application/history.py` | History use cases; deeply frozen DownloadHistory/TransferHistory DTOs and typed query/write/staging ports |
|
| `app/application/history.py` | History use cases; deeply frozen DownloadHistory/TransferHistory DTOs and typed query/write/staging ports |
|
||||||
| `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter |
|
| `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter |
|
||||||
| `app/chain/download/` | Stable DownloadChain facade plus single-owner selection, submission, batch, existence, failure, history, post-processing, subtitle, task and technical-port modules |
|
| `app/chain/download/` | Stable DownloadChain facade plus single-owner selection, submission, batch, existence, failure, history, post-processing, subtitle, task and technical-port modules |
|
||||||
| `app/chain/search/` | Stable SearchChain facade plus single-owner plan, provider, pagination, result, cache, title, media, music, subtitle, site and recommendation modules |
|
| `app/chain/search/` | Stable SearchChain facade plus shared execution, plan, provider, pagination, result, cache, title, media, music policy, subtitle, site and recommendation owners |
|
||||||
| `app/chain/media/` | Stable MediaChain facade plus single-owner recognition, plugin, auxiliary, projection, search, catalog, path, album and bounded cache modules |
|
| `app/chain/media/` | Stable MediaChain facade plus single-owner recognition, plugin, auxiliary, projection, search, catalog, path, album and bounded cache modules |
|
||||||
| `app/db/adapters/history/transfer.py` | TransferHistory short-session snapshot/query/mutation adapter and caller-owned transaction stager |
|
| `app/db/adapters/history/transfer.py` | TransferHistory short-session snapshot/query/mutation adapter and caller-owned transaction stager |
|
||||||
| `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts |
|
| `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts |
|
||||||
|
|||||||
@@ -889,7 +889,7 @@ Purpose: Search torrent sites directly from a free-form title and optional media
|
|||||||
Purpose: Search torrent sites for one canonical media identity.
|
Purpose: Search torrent sites for one canonical media identity.
|
||||||
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
|
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
|
||||||
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
|
- `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search.
|
||||||
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
- `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `include_candidates` (boolean; default `False`): Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope.
|
||||||
- `body`: none
|
- `body`: none
|
||||||
|
|
||||||
### `site.add`
|
### `site.add`
|
||||||
@@ -1702,6 +1702,7 @@ Subscription refresh execution status and progress summary.
|
|||||||
- `can_cancel` (boolean; default `False`): Whether the current subscription execution can be cancelled.
|
- `can_cancel` (boolean; default `False`): Whether the current subscription execution can be cancelled.
|
||||||
- `current_site_id` (integer|null): Configured site ID currently handling the subscription execution.
|
- `current_site_id` (integer|null): Configured site ID currently handling the subscription execution.
|
||||||
- `error` (string|null): Human-readable workflow, provider, or execution error message.
|
- `error` (string|null): Human-readable workflow, provider, or execution error message.
|
||||||
|
- `next_run_at` (string|null): Next scheduled subscription search time. Null when no future execution is planned.
|
||||||
- `phase*` (string): Current phase of a subscription execution.
|
- `phase*` (string): Current phase of a subscription execution.
|
||||||
- `source` (string|null): Exact metadata or recommendation source selected by the operation.
|
- `source` (string|null): Exact metadata or recommendation source selected by the operation.
|
||||||
- `state*` (string): Current site, subscription, marketplace, or transfer state filter.
|
- `state*` (string): Current site, subscription, marketplace, or transfer state filter.
|
||||||
|
|||||||
+2
-10
@@ -336,17 +336,9 @@
|
|||||||
},
|
},
|
||||||
"target": "app.runtime.execution.run_in_threadpool"
|
"target": "app.runtime.execution.run_in_threadpool"
|
||||||
},
|
},
|
||||||
"app/chain/search/media.py:app.runtime.execution.run_in_threadpool": {
|
"app/chain/search/execution.py:app.runtime.execution.run_in_threadpool": {
|
||||||
"owners": {
|
"owners": {
|
||||||
"SearchMediaOwner._run_media_process_async": 1,
|
"SearchExecutionOwner.events": 2
|
||||||
"SearchMediaOwner.async_process_stream": 1
|
|
||||||
},
|
|
||||||
"target": "app.runtime.execution.run_in_threadpool"
|
|
||||||
},
|
|
||||||
"app/chain/search/music.py:app.runtime.execution.run_in_threadpool": {
|
|
||||||
"owners": {
|
|
||||||
"SearchMusicOwner._async_process_music": 1,
|
|
||||||
"SearchMusicOwner._async_process_music_stream": 1
|
|
||||||
},
|
},
|
||||||
"target": "app.runtime.execution.run_in_threadpool"
|
"target": "app.runtime.execution.run_in_threadpool"
|
||||||
},
|
},
|
||||||
|
|||||||
+37
-15
@@ -1089,8 +1089,8 @@
|
|||||||
"runtime_only": true
|
"runtime_only": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"edge_count": 8239,
|
"edge_count": 8259,
|
||||||
"edge_sha256": "5d2e9b412b62fe2a06ff4dc64489b32ab85d63c6355ba97432f566145b86c0d1",
|
"edge_sha256": "f5f1d4cc3a4a6a659d1b3955995b605c8cc85be099cba76488ac3410935547cf",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.foundation",
|
"app -> app.foundation",
|
||||||
"app -> app.foundation.environment",
|
"app -> app.foundation.environment",
|
||||||
@@ -2241,7 +2241,6 @@
|
|||||||
"app.api.endpoints.download -> app.domain.media",
|
"app.api.endpoints.download -> app.domain.media",
|
||||||
"app.api.endpoints.download -> app.domain.meta",
|
"app.api.endpoints.download -> app.domain.meta",
|
||||||
"app.api.endpoints.download -> app.domain.meta.metabase",
|
"app.api.endpoints.download -> app.domain.meta.metabase",
|
||||||
"app.api.endpoints.download -> app.domain.meta.metamusic",
|
|
||||||
"app.api.endpoints.download -> app.domain.metainfo",
|
"app.api.endpoints.download -> app.domain.metainfo",
|
||||||
"app.api.endpoints.download -> app.schemas",
|
"app.api.endpoints.download -> app.schemas",
|
||||||
"app.api.endpoints.download -> app.schemas.common",
|
"app.api.endpoints.download -> app.schemas.common",
|
||||||
@@ -4317,6 +4316,7 @@
|
|||||||
"app.chain.media.search -> app.domain.context",
|
"app.chain.media.search -> app.domain.context",
|
||||||
"app.chain.media.search -> app.domain.meta",
|
"app.chain.media.search -> app.domain.meta",
|
||||||
"app.chain.media.search -> app.domain.meta.metabase",
|
"app.chain.media.search -> app.domain.meta.metabase",
|
||||||
|
"app.chain.media.search -> app.domain.meta.metamusic",
|
||||||
"app.chain.media.search -> app.domain.metainfo",
|
"app.chain.media.search -> app.domain.metainfo",
|
||||||
"app.chain.media.search -> app.domain.title",
|
"app.chain.media.search -> app.domain.title",
|
||||||
"app.chain.media.search -> app.runtime",
|
"app.chain.media.search -> app.runtime",
|
||||||
@@ -4432,6 +4432,19 @@
|
|||||||
"app.chain.search.cache -> app.schemas.types",
|
"app.chain.search.cache -> app.schemas.types",
|
||||||
"app.chain.search.contract -> app.chain",
|
"app.chain.search.contract -> app.chain",
|
||||||
"app.chain.search.contract -> app.chain.base",
|
"app.chain.search.contract -> app.chain.base",
|
||||||
|
"app.chain.search.execution -> app.chain",
|
||||||
|
"app.chain.search.execution -> app.chain.media",
|
||||||
|
"app.chain.search.execution -> app.chain.search",
|
||||||
|
"app.chain.search.execution -> app.chain.search.contract",
|
||||||
|
"app.chain.search.execution -> app.chain.search.plan",
|
||||||
|
"app.chain.search.execution -> app.domain",
|
||||||
|
"app.chain.search.execution -> app.domain.context",
|
||||||
|
"app.chain.search.execution -> app.domain.metainfo",
|
||||||
|
"app.chain.search.execution -> app.runtime",
|
||||||
|
"app.chain.search.execution -> app.runtime.execution",
|
||||||
|
"app.chain.search.execution -> app.runtime.log",
|
||||||
|
"app.chain.search.execution -> app.schemas",
|
||||||
|
"app.chain.search.execution -> app.schemas.mediaserver",
|
||||||
"app.chain.search.facade -> app.application",
|
"app.chain.search.facade -> app.application",
|
||||||
"app.chain.search.facade -> app.application.subscription",
|
"app.chain.search.facade -> app.application.subscription",
|
||||||
"app.chain.search.facade -> app.application.subscription.sitebudget",
|
"app.chain.search.facade -> app.application.subscription.sitebudget",
|
||||||
@@ -4460,20 +4473,15 @@
|
|||||||
"app.chain.search.media -> app.chain.media",
|
"app.chain.search.media -> app.chain.media",
|
||||||
"app.chain.search.media -> app.chain.search",
|
"app.chain.search.media -> app.chain.search",
|
||||||
"app.chain.search.media -> app.chain.search.contract",
|
"app.chain.search.media -> app.chain.search.contract",
|
||||||
|
"app.chain.search.media -> app.chain.search.execution",
|
||||||
"app.chain.search.media -> app.domain",
|
"app.chain.search.media -> app.domain",
|
||||||
"app.chain.search.media -> app.domain.context",
|
"app.chain.search.media -> app.domain.context",
|
||||||
"app.chain.search.media -> app.domain.metainfo",
|
|
||||||
"app.chain.search.media -> app.runtime",
|
"app.chain.search.media -> app.runtime",
|
||||||
"app.chain.search.media -> app.runtime.execution",
|
|
||||||
"app.chain.search.media -> app.runtime.log",
|
"app.chain.search.media -> app.runtime.log",
|
||||||
"app.chain.search.media -> app.schemas",
|
"app.chain.search.media -> app.schemas",
|
||||||
"app.chain.search.media -> app.schemas.media",
|
"app.chain.search.media -> app.schemas.media",
|
||||||
"app.chain.search.media -> app.schemas.mediaserver",
|
"app.chain.search.media -> app.schemas.mediaserver",
|
||||||
"app.chain.search.media -> app.schemas.types",
|
"app.chain.search.media -> app.schemas.types",
|
||||||
"app.chain.search.music -> app.application",
|
|
||||||
"app.chain.search.music -> app.application.configuration",
|
|
||||||
"app.chain.search.music -> app.application.torrent",
|
|
||||||
"app.chain.search.music -> app.application.torrent.download",
|
|
||||||
"app.chain.search.music -> app.chain",
|
"app.chain.search.music -> app.chain",
|
||||||
"app.chain.search.music -> app.chain.search",
|
"app.chain.search.music -> app.chain.search",
|
||||||
"app.chain.search.music -> app.chain.search.contract",
|
"app.chain.search.music -> app.chain.search.contract",
|
||||||
@@ -4481,10 +4489,9 @@
|
|||||||
"app.chain.search.music -> app.domain.context",
|
"app.chain.search.music -> app.domain.context",
|
||||||
"app.chain.search.music -> app.domain.meta",
|
"app.chain.search.music -> app.domain.meta",
|
||||||
"app.chain.search.music -> app.domain.meta.metamusic",
|
"app.chain.search.music -> app.domain.meta.metamusic",
|
||||||
|
"app.chain.search.music -> app.domain.music",
|
||||||
"app.chain.search.music -> app.foundation",
|
"app.chain.search.music -> app.foundation",
|
||||||
"app.chain.search.music -> app.foundation.text",
|
"app.chain.search.music -> app.foundation.text",
|
||||||
"app.chain.search.music -> app.runtime",
|
|
||||||
"app.chain.search.music -> app.runtime.execution",
|
|
||||||
"app.chain.search.music -> app.schemas",
|
"app.chain.search.music -> app.schemas",
|
||||||
"app.chain.search.music -> app.schemas.types",
|
"app.chain.search.music -> app.schemas.types",
|
||||||
"app.chain.search.pagination -> app.application",
|
"app.chain.search.pagination -> app.application",
|
||||||
@@ -4500,8 +4507,10 @@
|
|||||||
"app.chain.search.plan -> app.chain",
|
"app.chain.search.plan -> app.chain",
|
||||||
"app.chain.search.plan -> app.chain.search",
|
"app.chain.search.plan -> app.chain.search",
|
||||||
"app.chain.search.plan -> app.chain.search.contract",
|
"app.chain.search.plan -> app.chain.search.contract",
|
||||||
|
"app.chain.search.plan -> app.chain.search.music",
|
||||||
"app.chain.search.plan -> app.domain",
|
"app.chain.search.plan -> app.domain",
|
||||||
"app.chain.search.plan -> app.domain.context",
|
"app.chain.search.plan -> app.domain.context",
|
||||||
|
"app.chain.search.plan -> app.domain.metainfo",
|
||||||
"app.chain.search.plan -> app.schemas",
|
"app.chain.search.plan -> app.schemas",
|
||||||
"app.chain.search.plan -> app.schemas.media",
|
"app.chain.search.plan -> app.schemas.media",
|
||||||
"app.chain.search.plan -> app.schemas.mediaserver",
|
"app.chain.search.plan -> app.schemas.mediaserver",
|
||||||
@@ -4549,7 +4558,9 @@
|
|||||||
"app.chain.search.result -> app.domain.context",
|
"app.chain.search.result -> app.domain.context",
|
||||||
"app.chain.search.result -> app.domain.meta",
|
"app.chain.search.result -> app.domain.meta",
|
||||||
"app.chain.search.result -> app.domain.meta.metabase",
|
"app.chain.search.result -> app.domain.meta.metabase",
|
||||||
|
"app.chain.search.result -> app.domain.meta.metamusic",
|
||||||
"app.chain.search.result -> app.domain.metainfo",
|
"app.chain.search.result -> app.domain.metainfo",
|
||||||
|
"app.chain.search.result -> app.domain.music",
|
||||||
"app.chain.search.result -> app.runtime",
|
"app.chain.search.result -> app.runtime",
|
||||||
"app.chain.search.result -> app.runtime.log",
|
"app.chain.search.result -> app.runtime.log",
|
||||||
"app.chain.search.result -> app.runtime.progress",
|
"app.chain.search.result -> app.runtime.progress",
|
||||||
@@ -4593,8 +4604,6 @@
|
|||||||
"app.chain.search.title -> app.chain.search.contract",
|
"app.chain.search.title -> app.chain.search.contract",
|
||||||
"app.chain.search.title -> app.domain",
|
"app.chain.search.title -> app.domain",
|
||||||
"app.chain.search.title -> app.domain.context",
|
"app.chain.search.title -> app.domain.context",
|
||||||
"app.chain.search.title -> app.domain.meta",
|
|
||||||
"app.chain.search.title -> app.domain.meta.metamusic",
|
|
||||||
"app.chain.search.title -> app.domain.metainfo",
|
"app.chain.search.title -> app.domain.metainfo",
|
||||||
"app.chain.search.title -> app.runtime",
|
"app.chain.search.title -> app.runtime",
|
||||||
"app.chain.search.title -> app.runtime.execution",
|
"app.chain.search.title -> app.runtime.execution",
|
||||||
@@ -4773,10 +4782,13 @@
|
|||||||
"app.chain.subscribe.metadata -> app.chain.subscribe.identity",
|
"app.chain.subscribe.metadata -> app.chain.subscribe.identity",
|
||||||
"app.chain.subscribe.metadata -> app.domain",
|
"app.chain.subscribe.metadata -> app.domain",
|
||||||
"app.chain.subscribe.metadata -> app.domain.context",
|
"app.chain.subscribe.metadata -> app.domain.context",
|
||||||
|
"app.chain.subscribe.metadata -> app.domain.meta",
|
||||||
|
"app.chain.subscribe.metadata -> app.domain.meta.metabase",
|
||||||
"app.chain.subscribe.metadata -> app.runtime",
|
"app.chain.subscribe.metadata -> app.runtime",
|
||||||
"app.chain.subscribe.metadata -> app.runtime.log",
|
"app.chain.subscribe.metadata -> app.runtime.log",
|
||||||
"app.chain.subscribe.metadata -> app.schemas",
|
"app.chain.subscribe.metadata -> app.schemas",
|
||||||
"app.chain.subscribe.metadata -> app.schemas.media",
|
"app.chain.subscribe.metadata -> app.schemas.media",
|
||||||
|
"app.chain.subscribe.metadata -> app.schemas.mediaserver",
|
||||||
"app.chain.subscribe.metadata -> app.schemas.types",
|
"app.chain.subscribe.metadata -> app.schemas.types",
|
||||||
"app.chain.subscribe.notify -> app.application",
|
"app.chain.subscribe.notify -> app.application",
|
||||||
"app.chain.subscribe.notify -> app.application.configuration",
|
"app.chain.subscribe.notify -> app.application.configuration",
|
||||||
@@ -4890,7 +4902,6 @@
|
|||||||
"app.chain.subscribe.search -> app.chain.search.facade",
|
"app.chain.subscribe.search -> app.chain.search.facade",
|
||||||
"app.chain.subscribe.search -> app.chain.subscribe",
|
"app.chain.subscribe.search -> app.chain.subscribe",
|
||||||
"app.chain.subscribe.search -> app.chain.subscribe.contract",
|
"app.chain.subscribe.search -> app.chain.subscribe.contract",
|
||||||
"app.chain.subscribe.search -> app.chain.subscribe.identity",
|
|
||||||
"app.chain.subscribe.search -> app.chain.subscribe.metadata",
|
"app.chain.subscribe.search -> app.chain.subscribe.metadata",
|
||||||
"app.chain.subscribe.search -> app.chain.subscribe.searchtask",
|
"app.chain.subscribe.search -> app.chain.subscribe.searchtask",
|
||||||
"app.chain.subscribe.search -> app.domain",
|
"app.chain.subscribe.search -> app.domain",
|
||||||
@@ -5945,6 +5956,14 @@
|
|||||||
"app.domain.metainfo -> app.schemas",
|
"app.domain.metainfo -> app.schemas",
|
||||||
"app.domain.metainfo -> app.schemas.media",
|
"app.domain.metainfo -> app.schemas.media",
|
||||||
"app.domain.metainfo -> app.schemas.types",
|
"app.domain.metainfo -> app.schemas.types",
|
||||||
|
"app.domain.music -> app.domain",
|
||||||
|
"app.domain.music -> app.domain.context",
|
||||||
|
"app.domain.music -> app.domain.meta",
|
||||||
|
"app.domain.music -> app.domain.meta.metamusic",
|
||||||
|
"app.domain.music -> app.foundation",
|
||||||
|
"app.domain.music -> app.foundation.text",
|
||||||
|
"app.domain.music -> app.schemas",
|
||||||
|
"app.domain.music -> app.schemas.types",
|
||||||
"app.domain.projection.anilist -> app.domain",
|
"app.domain.projection.anilist -> app.domain",
|
||||||
"app.domain.projection.anilist -> app.domain.metainfo",
|
"app.domain.projection.anilist -> app.domain.metainfo",
|
||||||
"app.domain.projection.anilist -> app.domain.projection",
|
"app.domain.projection.anilist -> app.domain.projection",
|
||||||
@@ -6896,6 +6915,7 @@
|
|||||||
"app.modules.musicbrainz -> app.domain.meta",
|
"app.modules.musicbrainz -> app.domain.meta",
|
||||||
"app.modules.musicbrainz -> app.domain.meta.metabase",
|
"app.modules.musicbrainz -> app.domain.meta.metabase",
|
||||||
"app.modules.musicbrainz -> app.domain.meta.metamusic",
|
"app.modules.musicbrainz -> app.domain.meta.metamusic",
|
||||||
|
"app.modules.musicbrainz -> app.domain.music",
|
||||||
"app.modules.musicbrainz -> app.foundation",
|
"app.modules.musicbrainz -> app.foundation",
|
||||||
"app.modules.musicbrainz -> app.foundation.text",
|
"app.modules.musicbrainz -> app.foundation.text",
|
||||||
"app.modules.musicbrainz -> app.modules",
|
"app.modules.musicbrainz -> app.modules",
|
||||||
@@ -9332,7 +9352,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 974,
|
"module_count": 976,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -9715,6 +9735,7 @@
|
|||||||
"app.chain.search",
|
"app.chain.search",
|
||||||
"app.chain.search.cache",
|
"app.chain.search.cache",
|
||||||
"app.chain.search.contract",
|
"app.chain.search.contract",
|
||||||
|
"app.chain.search.execution",
|
||||||
"app.chain.search.facade",
|
"app.chain.search.facade",
|
||||||
"app.chain.search.media",
|
"app.chain.search.media",
|
||||||
"app.chain.search.music",
|
"app.chain.search.music",
|
||||||
@@ -9892,6 +9913,7 @@
|
|||||||
"app.domain.meta.streamingplatform",
|
"app.domain.meta.streamingplatform",
|
||||||
"app.domain.meta.words",
|
"app.domain.meta.words",
|
||||||
"app.domain.metainfo",
|
"app.domain.metainfo",
|
||||||
|
"app.domain.music",
|
||||||
"app.domain.plugin",
|
"app.domain.plugin",
|
||||||
"app.domain.projection",
|
"app.domain.projection",
|
||||||
"app.domain.projection.anilist",
|
"app.domain.projection.anilist",
|
||||||
|
|||||||
+6
-6
@@ -428,8 +428,8 @@
|
|||||||
"no-untyped-call": 2
|
"no-untyped-call": 2
|
||||||
},
|
},
|
||||||
"app/api/endpoints/download.py": {
|
"app/api/endpoints/download.py": {
|
||||||
"arg-type": 7,
|
"arg-type": 6,
|
||||||
"assignment": 6,
|
"assignment": 5,
|
||||||
"attr-defined": 4,
|
"attr-defined": 4,
|
||||||
"misc": 10,
|
"misc": 10,
|
||||||
"type-arg": 2
|
"type-arg": 2
|
||||||
@@ -860,7 +860,7 @@
|
|||||||
"type-arg": 2
|
"type-arg": 2
|
||||||
},
|
},
|
||||||
"app/chain/base.py": {
|
"app/chain/base.py": {
|
||||||
"assignment": 13,
|
"assignment": 12,
|
||||||
"attr-defined": 1,
|
"attr-defined": 1,
|
||||||
"no-any-return": 47,
|
"no-any-return": 47,
|
||||||
"no-untyped-def": 7,
|
"no-untyped-def": 7,
|
||||||
@@ -983,7 +983,7 @@
|
|||||||
"var-annotated": 2
|
"var-annotated": 2
|
||||||
},
|
},
|
||||||
"app/chain/subscribe/search.py": {
|
"app/chain/subscribe/search.py": {
|
||||||
"assignment": 2
|
"assignment": 1
|
||||||
},
|
},
|
||||||
"app/chain/system.py": {
|
"app/chain/system.py": {
|
||||||
"attr-defined": 1,
|
"attr-defined": 1,
|
||||||
@@ -998,7 +998,7 @@
|
|||||||
"type-arg": 2
|
"type-arg": 2
|
||||||
},
|
},
|
||||||
"app/chain/torrents.py": {
|
"app/chain/torrents.py": {
|
||||||
"arg-type": 22,
|
"arg-type": 21,
|
||||||
"assignment": 2,
|
"assignment": 2,
|
||||||
"no-untyped-call": 1,
|
"no-untyped-call": 1,
|
||||||
"no-untyped-def": 15,
|
"no-untyped-def": 15,
|
||||||
@@ -2924,7 +2924,7 @@
|
|||||||
"var-annotated": 1
|
"var-annotated": 1
|
||||||
},
|
},
|
||||||
"app/workflow/actions/filter_torrents.py": {
|
"app/workflow/actions/filter_torrents.py": {
|
||||||
"arg-type": 3,
|
"arg-type": 2,
|
||||||
"list-item": 1,
|
"list-item": 1,
|
||||||
"no-untyped-call": 1,
|
"no-untyped-call": 1,
|
||||||
"type-arg": 1,
|
"type-arg": 1,
|
||||||
|
|||||||
-12
@@ -147,9 +147,6 @@
|
|||||||
"app/domain/meta/metabase.py": {
|
"app/domain/meta/metabase.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/domain/meta/metamusic.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/domain/meta/metavideo.py": {
|
"app/domain/meta/metavideo.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -167,9 +164,6 @@
|
|||||||
"E731": 1,
|
"E731": 1,
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/domain/metainfo.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/domain/scraper.py": {
|
"app/domain/scraper.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -785,9 +779,6 @@
|
|||||||
"tests/test_media_scrape_endpoint.py": {
|
"tests/test_media_scrape_endpoint.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"tests/test_media_search_source_selection.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"tests/test_media_source_signature_compatibility.py": {
|
"tests/test_media_source_signature_compatibility.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -851,9 +842,6 @@
|
|||||||
"tests/test_music_torrents.py": {
|
"tests/test_music_torrents.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"tests/test_musicbrainz_module.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"tests/test_navidrome_module.py": {
|
"tests/test_navidrome_module.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"repeat": 3,
|
"repeat": 3,
|
||||||
"targets": {
|
"targets": {
|
||||||
"app.startup.lifecycle": {
|
"app.startup.lifecycle": {
|
||||||
"loaded_app_module_count": 537,
|
"loaded_app_module_count": 539,
|
||||||
"max_ms": 1293.338,
|
"max_ms": 1293.338,
|
||||||
"median_ms": 1156.239,
|
"median_ms": 1156.239,
|
||||||
"min_ms": 1102.806,
|
"min_ms": 1102.806,
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.factory": {
|
"app.factory": {
|
||||||
"loaded_app_module_count": 549,
|
"loaded_app_module_count": 551,
|
||||||
"max_ms": 1127.911,
|
"max_ms": 1127.911,
|
||||||
"median_ms": 1122.382,
|
"median_ms": 1122.382,
|
||||||
"min_ms": 1119.221,
|
"min_ms": 1119.221,
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.main": {
|
"app.main": {
|
||||||
"loaded_app_module_count": 551,
|
"loaded_app_module_count": 553,
|
||||||
"max_ms": 1188.652,
|
"max_ms": 1188.652,
|
||||||
"median_ms": 1183.509,
|
"median_ms": 1183.509,
|
||||||
"min_ms": 1174.522,
|
"min_ms": 1174.522,
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ from app.chain.transfer.facade import TransferChain
|
|||||||
MediaChain,
|
MediaChain,
|
||||||
{
|
{
|
||||||
"normalize_music_candidates": "MusicCatalogService",
|
"normalize_music_candidates": "MusicCatalogService",
|
||||||
"search_music": "_music_catalog",
|
"search": "_music_catalog",
|
||||||
"async_search_music": "_music_catalog",
|
"async_search": "_music_catalog",
|
||||||
|
"search_music": "self.search",
|
||||||
|
"async_search_music": "self.async_search",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ def test_media_chain_preserves_official_plugin_method_contracts() -> None:
|
|||||||
("self", inspect.Parameter.empty),
|
("self", inspect.Parameter.empty),
|
||||||
("title", inspect.Parameter.empty),
|
("title", inspect.Parameter.empty),
|
||||||
("media_source", None),
|
("media_source", None),
|
||||||
|
("mtype", None),
|
||||||
|
("limit", 20),
|
||||||
)
|
)
|
||||||
assert _parameter_contract(MediaChain.async_search) == _parameter_contract(MediaChain.search)
|
assert _parameter_contract(MediaChain.async_search) == _parameter_contract(MediaChain.search)
|
||||||
assert inspect.iscoroutinefunction(MediaChain.async_search)
|
assert inspect.iscoroutinefunction(MediaChain.async_search)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""三种媒体、三种 I/O 模式必须使用同一搜索执行及资源证据处理流程。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.chain.search import SearchChain, execution, media
|
||||||
|
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mtype", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC])
|
||||||
|
@pytest.mark.parametrize("mode", ["sync", "async", "stream"])
|
||||||
|
def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtype, mode):
|
||||||
|
"""已召回但被过滤的资源不能终止任何媒体类型的后续查询。"""
|
||||||
|
chain = SearchChain()
|
||||||
|
chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False)
|
||||||
|
title = "Example Album" if mtype == MediaType.MUSIC else "Example Movie"
|
||||||
|
if mtype == MediaType.MUSIC:
|
||||||
|
target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="album", music_type="album",
|
||||||
|
title=title, artists=["Artist"], year=2024)
|
||||||
|
resource_title = "Artist - Example Album (2024) FLAC"
|
||||||
|
else:
|
||||||
|
target = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1,
|
||||||
|
title=title, names=[title], type=mtype, year="2024")
|
||||||
|
if mtype == MediaType.TV:
|
||||||
|
target.season_years = {1: "2024"}
|
||||||
|
resource_title = f"Example.Movie.2024{' S01E01' if mtype == MediaType.TV else ''}.1080p"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def search(**kwargs):
|
||||||
|
"""首轮未达到过滤要求,第二轮返回相同作品的可用资源。"""
|
||||||
|
calls.append(kwargs["keyword"])
|
||||||
|
return [TorrentInfo(title=resource_title, category=mtype.value,
|
||||||
|
labels=[] if len(calls) == 1 else ["SITE_ACCEPT"])]
|
||||||
|
|
||||||
|
async def async_search(**kwargs):
|
||||||
|
"""异步端口复用相同站点数据。"""
|
||||||
|
return search(**kwargs)
|
||||||
|
|
||||||
|
async def stream(**kwargs):
|
||||||
|
"""流式端口复用相同站点数据。"""
|
||||||
|
yield {"items": search(**kwargs), "value": 100}
|
||||||
|
|
||||||
|
async def supplement(mediainfo):
|
||||||
|
"""不在单元测试中访问远端附加信息。"""
|
||||||
|
return mediainfo
|
||||||
|
|
||||||
|
async def sleep(_delay):
|
||||||
|
"""跳过测试中的退避等待。"""
|
||||||
|
|
||||||
|
provider = SimpleNamespace(supplement_media_info=lambda mediainfo: mediainfo,
|
||||||
|
async_supplement_media_info=supplement)
|
||||||
|
monkeypatch.setattr(media, "MediaChain", lambda: provider)
|
||||||
|
monkeypatch.setattr(execution.time, "sleep", lambda _delay: None)
|
||||||
|
monkeypatch.setattr(execution.asyncio, "sleep", sleep)
|
||||||
|
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
||||||
|
chain._SearchChain__search_all_sites = search
|
||||||
|
chain._SearchChain__async_search_all_sites = async_search
|
||||||
|
chain._SearchChain__async_search_all_sites_stream = stream
|
||||||
|
params = {"mediainfo": target, "rule_groups": [], "filter_params": {"include": "SITE_ACCEPT"}}
|
||||||
|
if mode == "sync":
|
||||||
|
contexts = chain.process(**params)
|
||||||
|
elif mode == "async":
|
||||||
|
contexts = asyncio.run(chain.async_process(**params))
|
||||||
|
else:
|
||||||
|
async def collect():
|
||||||
|
"""收集预览和最终上下文,验证未匹配预览不携带目标身份。"""
|
||||||
|
return [event async for event in chain.async_process_stream(**params)]
|
||||||
|
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
for event in events:
|
||||||
|
if event["type"] == "append":
|
||||||
|
assert all(item["media_info"] is None for item in event["items"])
|
||||||
|
assert events[-1]["candidate_items"] == 2
|
||||||
|
contexts = events[-1]["contexts"]
|
||||||
|
assert calls == ["first", "second"]
|
||||||
|
assert len(contexts) == 1
|
||||||
|
assert contexts[0].match_status == "exact"
|
||||||
|
assert contexts[0].media_info.media_id == target.media_id
|
||||||
|
assert contexts[0].meta_info.media_id is None
|
||||||
|
assert isinstance(contexts[0].meta_info, MetaMusic) == (mtype == MediaType.MUSIC)
|
||||||
@@ -5,10 +5,10 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.adapters.web.security.access import verify_token
|
||||||
from app.api.endpoints import media as media_endpoints
|
from app.api.endpoints import media as media_endpoints
|
||||||
from app.api.endpoints.media import search
|
from app.api.endpoints.media import search
|
||||||
from app.chain.base import ChainBase
|
from app.chain.base import ChainBase
|
||||||
from app.adapters.web.security.access import verify_token
|
|
||||||
from app.modules.douban import DoubanModule
|
from app.modules.douban import DoubanModule
|
||||||
from app.modules.themoviedb import TheMovieDbModule
|
from app.modules.themoviedb import TheMovieDbModule
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
@@ -71,7 +71,7 @@ def test_media_search_endpoint_forwards_multi_source() -> None:
|
|||||||
async def test_media_search_route_accepts_comma_separated_music_sources() -> None:
|
async def test_media_search_route_accepts_comma_separated_music_sources() -> None:
|
||||||
"""真实路由在旧逗号格式兼容边界后应把每项转换为 MediaSource。"""
|
"""真实路由在旧逗号格式兼容边界后应把每项转换为 MediaSource。"""
|
||||||
chain = Mock()
|
chain = Mock()
|
||||||
chain.async_search_music = AsyncMock(return_value=[])
|
chain.async_search = AsyncMock(return_value=(None, []))
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(media_endpoints.router, prefix="/api/v1/media")
|
app.include_router(media_endpoints.router, prefix="/api/v1/media")
|
||||||
app.dependency_overrides[verify_token] = lambda: Mock()
|
app.dependency_overrides[verify_token] = lambda: Mock()
|
||||||
@@ -93,8 +93,9 @@ async def test_media_search_route_accepts_comma_separated_music_sources() -> Non
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"success": True, "message": "", "data": []}
|
assert response.json() == {"success": True, "message": "", "data": []}
|
||||||
chain.async_search_music.assert_awaited_once_with(
|
chain.async_search.assert_awaited_once_with(
|
||||||
query="周杰伦",
|
title="周杰伦",
|
||||||
|
mtype=MediaType.MUSIC,
|
||||||
limit=30,
|
limit=30,
|
||||||
media_source=(
|
media_source=(
|
||||||
MediaSource.MusicBrainz,
|
MediaSource.MusicBrainz,
|
||||||
|
|||||||
@@ -50,3 +50,12 @@ def test_music_catalog_service_isolates_failed_source():
|
|||||||
|
|
||||||
assert service.search("artist title") == []
|
assert service.search("artist title") == []
|
||||||
assert errors and "broken" in errors[0]
|
assert errors and "broken" in errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_catalog_merge_keeps_later_source_with_full_first_page():
|
||||||
|
"""第一来源达到条数上限也不能挤掉后来来源的准确目标。"""
|
||||||
|
first = [MusicInfo(media_source=MediaSource.MusicBrainz, media_id=str(index), title=f"Other {index}") for index in range(30)]
|
||||||
|
second = MusicInfo(media_source=MediaSource.DoubanMusic, media_id="exact", title="Target")
|
||||||
|
result = MusicCatalogService.merge_sources([first, [second]], limit=30)
|
||||||
|
assert len(result) == 30
|
||||||
|
assert result[1] is second
|
||||||
|
|||||||
@@ -170,6 +170,28 @@ def test_download_endpoint_builds_music_context():
|
|||||||
assert context.media_info.media_id == "recording-1"
|
assert context.media_info.media_id == "recording-1"
|
||||||
assert context.meta_info.type == MediaType.MUSIC
|
assert context.meta_info.type == MediaType.MUSIC
|
||||||
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
|
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
|
||||||
|
assert context.meta_info.title == "叶惠美"
|
||||||
|
assert context.meta_info.media_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_album_without_id_uses_subtitle_evidence():
|
||||||
|
"""没有 ID 的人工专辑下载保留实体意图,并使用副标题中的真实艺人识别。"""
|
||||||
|
media_chain = Mock()
|
||||||
|
media_chain.recognize_by_meta.return_value = _album_info()
|
||||||
|
download_chain = Mock()
|
||||||
|
download_chain.download_single.return_value = "album-task"
|
||||||
|
with patch("app.api.endpoints.download.MediaChain", return_value=media_chain), patch(
|
||||||
|
"app.api.endpoints.download.DownloadChain", return_value=download_chain
|
||||||
|
):
|
||||||
|
response = add(
|
||||||
|
torrent_in=TorrentInfo(title="叶惠美 FLAC", description="艺术家:周杰伦", category="未知"),
|
||||||
|
music_type="album", current_user=Mock(name="admin"),
|
||||||
|
)
|
||||||
|
assert response.success is True
|
||||||
|
meta = media_chain.recognize_by_meta.call_args.args[0]
|
||||||
|
assert meta.artists == ["周杰伦"]
|
||||||
|
assert meta.title == "叶惠美"
|
||||||
|
assert media_chain.recognize_by_meta.call_args.kwargs["music_type"] == "album"
|
||||||
|
|
||||||
|
|
||||||
def test_download_add_forwards_album_namespace_to_media_chain():
|
def test_download_add_forwards_album_namespace_to_media_chain():
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ def test_music_routes_are_registered():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_media_search_routes_music_queries_with_query_kwarg():
|
def test_media_search_routes_music_through_common_catalog_entry():
|
||||||
"""统一媒体搜索的音乐分支应以关键字参数调用 MediaChain。"""
|
"""音乐与影视应调用同一媒体搜索入口,仅传入不同的媒体类型。"""
|
||||||
|
|
||||||
chain = Mock()
|
chain = Mock()
|
||||||
chain.async_search_music = AsyncMock(
|
chain.async_search = AsyncMock(
|
||||||
return_value=[
|
return_value=(None, [
|
||||||
MusicInfo(
|
MusicInfo(
|
||||||
media_source="musicbrainz",
|
media_source="musicbrainz",
|
||||||
media_id="recording-1",
|
media_id="recording-1",
|
||||||
@@ -76,7 +76,7 @@ def test_media_search_routes_music_queries_with_query_kwarg():
|
|||||||
release_date="2003-07-31",
|
release_date="2003-07-31",
|
||||||
category="Album / Studio",
|
category="Album / Studio",
|
||||||
)
|
)
|
||||||
]
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -95,14 +95,14 @@ def test_media_search_routes_music_queries_with_query_kwarg():
|
|||||||
assert result[0]["media_id"] == "recording-1"
|
assert result[0]["media_id"] == "recording-1"
|
||||||
assert result[0]["music_type"] == "recording"
|
assert result[0]["music_type"] == "recording"
|
||||||
assert result[0]["title"] == "晴天"
|
assert result[0]["title"] == "晴天"
|
||||||
chain.async_search_music.assert_awaited_once_with(query="晴天", limit=30)
|
chain.async_search.assert_awaited_once_with(title="晴天", limit=30, mtype=MediaType.MUSIC, media_source=None)
|
||||||
media_chain.assert_called_once()
|
media_chain.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_media_search_forwards_explicit_music_source():
|
def test_media_search_forwards_explicit_music_source():
|
||||||
"""统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。"""
|
"""统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。"""
|
||||||
chain = Mock()
|
chain = Mock()
|
||||||
chain.async_search_music = AsyncMock(return_value=[])
|
chain.async_search = AsyncMock(return_value=(None, []))
|
||||||
|
|
||||||
with patch.object(media_endpoints, "MediaChain", return_value=chain):
|
with patch.object(media_endpoints, "MediaChain", return_value=chain):
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
@@ -116,8 +116,9 @@ def test_media_search_forwards_explicit_music_source():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
chain.async_search_music.assert_awaited_once_with(
|
chain.async_search.assert_awaited_once_with(
|
||||||
query="Coldplay",
|
title="Coldplay",
|
||||||
|
mtype=MediaType.MUSIC,
|
||||||
limit=20,
|
limit=20,
|
||||||
media_source=(MediaSource.TheAudioDB,),
|
media_source=(MediaSource.TheAudioDB,),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""音乐搜索的名称边界、别名和人工候选回归测试。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.chain.download import DownloadChain
|
||||||
|
from app.chain.media import MediaChain
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.domain.context import Context, MusicAlbumInfo, MusicInfo
|
||||||
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
|
from app.domain.meta.runtime import get_metainfo_accelerator
|
||||||
|
from app.domain.music import match_music_resource
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", ["U2 - One Tree Hill FLAC", "U2 - Someone FLAC", "U2 - One - Tree Hill FLAC"])
|
||||||
|
def test_music_match_rejects_other_titles(title):
|
||||||
|
"""短曲名不能将较长的另一首作品判为同一单曲。"""
|
||||||
|
assert match_music_resource(MusicInfo(title="One", artists=["U2"]), title).status == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artist", ["Jay Chou", "周杰倫"])
|
||||||
|
def test_music_match_accepts_source_artist_aliases(artist):
|
||||||
|
"""同一艺术家来源别名和繁简署名均可命中。"""
|
||||||
|
music = MusicInfo(title="晴天", artists=["周杰伦"], artist_aliases=["Jay Chou"])
|
||||||
|
assert match_music_resource(music, f"{artist} - 晴天 FLAC").status == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artist", ["VA", "V.A.", "群星"])
|
||||||
|
def test_music_match_accepts_compilation_credit(artist):
|
||||||
|
"""合辑通用艺术家署名不应造成漏搜。"""
|
||||||
|
music = MusicInfo(music_type="album", title="Test Compilation", artists=["Various Artists"])
|
||||||
|
assert match_music_resource(music, f"{artist} - Test Compilation FLAC").status == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title,reason", [
|
||||||
|
("Jay Chou - 晴天 FLAC", "artist_unverified"),
|
||||||
|
("周杰伦 - 晴天 (Live) FLAC", "version_mismatch"),
|
||||||
|
])
|
||||||
|
def test_music_match_uncertain_identity_requires_confirmation(title, reason):
|
||||||
|
"""未核验艺名和不同录音版本只能交给用户确认。"""
|
||||||
|
result = match_music_resource(MusicInfo(title="晴天", artists=["周杰伦"]), title)
|
||||||
|
assert (result.status, result.reason) == ("candidate", reason)
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_match_distinguishes_missing_evidence_and_related_album():
|
||||||
|
"""缺艺术家、缺分类和所属专辑均不得直接绑定成目标单曲。"""
|
||||||
|
music = MusicInfo(title="Get Lucky", artists=["Daft Punk"], album="Random Access Memories")
|
||||||
|
assert match_music_resource(music, "Daft Punk - Random Access Memories FLAC").status == "album"
|
||||||
|
assert match_music_resource(music, "Daft Punk - Get Lucky FLAC", category=None).reason == "category_unknown"
|
||||||
|
assert match_music_resource(MusicInfo(title="晴天"), "周杰伦 - 晴天 FLAC").reason == "target_artist_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_match_handles_accents_and_edition_suffix():
|
||||||
|
"""变音符不改变身份,缺少明确的目标发行版本则必须人工确认。"""
|
||||||
|
assert match_music_resource(MusicInfo(title="Halo", artists=["Beyoncé"]), "Beyonce - Halo FLAC").status == "exact"
|
||||||
|
album = MusicInfo(music_type="album", title="Test Album (Deluxe Edition)", artists=["Artist"])
|
||||||
|
assert match_music_resource(album, "Artist - Test Album FLAC").reason == "edition_unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_parser_merges_subtitle_without_target_information():
|
||||||
|
"""副标题补全艺术家、专辑和音质,资源元数据不携带目标 ID。"""
|
||||||
|
meta = MetaMusic.parse_resource("晴天 FLAC", "演唱:周杰伦;专辑:叶惠美;24bit 96kHz")
|
||||||
|
assert meta.title == "晴天"
|
||||||
|
assert meta.artists == ["周杰伦"]
|
||||||
|
assert meta.album == "叶惠美"
|
||||||
|
assert meta.bit_depth == 24
|
||||||
|
assert meta.media_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_parser_preserves_title_evidence_and_parses_track_segments():
|
||||||
|
"""冲突副标题不能覆盖标题艺术家,明确的曲序段则可用于区分专辑和单曲。"""
|
||||||
|
meta = MetaMusic.parse_resource("Artist - Album - 01 - Song [FLAC]", "演唱:Other Artist")
|
||||||
|
assert (meta.artists, meta.album, meta.track_number, meta.title) == (["Artist"], "Album", 1, "Song")
|
||||||
|
live = MetaMusic.parse_resource("U2 - One (Live) FLAC")
|
||||||
|
assert live.version == "Live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_parser_keeps_bracketed_title():
|
||||||
|
"""纯展示括号内的作品名不能与规格标签一同丢弃。"""
|
||||||
|
meta = MetaMusic.parse_resource("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健;無損音樂")
|
||||||
|
assert meta.title and "永遠" in meta.title
|
||||||
|
assert meta.artists == ["周華健"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_album_field_cannot_match_target_recording():
|
||||||
|
"""结构化资源中的所属专辑不能冒充同名目标单曲。"""
|
||||||
|
music = MusicInfo(title="Album", artists=["Artist"])
|
||||||
|
assert match_music_resource(music, "Artist - Album - 01 - Other Song FLAC").status == "rejected"
|
||||||
|
wanted = MusicInfo(title="Wanted Song", album="Album", artists=["Artist"])
|
||||||
|
assert match_music_resource(wanted, "Artist - Album - 01 - Other Song FLAC").status == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_artist_in_subtitle_cannot_override_conflicting_title_credit():
|
||||||
|
"""另一位艺人的同名歌曲不能借副标题出现目标艺人而成为精确命中。"""
|
||||||
|
music = MusicInfo(title="One", artists=["U2"])
|
||||||
|
assert match_music_resource(music, "Metallica - One FLAC", "Related artist: U2").reason == "artist_unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_name_is_not_a_recording_version():
|
||||||
|
"""艺人名称含 Live 时,不能把署名误当作现场录音标记。"""
|
||||||
|
music = MusicInfo(title="Song", artists=["Live"])
|
||||||
|
assert match_music_resource(music, "Live - Song FLAC").status == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compilation_album_credit_does_not_prove_track_artist():
|
||||||
|
"""单曲已经有明确艺人时,整专的合辑署名不构成同一录音的身份依据。"""
|
||||||
|
music = MusicInfo(title="Song", artists=["Artist"], album_artist="Various Artists")
|
||||||
|
assert match_music_resource(music, "VA - Song FLAC").status == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_album_artist_is_not_a_recording_artist_alias():
|
||||||
|
"""演唱者与专辑艺术家分属不同实体时,不把专辑署名视为演唱者别名。"""
|
||||||
|
music = MusicInfo(title="Song", artists=["Performer"], album_artist="Album Artist")
|
||||||
|
assert match_music_resource(music, "Album Artist - Song FLAC").status == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_simplification_does_not_merge_recording_and_album_artist_aliases():
|
||||||
|
"""文本展示转换不能把另一位专辑艺术家混入录音艺术家别名。"""
|
||||||
|
original = MusicInfo(title="晴天", artists=["周杰倫"], album_artist="周華健")
|
||||||
|
simplified = MediaChain._simplify_recognized_music_info(original)
|
||||||
|
assert simplified.artist_aliases == ["周杰倫"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artist", ["AC/DC", "Earth, Wind & Fire"])
|
||||||
|
def test_compound_artist_credit_preserves_name_punctuation(artist):
|
||||||
|
"""已知完整艺名含分隔符时,不能因资源解析拆段而误判为另一位艺人。"""
|
||||||
|
music = MusicInfo(title="Song", artists=[artist])
|
||||||
|
assert match_music_resource(music, f"{artist} - Song FLAC").status == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("python_only", [False, True])
|
||||||
|
def test_resource_evidence_matches_python_and_native_parser_paths(monkeypatch, python_only):
|
||||||
|
"""资源级补充规则必须同时适用于 Python 回退和真实 Rust 标题解析。"""
|
||||||
|
if python_only:
|
||||||
|
monkeypatch.setattr("app.domain.meta.metamusic.get_metainfo_accelerator", lambda: None)
|
||||||
|
else:
|
||||||
|
accelerator = get_metainfo_accelerator()
|
||||||
|
if not accelerator or not accelerator.parse_metamusic("Artist - Song FLAC"):
|
||||||
|
pytest.skip("当前环境没有可用的 Rust 音乐解析器")
|
||||||
|
meta = MetaMusic.parse_resource("Artist - Album - 01 - Song FLAC", "24bit 96kHz")
|
||||||
|
assert (meta.title, meta.album, meta.track_number) == ("Song", "Album", 1)
|
||||||
|
assert meta.artists == ["Artist"]
|
||||||
|
assert (meta.bit_depth, meta.sample_rate) == (24, 96000)
|
||||||
|
bracketed = MetaMusic.parse_resource("【永遠・是朋友】24bit/96kHz", "專輯藝人:周華健")
|
||||||
|
assert bracketed.title and "永遠" in bracketed.title
|
||||||
|
assert bracketed.artists == ["周華健"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("factory", [MusicInfo, MusicAlbumInfo])
|
||||||
|
def test_old_music_cache_restores_alias_defaults(factory):
|
||||||
|
"""旧 pickle 没有新增字段时应恢复空列表,继续支持标准序列化和专辑投影。"""
|
||||||
|
info = factory(title="Album", artists=["Artist"])
|
||||||
|
for field in ("title_aliases", "artist_aliases", "album_aliases"):
|
||||||
|
info.__dict__.pop(field, None)
|
||||||
|
restored = pickle.loads(pickle.dumps(info))
|
||||||
|
assert restored.to_dict()["title_aliases"] == []
|
||||||
|
if isinstance(restored, MusicAlbumInfo):
|
||||||
|
assert restored.to_music_info().artist_aliases == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_music_context_does_not_mutate_source_snapshot():
|
||||||
|
"""共用结果裁剪只清理副本,不能清空模块缓存或调用方仍持有的原始响应。"""
|
||||||
|
original = MusicInfo(title="Album", raw_data={"id": "source"})
|
||||||
|
duplicate = copy.copy(original)
|
||||||
|
duplicate.clear()
|
||||||
|
assert duplicate.raw_data == {}
|
||||||
|
assert original.raw_data == {"id": "source"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_simplification_preserves_original_search_names():
|
||||||
|
"""展示繁转简后必须仍能按原始标题与艺术家检索。"""
|
||||||
|
original = MusicInfo(music_type="album", title="永遠是朋友", album="永遠是朋友", artists=["周華健"])
|
||||||
|
simplified = MediaChain._simplify_recognized_music_info(original)
|
||||||
|
keywords = SearchChain.music_site_keywords(simplified)
|
||||||
|
assert keywords[:2] == ["永远是朋友", "永遠是朋友"]
|
||||||
|
assert "周華健" in simplified.artist_aliases
|
||||||
|
assert MusicInfo.from_dict(simplified.to_dict()).title_aliases == ["永遠是朋友"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_album_alias_is_used_for_search():
|
||||||
|
"""匹配接受的作品别名也必须进入实际站点查询阶梯。"""
|
||||||
|
music = MusicInfo(music_type="album", title="Ye Hui Mei", title_aliases=["叶惠美"], artists=["Jay Chou"])
|
||||||
|
assert "叶惠美" in SearchChain.music_site_keywords(music)
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_batch_download_excludes_manual_music_candidates():
|
||||||
|
"""人工候选即使被传入批量入口,也不得自动交给下载器。"""
|
||||||
|
chain = object.__new__(DownloadChain)
|
||||||
|
assert chain._execute_batch_download([Context(match_status="candidate")]) == ([], None)
|
||||||
|
assert chain._execute_batch_download([Context(match_status="candidate", match_reason="related_album")]) == ([], None)
|
||||||
@@ -342,6 +342,22 @@ def test_media_chain_rejects_cross_entity_detail_result(monkeypatch):
|
|||||||
assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
|
assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_music_source_preserves_explicit_album_intent(monkeypatch):
|
||||||
|
"""没有原生 ID 或显式来源时,用户选择的专辑实体不能被默认单曲模式覆盖。"""
|
||||||
|
chain = MediaChain()
|
||||||
|
expected = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="album-1",
|
||||||
|
music_type=MUSIC_ENTITY_ALBUM, title="Album", artists=["Artist"])
|
||||||
|
recognize = Mock(return_value=expected)
|
||||||
|
monkeypatch.setattr(chain, "recognize_music_from_source", recognize)
|
||||||
|
with patch("app.adapters.external.server.MoviePilotServerHelper.report_recognize_share"), \
|
||||||
|
patch("app.adapters.external.server.MoviePilotServerHelper.query_recognize_share", return_value=None):
|
||||||
|
result = chain.recognize_media(meta=MetaMusic(title="Album", artists=["Artist"]),
|
||||||
|
mtype=MediaType.MUSIC, music_type=MUSIC_ENTITY_ALBUM)
|
||||||
|
assert result.music_type == MUSIC_ENTITY_ALBUM
|
||||||
|
assert recognize.call_args.kwargs["media_source"] == MediaSource.MusicBrainz
|
||||||
|
assert recognize.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
|
||||||
|
|
||||||
|
|
||||||
def test_music_metadata_simplified_conversion_defaults_to_enabled():
|
def test_music_metadata_simplified_conversion_defaults_to_enabled():
|
||||||
"""音乐识别结果转简体开关应默认开启。"""
|
"""音乐识别结果转简体开关应默认开启。"""
|
||||||
assert ConfigModel.model_fields["MUSIC_METADATA_TO_SIMPLIFIED"].default is True
|
assert ConfigModel.model_fields["MUSIC_METADATA_TO_SIMPLIFIED"].default is True
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.domain.context import MusicInfo, TorrentInfo
|
from app.domain.context import MusicInfo, TorrentInfo
|
||||||
@@ -8,6 +10,86 @@ from app.domain.meta.metamusic import MetaMusic
|
|||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["sync", "async", "stream"])
|
||||||
|
def test_music_search_keeps_searching_after_final_audio_filter(mode):
|
||||||
|
"""首轮名称命中但音质不符时,三条入口均须继续查询艺术家组合词。"""
|
||||||
|
chain = SearchChain()
|
||||||
|
chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False)
|
||||||
|
target = MusicInfo(music_type="album", title="Test Album", artists=["Test Artist"])
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def batch(**kwargs):
|
||||||
|
"""首轮模拟有损音质,后续组合词返回无损资源。"""
|
||||||
|
calls.append(kwargs["keyword"])
|
||||||
|
codec = "MP3" if len(calls) == 1 else "FLAC"
|
||||||
|
return [TorrentInfo(title=f"Test Artist - Test Album {codec}", category=MediaType.MUSIC.value)]
|
||||||
|
|
||||||
|
async def stream(**kwargs):
|
||||||
|
"""从同一站点样本生成进度事件。"""
|
||||||
|
yield {"items": batch(**kwargs), "stage": "searching", "value": 100}
|
||||||
|
|
||||||
|
async def async_batch(**kwargs):
|
||||||
|
"""普通异步端口与流式端口读取相同站点样本。"""
|
||||||
|
return batch(**kwargs)
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
"""调用对应异步入口并返回最终结果。"""
|
||||||
|
params = {"mediainfo": target, "rule_groups": [], "filter_params": {"audio_format": "FLAC"}}
|
||||||
|
if mode == "async":
|
||||||
|
return await chain._async_process_music(**params)
|
||||||
|
events = [event async for event in chain._async_process_music_stream(**params)]
|
||||||
|
assert events[-1]["candidate_items"] == 2
|
||||||
|
assert events[-1]["match_counts"]["filter_params"] == 1
|
||||||
|
return events[-1]["contexts"]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(chain, "_SearchChain__search_all_sites", side_effect=batch),
|
||||||
|
patch.object(chain, "_SearchChain__async_search_all_sites", side_effect=async_batch),
|
||||||
|
patch.object(chain, "_SearchChain__async_search_all_sites_stream", side_effect=stream),
|
||||||
|
patch("app.chain.search.execution.time.sleep"),
|
||||||
|
patch("app.chain.search.execution.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
results = chain._process_music(target, rule_groups=[], filter_params={"audio_format": "FLAC"}) \
|
||||||
|
if mode == "sync" else asyncio.run(collect())
|
||||||
|
assert calls == ["Test Album", "Test Artist Test Album"]
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].meta_info.audio_format == "FLAC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_manual_candidates_keep_resource_identity_separate():
|
||||||
|
"""来源署名未经验证时只展示资源自身信息,不能回填成所选单曲。"""
|
||||||
|
chain = SearchChain()
|
||||||
|
target = MusicInfo(media_source="musicbrainz", media_id="target", title="晴天", artists=["周杰伦"])
|
||||||
|
torrent = TorrentInfo(title="Jay Chou - 晴天 FLAC", category=MediaType.MUSIC.value)
|
||||||
|
assert chain._build_music_contexts([torrent], target, rule_groups=[]) == []
|
||||||
|
candidate = chain._build_music_contexts([torrent], target, rule_groups=[], include_candidates=True)[0]
|
||||||
|
assert candidate.match_reason == "artist_unverified"
|
||||||
|
assert candidate.media_info is None
|
||||||
|
assert candidate.meta_info.artists == ["Jay Chou"]
|
||||||
|
assert candidate.meta_info.media_id is None
|
||||||
|
assert candidate.media_info_is_target is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_stream_counts_rejected_site_results():
|
||||||
|
"""分类和名称不符仍属于站点原始候选,空态应能说明为何没有最终结果。"""
|
||||||
|
chain = SearchChain()
|
||||||
|
target = MusicInfo(title="One", artists=["U2"])
|
||||||
|
|
||||||
|
async def batches(**_kwargs):
|
||||||
|
"""返回一条属于其他作品的候选。"""
|
||||||
|
yield {"items": [TorrentInfo(title="U2 - One Tree Hill", category=MediaType.MUSIC.value)]}
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
"""使用显式关键词把样例限定为一次站点查询。"""
|
||||||
|
return [event async for event in chain._async_process_music_stream(target, keyword="One", rule_groups=[])]
|
||||||
|
|
||||||
|
with patch.object(chain, "_SearchChain__async_search_all_sites_stream", side_effect=batches):
|
||||||
|
events = asyncio.run(collect())
|
||||||
|
assert events[-1]["candidate_items"] == 1
|
||||||
|
assert events[-1]["total_items"] == 0
|
||||||
|
assert events[-1]["match_counts"] == {"title_mismatch": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_music_context_builder_keeps_only_music_category():
|
def test_music_context_builder_keeps_only_music_category():
|
||||||
"""精确音乐搜索只应保留明确标记为音乐分类的站点资源。"""
|
"""精确音乐搜索只应保留明确标记为音乐分类的站点资源。"""
|
||||||
chain = SearchChain()
|
chain = SearchChain()
|
||||||
@@ -36,7 +118,7 @@ def test_music_context_builder_keeps_only_music_category():
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch.object(chain, "filter_torrents", return_value=torrents[:1]):
|
with patch.object(chain, "filter_torrents", side_effect=lambda **kwargs: kwargs["torrent_list"][:1]):
|
||||||
contexts = chain._build_music_contexts(
|
contexts = chain._build_music_contexts(
|
||||||
torrents=torrents,
|
torrents=torrents,
|
||||||
mediainfo=music,
|
mediainfo=music,
|
||||||
@@ -44,9 +126,11 @@ def test_music_context_builder_keeps_only_music_category():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert len(contexts) == 1
|
assert len(contexts) == 1
|
||||||
assert contexts[0].media_info is music
|
assert contexts[0].media_info is not music
|
||||||
assert isinstance(contexts[0].meta_info, MetaMusic)
|
assert isinstance(contexts[0].meta_info, MetaMusic)
|
||||||
assert contexts[0].meta_info.media_id == "recording-1"
|
assert contexts[0].meta_info.media_id is None
|
||||||
|
assert contexts[0].meta_info.title == "Get Lucky - Random Access Memories"
|
||||||
|
assert contexts[0].media_info.media_id == "recording-1"
|
||||||
assert contexts[0].torrent_info.category == MediaType.MUSIC.value
|
assert contexts[0].torrent_info.category == MediaType.MUSIC.value
|
||||||
|
|
||||||
|
|
||||||
@@ -75,7 +159,7 @@ def test_music_search_continues_after_unrelated_first_keyword_results():
|
|||||||
chain,
|
chain,
|
||||||
"_SearchChain__search_all_sites",
|
"_SearchChain__search_all_sites",
|
||||||
side_effect=[[unrelated], [matched]],
|
side_effect=[[unrelated], [matched]],
|
||||||
) as search_sites, patch("app.chain.search.music.time.sleep"):
|
) as search_sites, patch("app.chain.search.execution.time.sleep"):
|
||||||
contexts = chain._process_music(music, rule_groups=[])
|
contexts = chain._process_music(music, rule_groups=[])
|
||||||
|
|
||||||
assert search_sites.call_count == 2
|
assert search_sites.call_count == 2
|
||||||
@@ -104,7 +188,7 @@ def test_music_search_uses_simplified_keywords_before_original_traditional_keywo
|
|||||||
chain,
|
chain,
|
||||||
"_SearchChain__search_all_sites",
|
"_SearchChain__search_all_sites",
|
||||||
side_effect=[[], [matched]],
|
side_effect=[[], [matched]],
|
||||||
) as search_sites, patch("app.chain.search.music.time.sleep"):
|
) as search_sites, patch("app.chain.search.execution.time.sleep"):
|
||||||
contexts = chain._process_music(music, rule_groups=[])
|
contexts = chain._process_music(music, rule_groups=[])
|
||||||
|
|
||||||
assert [call.kwargs["keyword"] for call in search_sites.call_args_list] == [
|
assert [call.kwargs["keyword"] for call in search_sites.call_args_list] == [
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""人工音乐候选从 HTTP 参数到共用搜索、结果序列化的集成回归。"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.adapters.web.security.access import verify_token
|
||||||
|
from app.api.endpoints import search as endpoint
|
||||||
|
from app.chain.search import media
|
||||||
|
from app.chain.search.facade import SearchChain
|
||||||
|
from app.domain.context import MusicInfo, TorrentInfo
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("include_candidates", [False, True])
|
||||||
|
async def test_music_manual_candidate_opt_in_survives_http_and_serialization(include_candidates):
|
||||||
|
"""默认精确搜索不会放行未核验署名,显式人工候选请求保留资源但不绑定目标。"""
|
||||||
|
media_id = "695f5ac8-cfd5-4e7b-96a0-22830c931bb0"
|
||||||
|
target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id=media_id, title="晴天", artists=["周杰伦"])
|
||||||
|
chain = SearchChain()
|
||||||
|
chain.async_save_last_search_params = AsyncMock()
|
||||||
|
chain._async_save_results = AsyncMock()
|
||||||
|
chain.cancel_ai_recommend = Mock()
|
||||||
|
chain._SearchChain__async_search_all_sites = AsyncMock(return_value=[
|
||||||
|
TorrentInfo(title="Jay Chou - 晴天 FLAC", category=MediaType.MUSIC.value),
|
||||||
|
])
|
||||||
|
metadata = Mock()
|
||||||
|
metadata.async_recognize_media = AsyncMock(return_value=target)
|
||||||
|
metadata.async_supplement_media_info = AsyncMock(side_effect=lambda mediainfo: mediainfo)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(endpoint.router, prefix="/api/v1/search")
|
||||||
|
app.dependency_overrides[verify_token] = lambda: Mock()
|
||||||
|
with (
|
||||||
|
patch.object(endpoint, "SearchChain", return_value=chain),
|
||||||
|
patch.object(media, "MediaChain", return_value=metadata),
|
||||||
|
patch("app.chain.search.execution.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client:
|
||||||
|
response = await client.get(f"/api/v1/search/media/{media_id}", params={
|
||||||
|
"media_source": "musicbrainz", "include_candidates": include_candidates,
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
if include_candidates:
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert len(payload["data"]) == 1
|
||||||
|
candidate = payload["data"][0]
|
||||||
|
assert candidate["match_status"] == "candidate"
|
||||||
|
assert candidate["match_reason"] == "artist_unverified"
|
||||||
|
assert candidate["media_info"] is None
|
||||||
|
assert candidate["meta_info"]["artists"] == ["Jay Chou"]
|
||||||
|
assert candidate["meta_info"]["media_id"] is None
|
||||||
|
assert chain.async_save_last_search_params.call_args.kwargs["include_candidates"] is True
|
||||||
|
else:
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert chain._async_save_results.call_args.args[0] == []
|
||||||
@@ -18,6 +18,7 @@ from app.application.subscription.execution import (
|
|||||||
)
|
)
|
||||||
from app.application.subscription.mutation import SubscriptionMutation
|
from app.application.subscription.mutation import SubscriptionMutation
|
||||||
from app.application.subscription.sitebudget import SubscriptionSearchCancelled
|
from app.application.subscription.sitebudget import SubscriptionSearchCancelled
|
||||||
|
from app.chain.search import SearchChain
|
||||||
from app.chain.subscribe.facade import SubscribeChain
|
from app.chain.subscribe.facade import SubscribeChain
|
||||||
from app.domain.context import (
|
from app.domain.context import (
|
||||||
MUSIC_ENTITY_ALBUM,
|
MUSIC_ENTITY_ALBUM,
|
||||||
@@ -44,6 +45,14 @@ def _music_info() -> MusicInfo:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_chain_for_torrents(contexts: list[Context]) -> SearchChain:
|
||||||
|
"""只替换站点 I/O,保留真实共用搜索、解析、过滤和匹配流程。"""
|
||||||
|
chain = SearchChain()
|
||||||
|
chain._SearchChain__search_all_sites = Mock(return_value=[context.torrent_info for context in contexts])
|
||||||
|
chain.process = Mock(wraps=chain.process)
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
def _subscribe(**overrides) -> SimpleNamespace:
|
def _subscribe(**overrides) -> SimpleNamespace:
|
||||||
"""构造不依赖数据库的音乐订阅对象。"""
|
"""构造不依赖数据库的音乐订阅对象。"""
|
||||||
values = dict(
|
values = dict(
|
||||||
@@ -52,6 +61,7 @@ def _subscribe(**overrides) -> SimpleNamespace:
|
|||||||
year="2003",
|
year="2003",
|
||||||
type=MediaType.MUSIC.value,
|
type=MediaType.MUSIC.value,
|
||||||
keyword=None,
|
keyword=None,
|
||||||
|
search_imdbid=False,
|
||||||
media_source="musicbrainz",
|
media_source="musicbrainz",
|
||||||
media_id="recording-1",
|
media_id="recording-1",
|
||||||
mediaid=None,
|
mediaid=None,
|
||||||
@@ -196,10 +206,9 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
|
|||||||
category=MediaType.MUSIC.value,
|
category=MediaType.MUSIC.value,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
search_chain = Mock()
|
search_chain = _search_chain_for_torrents([context])
|
||||||
search_chain.search_by_title.side_effect = [[context], []]
|
|
||||||
download_chain = Mock()
|
download_chain = Mock()
|
||||||
download_chain.batch_download.return_value = ([context], None)
|
download_chain.batch_download.side_effect = lambda **kwargs: (kwargs["contexts"], None)
|
||||||
chain = SubscribeChain()
|
chain = SubscribeChain()
|
||||||
chain.finish_subscribe_or_not = Mock()
|
chain.finish_subscribe_or_not = Mock()
|
||||||
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
||||||
@@ -209,17 +218,17 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
|
|||||||
chain.subscription_repository = subscribe_oper
|
chain.subscription_repository = subscribe_oper
|
||||||
|
|
||||||
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
|
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
|
||||||
patch("app.chain._music.SearchChain", return_value=search_chain), \
|
patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \
|
||||||
patch("app.chain._music.DownloadChain", return_value=download_chain):
|
patch("app.chain._music.DownloadChain", return_value=download_chain):
|
||||||
chain._search_music_subscribe(subscribe)
|
chain._search_music_subscribe(subscribe)
|
||||||
|
|
||||||
search_chain.search_by_title.assert_any_call(
|
search_chain.process.assert_called_once()
|
||||||
title="周杰伦 晴天", sites=[], mtype=MediaType.MUSIC, rule_groups=[]
|
assert search_chain.process.call_args.kwargs["keyword"] == "周杰伦 晴天"
|
||||||
)
|
assert search_chain.process.call_args.kwargs["sites"] == []
|
||||||
download_chain.batch_download.assert_called_once()
|
download_chain.batch_download.assert_called_once()
|
||||||
matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0]
|
matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0]
|
||||||
assert matched_context is not context
|
assert matched_context is not context
|
||||||
assert matched_context.media_info is target
|
assert matched_context.media_info.media_id == target.media_id
|
||||||
assert isinstance(matched_context.meta_info, MetaMusic)
|
assert isinstance(matched_context.meta_info, MetaMusic)
|
||||||
assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
|
assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
|
||||||
assert matched_context.meta_info.audio_format == "FLAC"
|
assert matched_context.meta_info.audio_format == "FLAC"
|
||||||
@@ -233,7 +242,7 @@ def test_music_search_honours_cancel_before_external_work():
|
|||||||
chain = SubscribeChain()
|
chain = SubscribeChain()
|
||||||
execution_context = _execution_context(cancelled=lambda: True)
|
execution_context = _execution_context(cancelled=lambda: True)
|
||||||
|
|
||||||
with patch("app.chain._music.SearchChain") as search_chain, \
|
with patch("app.chain.subscribe.search.SearchChain") as search_chain, \
|
||||||
pytest.raises(SubscriptionSearchCancelled):
|
pytest.raises(SubscriptionSearchCancelled):
|
||||||
chain._search_music_subscribe(
|
chain._search_music_subscribe(
|
||||||
subscribe,
|
subscribe,
|
||||||
@@ -243,6 +252,39 @@ def test_music_search_honours_cancel_before_external_work():
|
|||||||
search_chain.assert_not_called()
|
search_chain.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_best_version_filters_before_stopping_keyword_search():
|
||||||
|
"""首关键词只有当前音质时,应继续换词直到找到满足洗版条件的资源。"""
|
||||||
|
subscribe = _subscribe(best_version=1, current_priority=90)
|
||||||
|
target = _music_info()
|
||||||
|
chain = SubscribeChain()
|
||||||
|
chain.subscription_repository = Mock()
|
||||||
|
chain.subscription_repository.get.return_value = subscribe
|
||||||
|
search_chain = SearchChain()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def search(**kwargs):
|
||||||
|
"""首轮为 CD 音质,组合词返回更高音质。"""
|
||||||
|
calls.append(kwargs["keyword"])
|
||||||
|
quality = "16bit 44.1kHz" if len(calls) == 1 else "24bit 96kHz"
|
||||||
|
return [TorrentInfo(title=f"周杰伦 - 晴天 FLAC {quality}", category=MediaType.MUSIC.value)]
|
||||||
|
|
||||||
|
search_chain._SearchChain__search_all_sites = Mock(side_effect=search)
|
||||||
|
with (
|
||||||
|
patch.object(chain, "_prepare_music_subscribe", return_value=(subscribe, target, MetaMusic.from_music_info(target))),
|
||||||
|
patch.object(chain, "_download_music_subscribe") as download,
|
||||||
|
patch("app.chain.search.execution.time.sleep"),
|
||||||
|
patch.object(search_chain, "consume_subscription_site_budget_failures", wraps=search_chain.consume_subscription_site_budget_failures) as budget,
|
||||||
|
):
|
||||||
|
chain._process_search_subscription(subscribe, search_chain)
|
||||||
|
assert calls == ["晴天", "周杰伦 晴天"]
|
||||||
|
budget.assert_called_once_with(has_results=True)
|
||||||
|
download.assert_called_once()
|
||||||
|
contexts = download.call_args.args[2]
|
||||||
|
assert len(contexts) == 1
|
||||||
|
assert contexts[0].meta_info.bit_depth == 24
|
||||||
|
assert contexts[0].torrent_info.pri_order > 90
|
||||||
|
|
||||||
|
|
||||||
def test_music_download_marks_shared_execution_context_before_side_effect():
|
def test_music_download_marks_shared_execution_context_before_side_effect():
|
||||||
"""音乐下载必须把取消和副作用边界传入统一下载治理。"""
|
"""音乐下载必须把取消和副作用边界传入统一下载治理。"""
|
||||||
cancelled = [False]
|
cancelled = [False]
|
||||||
@@ -520,13 +562,13 @@ def test_music_subscribe_ignores_non_music_category():
|
|||||||
category=MediaType.MOVIE.value,
|
category=MediaType.MOVIE.value,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
search_chain = Mock()
|
search_chain = _search_chain_for_torrents([context])
|
||||||
search_chain.search_by_title.return_value = [context]
|
|
||||||
chain = SubscribeChain()
|
chain = SubscribeChain()
|
||||||
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
||||||
|
|
||||||
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
||||||
patch("app.chain._music.SearchChain", return_value=search_chain), \
|
patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \
|
||||||
|
patch("app.chain.search.execution.time.sleep"), \
|
||||||
patch("app.chain._music.DownloadChain") as download_chain:
|
patch("app.chain._music.DownloadChain") as download_chain:
|
||||||
chain._search_music_subscribe(subscribe)
|
chain._search_music_subscribe(subscribe)
|
||||||
|
|
||||||
@@ -542,14 +584,14 @@ def test_music_subscribe_ignores_unrelated_music_title():
|
|||||||
category=MediaType.MUSIC.value,
|
category=MediaType.MUSIC.value,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
search_chain = Mock()
|
search_chain = _search_chain_for_torrents([context])
|
||||||
search_chain.search_by_title.return_value = [context]
|
|
||||||
|
|
||||||
chain = SubscribeChain()
|
chain = SubscribeChain()
|
||||||
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
|
||||||
|
|
||||||
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
||||||
patch("app.chain._music.SearchChain", return_value=search_chain), \
|
patch("app.chain.subscribe.search.SearchChain", return_value=search_chain), \
|
||||||
|
patch("app.chain.search.execution.time.sleep"), \
|
||||||
patch("app.chain._music.DownloadChain") as download_chain:
|
patch("app.chain._music.DownloadChain") as download_chain:
|
||||||
chain._search_music_subscribe(subscribe)
|
chain._search_music_subscribe(subscribe)
|
||||||
|
|
||||||
@@ -563,7 +605,7 @@ def test_music_subscribe_skips_search_when_target_is_already_in_library():
|
|||||||
chain.check_and_handle_existing_media = Mock(return_value=(True, {}))
|
chain.check_and_handle_existing_media = Mock(return_value=(True, {}))
|
||||||
|
|
||||||
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
|
||||||
patch("app.chain._music.SearchChain") as search_chain, \
|
patch("app.chain.subscribe.search.SearchChain") as search_chain, \
|
||||||
patch("app.chain._music.DownloadChain") as download_chain:
|
patch("app.chain._music.DownloadChain") as download_chain:
|
||||||
chain._search_music_subscribe(subscribe)
|
chain._search_music_subscribe(subscribe)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,62 @@
|
|||||||
from app.runtime.config import settings
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from app.domain.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
|
from app.domain.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.modules.musicbrainz import MusicBrainzModule
|
from app.modules.musicbrainz import MusicBrainzModule
|
||||||
|
from app.runtime.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_search_uses_phrase_before_character_fallback(monkeypatch):
|
||||||
|
"""完整中文名称命中时不应再执行逐字 OR 查询。"""
|
||||||
|
module = MusicBrainzModule()
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
def request(_path, params):
|
||||||
|
"""模拟完整名称检索返回同名录音。"""
|
||||||
|
queries.append(params["query"])
|
||||||
|
return {"recordings": [{"id": "recording", "title": "晴天"}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_request_json", request)
|
||||||
|
assert module._search_recordings(MetaMusic(title="晴天"), 30)[0].title == "晴天"
|
||||||
|
assert queries == ['recording:"晴天"']
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_search_keeps_character_query_as_last_resort(monkeypatch):
|
||||||
|
"""完整短语无结果后保留旧的宽召回能力,不能先用单字占满结果窗口。"""
|
||||||
|
module = MusicBrainzModule()
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
def request(_path, params):
|
||||||
|
"""首轮无结果,仅在末级检索式返回候选。"""
|
||||||
|
queries.append(params["query"])
|
||||||
|
return {"recordings": [] if len(queries) == 1 else [{"id": "recording", "title": "晴天"}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_request_json", request)
|
||||||
|
assert module._search_recordings(MetaMusic(title="晴天"), 30)
|
||||||
|
assert queries == ['recording:"晴天"', 'recording:("晴" OR "天")']
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_alias_lookup_verifies_identity_in_both_io_modes(monkeypatch):
|
||||||
|
"""同步和异步别名补充必须校验精确艺术家 ID,拒绝其它艺人的响应。"""
|
||||||
|
module = MusicBrainzModule()
|
||||||
|
artist_id = "a223958d-5c56-4b2c-a30a-87e357bc121b"
|
||||||
|
payload = {"id": artist_id, "name": "周杰倫", "aliases": [{"name": "Jay Chou"}]}
|
||||||
|
monkeypatch.setattr(module, "_request_json", lambda *_args, **_kwargs: payload)
|
||||||
|
monkeypatch.setattr(module, "_async_request_json", AsyncMock(return_value=payload))
|
||||||
|
expected = ["周杰倫", "Jay Chou"]
|
||||||
|
assert module._lookup_artist_aliases([artist_id], []) == expected
|
||||||
|
assert asyncio.run(module._async_lookup_artist_aliases([artist_id], [])) == expected
|
||||||
|
assert module._artist_alias_values(payload, "other-artist") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_ranking_prefers_complete_name_over_partial_character_hit():
|
||||||
|
"""宽召回之后也应按完整标题与署名排序,避免单字相关候选压过准确目标。"""
|
||||||
|
exact = MusicInfo(title="晴天", artists=["周杰倫"])
|
||||||
|
unrelated = MusicInfo(title="天", artists=["Other Artist"])
|
||||||
|
assert MusicBrainzModule._rank_search_candidates(
|
||||||
|
MetaMusic(title="周杰伦 晴天"), [unrelated, exact],
|
||||||
|
)[0] is exact
|
||||||
|
|
||||||
|
|
||||||
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
|
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
|
||||||
@@ -36,7 +91,7 @@ def test_build_query_strips_audio_quality_tokens():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# CJK 短语在 Lucene 索引中是单一词元,检索式拆为逐字 OR,OR 组带括号避免 AND 优先级歧义
|
# CJK 短语在 Lucene 索引中是单一词元,检索式拆为逐字 OR,OR 组带括号避免 AND 优先级歧义
|
||||||
assert query == 'recording:("永" OR "远" OR "是" OR "朋" OR "友") AND artist:"毛阿敏"'
|
assert query == 'recording:("永远是朋友" OR "永遠是朋友") AND artist:"毛阿敏"'
|
||||||
|
|
||||||
|
|
||||||
def test_select_candidate_matches_traditional_chinese_title():
|
def test_select_candidate_matches_traditional_chinese_title():
|
||||||
@@ -166,8 +221,8 @@ def test_search_music_interleaves_recordings_albums_and_artists(monkeypatch):
|
|||||||
assert results[1].album == "叶惠美"
|
assert results[1].album == "叶惠美"
|
||||||
assert results[2].title == "周杰伦"
|
assert results[2].title == "周杰伦"
|
||||||
assert results[2].artists == []
|
assert results[2].artists == []
|
||||||
assert requested[1][1]["query"] == 'releasegroup:("晴" OR "天") AND artist:"周杰伦"'
|
assert requested[1][1]["query"] == 'releasegroup:"晴天" AND artist:("周杰伦" OR "周杰倫")'
|
||||||
assert requested[2][1]["query"] == 'artist:("周" OR "杰" OR "伦")'
|
assert requested[2][1]["query"] == 'artist:("周杰伦" OR "周杰倫")'
|
||||||
|
|
||||||
|
|
||||||
def test_file_recognition_searches_recordings_only(monkeypatch):
|
def test_file_recognition_searches_recordings_only(monkeypatch):
|
||||||
@@ -660,19 +715,20 @@ def test_recording_queries_ladder_relaxes_to_bare_title_last():
|
|||||||
MetaMusic(title="晴天 (电影版)", artists=["周杰伦"])
|
MetaMusic(title="晴天 (电影版)", artists=["周杰伦"])
|
||||||
)
|
)
|
||||||
|
|
||||||
full = '("晴" OR "天") OR ("电" OR "影" OR "版")'
|
full = '("晴天 (电影版)" OR "晴天 (電影版)")'
|
||||||
assert queries[0] == f'recording:({full}) AND artist:"周杰伦"'
|
assert queries[0] == f'recording:{full} AND artist:("周杰伦" OR "周杰倫")'
|
||||||
assert queries[1] == f'recording:({full})'
|
assert queries[1] == f'recording:{full}'
|
||||||
assert queries[2] == 'recording:("晴" OR "天") AND artist:"周杰伦"'
|
assert queries[2] == 'recording:"晴天" AND artist:("周杰伦" OR "周杰倫")'
|
||||||
# 署名变体兜底放在最后,由候选挑选的艺术家要求收紧
|
# 全名查询之后才逐字兜底,避免单字噪声抢占召回窗口。
|
||||||
assert queries[-1] == 'recording:("晴" OR "天")'
|
assert queries[-1] == 'recording:("晴" OR "天")'
|
||||||
|
|
||||||
|
|
||||||
def test_query_phrase_latin_unchanged_and_cjk_char_or():
|
def test_query_phrase_prefers_complete_names_with_explicit_loose_fallback():
|
||||||
"""拉丁文本保持短语检索,CJK 文本拆为逐字 OR,混合文本按词元拆分。"""
|
"""所有文字优先完整短语,CJK 只有显式末级兜底才拆为逐字 OR。"""
|
||||||
assert MusicBrainzModule._query_phrase("Fearless") == '"Fearless"'
|
assert MusicBrainzModule._query_phrase("Fearless") == '"Fearless"'
|
||||||
assert MusicBrainzModule._query_phrase("晴天") == '("晴" OR "天")'
|
assert MusicBrainzModule._query_phrase("晴天") == '"晴天"'
|
||||||
assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == (
|
assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == '"好歌茹芸 Vol. 3"'
|
||||||
|
assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3", loose=True) == (
|
||||||
'(("好" OR "歌" OR "茹" OR "芸") OR "Vol." OR "3")'
|
'(("好" OR "歌" OR "茹" OR "芸") OR "Vol." OR "3")'
|
||||||
)
|
)
|
||||||
assert MusicBrainzModule._query_phrase("") is None
|
assert MusicBrainzModule._query_phrase("") is None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ EXPECTED_SEARCH_MODULES = {
|
|||||||
"__init__.py",
|
"__init__.py",
|
||||||
"cache.py",
|
"cache.py",
|
||||||
"contract.py",
|
"contract.py",
|
||||||
|
"execution.py",
|
||||||
"facade.py",
|
"facade.py",
|
||||||
"media.py",
|
"media.py",
|
||||||
"music.py",
|
"music.py",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ LUNA_PRIVATE_PATCH_POINTS = (
|
|||||||
)
|
)
|
||||||
INTERNAL_EXPORTS = (
|
INTERNAL_EXPORTS = (
|
||||||
"SearchCacheOwner",
|
"SearchCacheOwner",
|
||||||
|
"SearchExecutionOwner",
|
||||||
"SearchMediaOwner",
|
"SearchMediaOwner",
|
||||||
"SearchMusicOwner",
|
"SearchMusicOwner",
|
||||||
"SearchPaginationOwner",
|
"SearchPaginationOwner",
|
||||||
@@ -62,6 +63,8 @@ EXPECTED_SIGNATURES = {
|
|||||||
("area", "title"),
|
("area", "title"),
|
||||||
("custom_words", None),
|
("custom_words", None),
|
||||||
("filter_params", None),
|
("filter_params", None),
|
||||||
|
("include_candidates", False),
|
||||||
|
("candidate_filter", None),
|
||||||
),
|
),
|
||||||
"search_by_id": (
|
"search_by_id": (
|
||||||
("self", REQUIRED),
|
("self", REQUIRED),
|
||||||
@@ -73,6 +76,7 @@ EXPECTED_SIGNATURES = {
|
|||||||
("sites", None),
|
("sites", None),
|
||||||
("cache_local", False),
|
("cache_local", False),
|
||||||
("music_type", None),
|
("music_type", None),
|
||||||
|
("include_candidates", False),
|
||||||
),
|
),
|
||||||
"search_by_title": (
|
"search_by_title": (
|
||||||
("self", REQUIRED),
|
("self", REQUIRED),
|
||||||
@@ -93,6 +97,8 @@ EXPECTED_SIGNATURES = {
|
|||||||
("area", "title"),
|
("area", "title"),
|
||||||
("custom_words", None),
|
("custom_words", None),
|
||||||
("filter_params", None),
|
("filter_params", None),
|
||||||
|
("include_candidates", False),
|
||||||
|
("candidate_filter", None),
|
||||||
),
|
),
|
||||||
"async_search_by_id": (
|
"async_search_by_id": (
|
||||||
("self", REQUIRED),
|
("self", REQUIRED),
|
||||||
@@ -104,6 +110,7 @@ EXPECTED_SIGNATURES = {
|
|||||||
("sites", None),
|
("sites", None),
|
||||||
("cache_local", False),
|
("cache_local", False),
|
||||||
("music_type", None),
|
("music_type", None),
|
||||||
|
("include_candidates", False),
|
||||||
),
|
),
|
||||||
"async_search_by_title": (
|
"async_search_by_title": (
|
||||||
("self", REQUIRED),
|
("self", REQUIRED),
|
||||||
@@ -124,6 +131,8 @@ EXPECTED_SIGNATURES = {
|
|||||||
("area", "title"),
|
("area", "title"),
|
||||||
("custom_words", None),
|
("custom_words", None),
|
||||||
("filter_params", None),
|
("filter_params", None),
|
||||||
|
("include_candidates", False),
|
||||||
|
("candidate_filter", None),
|
||||||
),
|
),
|
||||||
"async_search_by_title_stream": (
|
"async_search_by_title_stream": (
|
||||||
("self", REQUIRED),
|
("self", REQUIRED),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.chain.search import execution as execution_module
|
||||||
from app.chain.search import media as media_module
|
from app.chain.search import media as media_module
|
||||||
from app.chain.search import title as title_module
|
from app.chain.search import title as title_module
|
||||||
from app.chain.search.facade import SearchChain
|
from app.chain.search.facade import SearchChain
|
||||||
@@ -169,9 +170,9 @@ def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch):
|
|||||||
async_sleeps.append(delay)
|
async_sleeps.append(delay)
|
||||||
|
|
||||||
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
||||||
monkeypatch.setattr(media_module.random, "randint", lambda _start, _end: 1)
|
monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1)
|
||||||
monkeypatch.setattr(media_module.time, "sleep", sync_sleeps.append)
|
monkeypatch.setattr(execution_module.time, "sleep", sync_sleeps.append)
|
||||||
monkeypatch.setattr(media_module.asyncio, "sleep", fake_async_sleep)
|
monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep)
|
||||||
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
||||||
chain._copy_media_input = deepcopy
|
chain._copy_media_input = deepcopy
|
||||||
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
||||||
@@ -188,46 +189,47 @@ def test_media_process_sync_async_share_keyword_stop_decision(monkeypatch):
|
|||||||
assert sync_result == async_result == [found]
|
assert sync_result == async_result == [found]
|
||||||
|
|
||||||
|
|
||||||
def test_keyword_resolution_searches_all_names_when_enabled():
|
def test_keyword_resolution_searches_all_names_when_enabled(monkeypatch):
|
||||||
"""开启多名称搜索时共享状态机应完整执行并稳定聚合各关键字结果。"""
|
"""开启多名称搜索时同步、异步共用状态机须完整执行并稳定聚合结果。"""
|
||||||
|
chain = _chain()
|
||||||
|
target = _media()
|
||||||
first = _torrent("First Result")
|
first = _torrent("First Result")
|
||||||
third = _torrent("Third Result")
|
third = _torrent("Third Result")
|
||||||
expected = {
|
results = {"first": [first], "second": [], "third": [third]}
|
||||||
"first": [first],
|
sync_keywords = []
|
||||||
"second": [],
|
async_keywords = []
|
||||||
"third": [third],
|
|
||||||
}
|
|
||||||
sync_keywords: list[str] = []
|
|
||||||
async_keywords: list[str] = []
|
|
||||||
|
|
||||||
def execute_sync(request):
|
def search(**kwargs):
|
||||||
"""记录同步驱动顺序并返回关键字结果。"""
|
"""记录同步站点调用顺序。"""
|
||||||
sync_keywords.append(request.keyword)
|
sync_keywords.append(kwargs["keyword"])
|
||||||
return expected[request.keyword]
|
return results[kwargs["keyword"]]
|
||||||
|
|
||||||
async def execute_async(request):
|
async def async_search(**kwargs):
|
||||||
"""记录异步驱动顺序并返回关键字结果。"""
|
"""记录异步站点调用顺序。"""
|
||||||
async_keywords.append(request.keyword)
|
async_keywords.append(kwargs["keyword"])
|
||||||
return expected[request.keyword]
|
return results[kwargs["keyword"]]
|
||||||
|
|
||||||
keywords = ["first", "second", "third"]
|
async def supplement(mediainfo):
|
||||||
sync_result = media_module._run_keyword_search_sync(
|
"""异步补充步骤在样例中不改变媒体对象。"""
|
||||||
media_module._keyword_search_resolution(keywords, search_multiple_name=True),
|
return mediainfo
|
||||||
execute_sync,
|
|
||||||
)
|
|
||||||
async_result = asyncio.run(
|
|
||||||
media_module._run_keyword_search_async(
|
|
||||||
media_module._keyword_search_resolution(
|
|
||||||
keywords, search_multiple_name=True
|
|
||||||
),
|
|
||||||
execute_async,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert sync_keywords == async_keywords == keywords
|
async def sleep(_delay):
|
||||||
assert sync_result == async_result
|
"""隔离关键词间隔,不执行实际等待。"""
|
||||||
assert sync_result.torrents == [first, third]
|
|
||||||
assert sync_result.stopped_early is False
|
provider = SimpleNamespace(supplement_media_info=lambda mediainfo: mediainfo,
|
||||||
|
async_supplement_media_info=supplement)
|
||||||
|
monkeypatch.setattr(media_module, "MediaChain", lambda: provider)
|
||||||
|
monkeypatch.setattr(execution_module.time, "sleep", lambda _delay: None)
|
||||||
|
monkeypatch.setattr(execution_module.asyncio, "sleep", sleep)
|
||||||
|
chain.runtime_config = SimpleNamespace(search_multiple_name=True)
|
||||||
|
chain._prepare_params = lambda **_kwargs: (None, list(results))
|
||||||
|
chain._SearchChain__search_all_sites = search
|
||||||
|
chain._SearchChain__async_search_all_sites = async_search
|
||||||
|
chain._parse_result = lambda **kwargs: list(kwargs["torrents"])
|
||||||
|
|
||||||
|
assert chain.process(target) == [first, third]
|
||||||
|
assert asyncio.run(chain.async_process(target)) == [first, third]
|
||||||
|
assert sync_keywords == async_keywords == list(results)
|
||||||
|
|
||||||
|
|
||||||
def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch):
|
def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch):
|
||||||
@@ -272,8 +274,8 @@ def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch
|
|||||||
]
|
]
|
||||||
|
|
||||||
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
monkeypatch.setattr(media_module, "MediaChain", FakeMediaChain)
|
||||||
monkeypatch.setattr(media_module.random, "randint", lambda _start, _end: 1)
|
monkeypatch.setattr(execution_module.random, "randint", lambda _start, _end: 1)
|
||||||
monkeypatch.setattr(media_module.asyncio, "sleep", fake_async_sleep)
|
monkeypatch.setattr(execution_module.asyncio, "sleep", fake_async_sleep)
|
||||||
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
chain.runtime_config = SimpleNamespace(search_multiple_name=False)
|
||||||
chain._copy_media_input = deepcopy
|
chain._copy_media_input = deepcopy
|
||||||
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
chain._prepare_params = lambda **_kwargs: (None, ["first", "second", "third"])
|
||||||
@@ -282,13 +284,14 @@ def test_media_process_stream_shares_keyword_order_and_stop_decision(monkeypatch
|
|||||||
|
|
||||||
events = asyncio.run(collect_events())
|
events = asyncio.run(collect_events())
|
||||||
|
|
||||||
assert stream_keywords == ["first", "second"]
|
assert stream_keywords == ["first", "second", "third"]
|
||||||
assert sleeps == [1]
|
assert sleeps == [1, 1]
|
||||||
assert parsed == [[found]]
|
assert parsed == [[found]]
|
||||||
assert [event["type"] for event in events] == [
|
assert [event["type"] for event in events] == [
|
||||||
"append",
|
"append",
|
||||||
"append",
|
"append",
|
||||||
"progress",
|
"progress",
|
||||||
|
"append",
|
||||||
"replace",
|
"replace",
|
||||||
"done",
|
"done",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user