Files
MoviePilot/app/chain/subscribe/search.py
2026-11-03 02:59:35 +08:00

1239 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅主动搜索编排
"""
import asyncio
import random
import time
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from functools import partial
from typing import Any, Callable, Optional, cast
from uuid import uuid4
from app.application.configuration import get_configured_system_config
from app.application.subscription.activity import current_activity_run, subscription_activity_scope
from app.application.subscription.contract import (
SubscriptionRepository,
SubscriptionSnapshot,
)
from app.application.subscription.execution import (
SearchBatchSnapshot,
SearchTaskSnapshot,
SubscriptionExecutionContext,
SubscriptionSearchRepository,
SubscriptionSiteSearchFailed,
raise_subscription_site_budget_deferral,
raise_subscription_site_budget_failures,
)
from app.application.subscription.observability import (
SearchExecutionSummary,
SearchTaskOutcome,
batch_finished_count,
batch_progress_text,
inline_search_result,
subscription_log_scope,
)
from app.application.subscription.query import SubscriptionQueryService, subscription_search_due
from app.application.subscription.sitebudget import (
SubscriptionSearchCancelled,
SubscriptionSearchDeferred,
)
from app.chain.media import MediaChain
from app.chain.search.execution import MediaSearchPlan
from app.chain.search.facade import SearchChain
from app.chain.search.scan import SearchScan, search_targets
from app.chain.subscribe.contract import _SubscribeOwnerBase
from app.chain.subscribe.metadata import (
SubscriptionSearchTarget,
apply_subscription_classification,
prepare_search_target,
)
from app.chain.subscribe.searchtask import (
SubscriptionSearchTaskRunner,
retry_at_after,
subscription_search_cancelled,
)
from app.domain.context import (
Context,
MediaInfo,
MusicInfo,
)
from app.domain.meta.metabase import MetaBase
from app.runtime.execution import await_task_to_terminal
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.runtime.tasks import get_task_registry
from app.schemas.types import (
MediaType,
MessageType,
SystemConfigKey,
)
_NEW_SUBSCRIPTION_EDIT_SECONDS = 60
def _finish_paged_subscription(owner: _SubscribeOwnerBase, subscribe: SubscriptionSnapshot,
target: SubscriptionSearchTarget) -> None:
"""
按当前订阅对账缺集,不回写搜索开始时的总集数;显式补搜暂停后仍正常结算。
"""
current = owner.subscription_repository.get(subscribe.id)
if current is None or owner._SubscribeChain__candidate_contract_changed(subscribe, current):
return
exists, missing = owner.resolve_subscribe_missing(
subscribe=current, meta=target.meta, mediainfo=target.media, mediakey=target.media_key)
owner.finish_subscribe_or_not(subscribe=current, meta=target.meta, mediainfo=target.media,
downloads=[], lefts=missing, force=exists)
def _subscription_search_strategy(best_version: bool) -> str:
"""
洗版固定全量;普通订阅按补全搜索策略,未设置时为智能。
"""
if best_version:
return "full"
strategy = get_configured_system_config().get(SystemConfigKey.SubscribeSearchStrategy)
return cast(str, strategy) if strategy in ("smart", "full", "single_page") else "smart"
def _subscription_rule_groups(subscribe: SubscriptionSnapshot) -> tuple[list[str], list[str]]:
"""
返回订阅普通搜索和洗版升级各自使用的规则组。
"""
configuration = get_configured_system_config()
if subscribe.filter_groups:
groups = list(subscribe.filter_groups)
return groups, groups
return (
configuration.get(SystemConfigKey.SubscribeFilterRuleGroups) or [],
configuration.get(SystemConfigKey.BestVersionFilterRuleGroups) or [],
)
def _best_version_target_episodes(
subscribe: SubscriptionSnapshot,
target: SubscriptionSearchTarget,
) -> set[int]:
"""
返回本轮洗版搜索覆盖的目标集数,用于区分首次下载与升级候选。
"""
media_key = str(target.media_key) if target.media_key is not None else ""
season_info = (
(target.missing.get(media_key) or {}).get(subscribe.season)
if subscribe.season is not None else None
)
if season_info and season_info.episodes:
return set(season_info.episodes)
start_episode = subscribe.start_episode or 1
total_episode = subscribe.total_episode or 0
return set(range(start_episode, total_episode + 1)) if total_episode >= start_episode else set()
def _build_subscription_candidate_filter(
owner: _SubscribeOwnerBase,
searchchain: SearchChain,
subscribe: SubscriptionSnapshot,
target: SubscriptionSearchTarget,
) -> Callable[[list[Context]], list[Context]]:
"""
按资源覆盖范围选择普通规则或洗版规则,再执行订阅候选校验。
分集洗版允许从未下载的剧集使用普通规则;全集洗版始终使用洗版规则,
避免普通规则的优先级被当作全集升级优先级。订阅显式规则组在两种模式下均优先。
"""
normal_groups, best_groups = _subscription_rule_groups(subscribe)
if not subscribe.best_version or target.media.type != MediaType.TV:
return partial(cast(Any, owner)._filter_search_contexts, subscribe)
media = cast(MediaInfo, target.media)
target_episodes = _best_version_target_episodes(subscribe, target)
downloaded_episodes = set(
owner._SubscribeChain__get_downloaded_best_version_episodes(
subscribe, total_episode=subscribe.total_episode,
)
)
def filter_contexts(contexts: list[Context]) -> list[Context]:
"""
分集模式区分首次下载和升级候选,全集模式统一执行升级筛选。
"""
initial_downloads: list[Context] = []
upgrades: list[Context] = []
for context in contexts:
episodes = set(context.meta_info.episode_list if context.meta_info else [])
if not episodes:
episodes = set(target_episodes)
if subscribe.best_version_full or (episodes and episodes <= downloaded_episodes):
upgrades.append(context)
else:
initial_downloads.append(context)
filtered: list[Context] = []
for candidates, groups, apply_best_version in (
(initial_downloads, normal_groups, False),
(upgrades, best_groups, True),
):
if not candidates:
continue
candidates = cast(Any, searchchain)._filter_identified_contexts(
candidates,
media,
groups,
{},
None,
)
filtered.extend(
cast(Any, owner)._filter_search_contexts(
subscribe,
candidates,
apply_best_version=apply_best_version,
)
)
return filtered
return filter_contexts
def _process_paged_subscription(owner: _SubscribeOwnerBase, subscribe: SubscriptionSnapshot,
searchchain: SearchChain, target: SubscriptionSearchTarget,
rule_key: SystemConfigKey,
execution_context: Optional[SubscriptionExecutionContext]) -> Optional[SubscriptionSnapshot]:
"""
影视订阅在每页处理后分批提交,保留当前任务的持久恢复身份。
"""
if not isinstance(target.media, MediaInfo):
raise ValueError("增量搜索仅适用于影视订阅")
dynamic_rule_groups = subscribe.best_version and target.media.type == MediaType.TV
candidate_filter: Callable[[list[Context]], list[Context]] = partial(
cast(Any, owner)._filter_search_contexts,
subscribe,
)
rule_groups = subscribe.filter_groups or get_configured_system_config().get(rule_key) or []
rule_groups_signature = None
if dynamic_rule_groups:
rule_groups = []
normal_groups, best_groups = _subscription_rule_groups(subscribe)
rule_groups_signature = {"normal": normal_groups, "best": best_groups}
candidate_filter = _build_subscription_candidate_filter(owner, searchchain, subscribe, target)
plan = MediaSearchPlan(
mediainfo=target.media, keyword=subscribe.keyword, no_exists=target.missing,
sites=owner.get_sub_sites(subscribe),
rule_groups=rule_groups,
area="imdbid" if subscribe.search_imdbid and target.media.imdb_id else "title",
custom_words=subscribe.custom_words.split("\n") if subscribe.custom_words else None,
filter_params=owner.get_params(subscribe), candidate_filter=candidate_filter,
rule_groups_signature=rule_groups_signature,
)
repository = getattr(getattr(owner, "subscription_search_repository", None), "sessions", None) if execution_context else None
snapshot = repository.get(task_id=execution_context.task_id) if repository and execution_context and execution_context.task_id else None
strategy = _subscription_search_strategy(bool(subscribe.best_version))
prefer_initial_pack = bool(cast(Any, owner)._SubscribeChain__build_full_pack_first_no_exists(
subscribe, target.media_key, target.missing))
scan = SearchScan(owner=searchchain, plan=plan, full=strategy == "full" or prefer_initial_pack,
page_limit=1 if strategy == "single_page" else None,
execution=execution_context, repository=repository, snapshot=snapshot,
target_scope=(subscribe.start_episode or 1, subscribe.total_episode or 0),
prefer_complete=prefer_initial_pack, complete_scan=strategy == "full")
if execution_context:
execution_context.incremental_search = True
def submit(contexts: list[Context], ready: set[str]) -> set[str]:
"""
仅下载已就绪目标,并记录本页候选和实际提交结果。
"""
_ensure_execution_active(execution_context)
if execution_context:
execution_context.report_phase("preparing")
logger.scoped_info(f"本页可选资源 {len(contexts)} 个,开始检查下载条件")
downloads, lefts = owner._SubscribeChain__download_best_version_with_full_pack_first(
contexts=contexts, no_exists=deepcopy(target.missing),
subscribe=subscribe, mediakey=target.media_key, username=subscribe.username,
save_path=subscribe.save_path, downloader=subscribe.downloader,
source=owner.get_subscribe_source_keyword(subscribe), execution_context=execution_context,
eligible_targets=ready,
)
activity = current_activity_run()
if activity:
for download in downloads or []:
activity.resource(download, "submitted")
logger.scoped_info(f"本页新增下载 {len(downloads or [])} 个")
if target.media.type == MediaType.MOVIE:
settled = ready if downloads else set()
else:
leftover = search_targets(MediaSearchPlan(mediainfo=target.media, no_exists=lefts)) if lefts else set()
settled = ready - leftover
current = owner.subscription_repository.get(subscribe.id)
if current and downloads:
owner.finish_subscribe_or_not(subscribe=current, meta=target.meta, mediainfo=target.media,
downloads=downloads, lefts=lefts)
return settled
scan.run(submit)
if not runtime_stop_state.is_system_stopped and not (execution_context and execution_context.should_stop()):
_finish_paged_subscription(owner, subscribe, target)
if execution_context:
execution_context.scan_finished = not execution_context.should_stop() and not runtime_stop_state.is_system_stopped
execution_context.report_phase("finished")
return cast(Optional[SubscriptionSnapshot], owner.subscription_repository.get(subscribe.id))
def _ensure_execution_active(
execution_context: Optional[SubscriptionExecutionContext],
) -> None:
"""
在可安全停止的边界区分用户取消与执行超时。
"""
if execution_context is None:
return
if execution_context.is_cancel_requested():
raise SubscriptionSearchCancelled("搜索已停止")
if execution_context.is_expired():
raise TimeoutError("这次搜索用时过长,已停止")
def _search_source_and_priority(
*,
sid: Optional[int],
sids: Optional[tuple[int, ...]],
state: Optional[str],
manual: Optional[bool],
) -> tuple[str, int]:
"""
把兼容入口归一为持久来源和公平队列优先级。
"""
if manual:
target_count = 1 if sid else len(sids or ())
return "manual", 120 if target_count == 1 else 100
if sid or sids is not None:
return "targeted", 80
if state in {"R", "P"}:
return "fallback", 10
return "new", 50
def _search_task_available_at(
source: str,
subscription_ids: tuple[int, ...],
*,
now: Optional[datetime] = None,
) -> dict[int, str]:
"""
整批仅抖动一次;请求节奏由站点限流控制,不随订阅数量累计空等。
"""
ordered_ids = tuple(dict.fromkeys(subscription_ids))
if not ordered_ids:
return {}
cursor = now or datetime.now(timezone.utc)
if source == "fallback":
cursor += timedelta(seconds=random.randint(0, 60))
return dict.fromkeys(ordered_ids, cursor.isoformat(timespec="seconds"))
class _SubscribeSearchQueueCoordinator(_SubscribeOwnerBase):
"""
订阅主动搜索入口、队列提交与消费协调组件。
"""
def _subscription_query(self) -> SubscriptionQueryService:
"""
构造绑定订阅 Oper 的查询应用服务。
"""
return SubscriptionQueryService(self.subscription_repository)
@classmethod
def exists(
cls,
mediainfo: MediaInfo,
meta: Optional[MetaBase] = None,
repository: Optional[SubscriptionRepository] = None,
) -> bool:
"""
判断订阅是否已存在
"""
chain = cls()
if repository is not None:
chain.subscription_repository = repository
result: bool = chain._subscription_query().exists(mediainfo, meta)
return result
def _acquire_run_lock(
self,
operation: str,
progress_callback: Optional[Callable[..., None]],
) -> bool:
"""
获取 Search 或 Match 通道锁,保持各通道内部串行。
"""
operation_label = {"search": "搜索", "match": "资源检查"}[operation]
lock = self._match_lock if operation == "match" else self._search_queue_lock
if lock.acquire(blocking=True, timeout=self._SUBSCRIPTION_EXECUTION_TTL):
logger.debug(f"订阅{operation_label}已开始:{datetime.now()}")
return True
progress_text = {
"search": "订阅搜索正在处理中,本次不再重复开始",
"match": "订阅资源检查正在进行,本次不再重复开始",
}[operation]
logger.error(f"订阅{operation_label}等待时间过长,本次不再重复开始")
if progress_callback:
progress_callback(
value=100,
text=progress_text,
)
return False
def search(
self,
sid: Optional[int] = None,
state: Optional[str] = "N",
manual: Optional[bool] = False,
progress_callback: Optional[Callable[..., None]] = None,
sids: Optional[tuple[int, ...]] = None,
scheduled_interval: Optional[int] = None,
) -> Optional[str]:
"""
执行订阅搜索。
scheduled_interval 仅供定时调度传入系统间隔;手动和指定目标搜索不受周期限制。
暂停会阻止自动搜索及下载;手动或指定目标补搜可以执行一次,也不会仅因搜索而恢复订阅。
"""
return self._execute_search(
sid=sid,
sids=sids,
state=state,
manual=manual,
progress_callback=progress_callback,
scheduled_interval=scheduled_interval,
)
def _execute_search(
self,
sid: Optional[int] = None,
state: Optional[str] = "N",
manual: Optional[bool] = False,
progress_callback: Optional[Callable[..., None]] = None,
sids: Optional[tuple[int, ...]] = None,
scheduled_interval: Optional[int] = None,
) -> Optional[str]:
"""
订阅搜索
:param sid: 订阅ID,有值时只处理该订阅
:param state: 订阅状态 N:新建, R:订阅中, P:待定, S:暂停
:param manual: 是否手动搜索
:param progress_callback: 定时服务进度更新回调
:param sids: 订阅ID集合,有值时按给定顺序处理
:return: 更新订阅状态为R或删除订阅
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
if queue is not None:
return self._execute_queued_search(
queue=queue,
sid=sid,
sids=sids,
state=state,
manual=manual,
progress_callback=progress_callback,
scheduled_interval=scheduled_interval,
)
self._execute_inline_search(
sid=sid,
sids=sids,
state=state,
manual=manual,
progress_callback=progress_callback,
scheduled_interval=scheduled_interval,
)
return None
def _reset_new_subscription(self, subscribe: SubscriptionSnapshot) -> SubscriptionSnapshot:
"""
搜索收尾前重读状态,避免旧的新建快照撤销用户暂停。
"""
current = self.subscription_repository.get(subscribe.id)
if current and current.state == "N":
return cast(
SubscriptionSnapshot,
self._SubscribeChain__apply_subscribe_update(current, {"state": "R"}, scene="search_reset"),
)
return current or subscribe
def _execute_inline_search(
self,
sid: Optional[int],
sids: Optional[tuple[int, ...]],
state: Optional[str],
manual: Optional[bool],
progress_callback: Optional[Callable[..., None]],
scheduled_interval: Optional[int] = None,
) -> None:
"""
在独立 Search 通道内按订阅准入执行兼容搜索。
"""
lock_acquired = self._acquire_run_lock("search", progress_callback)
if not lock_acquired:
return
subscribes = []
processed = []
summary: Optional[SearchExecutionSummary] = None
try:
subscribes = self._load_search_subscriptions(
sid=sid, sids=sids, state=state,
scheduled_interval=None if manual else scheduled_interval,
)
total = len(subscribes)
source, _priority = _search_source_and_priority(
sid=sid,
sids=sids,
state=state,
manual=manual,
)
summary = SearchExecutionSummary(source="inline", requested=total)
logger.info(summary.start_log())
if progress_callback:
progress_callback(
value=0,
text=f"开始订阅搜索,共 {total} 个订阅 ...",
data={"total": total, "finished": 0},
)
searchchain = SearchChain()
for index, subscribe in enumerate(subscribes, start=1):
if runtime_stop_state.is_system_stopped:
summary.stopped = True
break
self._report_search_progress(progress_callback, subscribe, index, total)
if self._recent_subscription_retry_at(subscribe, source):
summary.record("skipped", "recent_subscription")
continue
self._wait_before_scheduled_search(sid, sids, state, progress_callback)
lease = self._subscription_execution_admission.try_acquire(
subscription_id=subscribe.id,
operation="search",
ttl_seconds=self._SUBSCRIPTION_EXECUTION_TTL,
)
if lease is None:
logger.debug(f"订阅《{subscribe.name}》正在处理,本次搜索先不重复执行")
summary.record("skipped", "admission_conflict")
continue
allow_paused_search = source in {"manual", "targeted"}
execution_context = SubscriptionExecutionContext(
lease=lease,
admission=self._subscription_execution_admission,
cancel_requested=partial(
subscription_search_cancelled,
self.subscription_repository,
subscribe.id,
allow_paused_search,
lambda: runtime_stop_state.is_system_stopped,
),
allow_paused_search=allow_paused_search,
)
current = None
outcome: SearchTaskOutcome = "skipped"
reason: Optional[str] = "not_eligible"
try:
current = self.subscription_repository.get(subscribe.id)
if current is None or (current.state == "S" and not allow_paused_search):
continue
processed_result = self._process_search_subscription(
current,
searchchain,
execution_context=execution_context,
)
processed.append(processed_result or current)
current = processed_result
outcome = "completed"
reason = None
except SubscriptionSearchCancelled:
reason = "ttl_timeout" if execution_context.is_expired() else "cancelled"
outcome = "failed" if reason == "ttl_timeout" else "cancelled"
logger.debug(f"订阅《{subscribe.name}》搜索已停止")
except SubscriptionSearchDeferred as deferred:
outcome = "skipped"
reason = "site_budget_deferred"
logger.debug(
f"订阅《{subscribe.name}》遇到站点繁忙,将在稍后自动重试:{deferred.retry_at}"
)
except Exception as err:
outcome = "failed"
reason = "error"
logger.error(
f"订阅 {subscribe.name} 搜索失败:{str(err)}",
exc_info=not isinstance(err, SubscriptionSiteSearchFailed),
)
finally:
try:
if current and current.state == "N":
self._reset_new_subscription(current)
except Exception as err:
logger.error(
f"订阅《{subscribe.name}》搜索结束后没有恢复到正常状态,"
f"系统稍后会继续处理:{str(err)}",
exc_info=True,
)
finally:
released = self._subscription_execution_admission.release(lease)
if not released:
summary.release_failures += 1
logger.error(
f"订阅《{subscribe.name}》的搜索状态没有正常恢复,系统稍后会继续处理"
)
summary.record(outcome, reason)
self._report_search_progress(
progress_callback,
subscribe,
index,
total,
finished=True,
)
self._notify_manual_search(manual, sid, sids, subscribes, processed)
if progress_callback:
text, data = inline_search_result(total, len(processed))
progress_callback(value=100, text=text, data=data)
finally:
subscribes.clear()
self._search_queue_lock.release()
if summary is not None:
logger.info(summary.finish_log())
logger.debug(f"订阅搜索已结束:{datetime.now()}")
def _execute_queued_search(
self,
*,
queue: SubscriptionSearchRepository,
sid: Optional[int],
sids: Optional[tuple[int, ...]],
state: Optional[str],
manual: Optional[bool],
progress_callback: Optional[Callable[..., None]],
scheduled_interval: Optional[int] = None,
) -> Optional[str]:
"""
将搜索转为持久任务并在无 Match 长锁的短租约中串行消费。
"""
subscribes = self._load_search_subscriptions(
sid=sid, sids=sids, state=state,
scheduled_interval=None if manual else scheduled_interval,
)
if scheduled_interval is not None and not subscribes:
if progress_callback:
progress_callback(value=100, text="暂无到期订阅,无需搜索")
return None
source, priority = _search_source_and_priority(
sid=sid,
sids=sids,
state=state,
manual=manual,
)
subscription_ids = tuple(subscribe.id for subscribe in subscribes)
enqueued = queue.enqueue(
subscription_ids=subscription_ids,
source=source,
priority=priority,
available_at_by_subscription=_search_task_available_at(
source,
subscription_ids,
),
refresh_pending=scheduled_interval is not None,
)
total = len(subscribes)
summary = SearchExecutionSummary(
source=source,
requested=total,
batch_id=str(enqueued.batch.batch_id),
coalesced=enqueued.coalesced_count,
)
logger.info(summary.start_log())
batch: Optional[SearchBatchSnapshot] = None
try:
if progress_callback:
progress_callback(
value=0,
text=f"开始订阅搜索,共 {total} 个订阅 ...",
data={
"batch_id": enqueued.batch.batch_id,
"total": total,
"finished": 0,
"coalesced": enqueued.coalesced_count,
},
)
processed = self._drain_search_queue(
queue=queue,
limit=max(1, enqueued.created_count + enqueued.coalesced_count),
progress_callback=progress_callback,
summary=summary,
)
processed_subscribes = [item for item in subscribes if item.id in processed]
self._notify_manual_search(manual, sid, sids, subscribes, processed_subscribes)
batch = queue.get_batch(enqueued.batch.batch_id)
if progress_callback:
progress_callback(
value=100,
text=batch_progress_text(batch),
data={
"batch_id": enqueued.batch.batch_id,
"total": total,
"finished": batch_finished_count(batch, len(processed_subscribes)),
"coalesced": enqueued.coalesced_count,
},
)
return str(enqueued.batch.batch_id)
except Exception:
summary.round_failed = True
raise
finally:
logger.info(summary.finish_log(batch))
def _drain_search_queue(
self,
*,
queue: SubscriptionSearchRepository,
limit: int,
progress_callback: Optional[Callable[..., None]],
summary: SearchExecutionSummary,
) -> set[int]:
"""
有界消费可恢复任务;单任务失败不得阻止后续订阅。
"""
if not self._search_queue_lock.acquire(blocking=False):
logger.debug("订阅搜索已经在后台进行,本次安排会接着处理")
summary.consumer_conflicts += 1
return set()
owner = f"subscribe-search:{uuid4().hex}"
processed: set[int] = set()
try:
searchchain = SearchChain()
for index in range(1, limit + 1):
if runtime_stop_state.is_system_stopped:
summary.stopped = True
break
task = queue.claim_next(owner=owner)
if task is None:
break
subscription_id = self._execute_search_task(
queue=queue,
task=task,
owner=owner,
searchchain=searchchain,
index=index,
limit=limit,
progress_callback=progress_callback,
summary=summary,
)
if subscription_id is not None:
processed.add(subscription_id)
finally:
self._search_queue_lock.release()
return processed
class _SubscribeSearchQueueOwner(_SubscribeSearchQueueCoordinator):
"""
持久搜索任务执行与批次控制组件。
"""
def _execute_search_task(
self,
*,
queue: SubscriptionSearchRepository,
task: SearchTaskSnapshot,
owner: str,
searchchain: SearchChain,
index: int,
limit: int,
progress_callback: Optional[Callable[..., None]],
summary: SearchExecutionSummary,
) -> Optional[int]:
"""
执行一条已认领任务,返回实际进入搜索处理的订阅 ID。
"""
stop_state = getattr(self, "stop_state", runtime_stop_state)
runner = SubscriptionSearchTaskRunner(
queue=queue,
task=task,
owner=owner,
searchchain=searchchain,
index=index,
limit=limit,
progress_callback=progress_callback,
summary=summary,
subscription_repository=self.subscription_repository,
execution_admission=self._subscription_execution_admission,
execution_ttl=self._SUBSCRIPTION_EXECUTION_TTL,
recent_retry_at=self._recent_subscription_retry_at,
process_subscription=self._process_search_subscription,
reset_subscription=self._reset_new_subscription,
report_progress=self._report_search_progress,
stop_state=stop_state,
)
return runner.execute()
async def async_resume_search_queue(
self,
progress_callback: Optional[Callable[..., None]] = None,
limit: int = 50,
manual_sids: Optional[tuple[int, ...]] = None,
) -> None:
"""
托管恢复协程及手工反馈,等待同步消费者真实结束后才释放运行所有权。
"""
task = get_task_registry().create_sync(
self.resume_search_queue,
owner="subscription.search_queue",
progress_callback=progress_callback,
limit=limit,
manual_sids=manual_sids,
)
try:
await asyncio.shield(task)
except asyncio.CancelledError:
# 同步线程不可强制取消;真实收尾前不能让下一次轮询取得消费者所有权。
await await_task_to_terminal(task)
raise
def resume_search_queue(
self,
progress_callback: Optional[Callable[..., None]] = None,
limit: int = 50,
manual_sids: Optional[tuple[int, ...]] = None,
) -> None:
"""
短周期恢复等待任务,并可优先反馈本次手工搜索结果。
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
if queue is None:
return
manual_ids = tuple(dict.fromkeys(manual_sids or ()))
subscribes = (
self._load_search_subscriptions(sid=None, sids=manual_ids, state=None)
if manual_ids
else []
)
summary = SearchExecutionSummary(
source="manual" if manual_ids else "resume",
requested=len(manual_ids) if manual_ids else max(1, limit),
)
if manual_ids:
logger.info(summary.start_log())
try:
processed = self._drain_search_queue(
queue=queue,
limit=max(1, limit, len(manual_ids)),
progress_callback=progress_callback,
summary=summary,
)
if manual_ids:
processed_subscribes = [item for item in subscribes if item.id in processed]
self._notify_manual_search(
True,
manual_ids[0] if len(manual_ids) == 1 else None,
None if len(manual_ids) == 1 else manual_ids,
subscribes,
processed_subscribes,
)
except Exception:
summary.round_failed = True
raise
finally:
if not manual_ids and summary.processed:
summary.requested = summary.processed
if manual_ids or summary.processed or summary.round_failed:
logger.info(summary.finish_log())
def cancel_search_batch(self, batch_id: str) -> bool:
"""
请求取消持久搜索批次;未注入队列时返回失败。
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
return bool(queue and queue.request_cancel(batch_id))
def get_search_batch(self, batch_id: str) -> Optional[SearchBatchSnapshot]:
"""
返回持久搜索批次状态;未注入队列时返回空。
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
return queue.get_batch(batch_id) if queue else None
class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
"""
订阅搜索加载、单项处理与结果通知组件。
"""
def _load_search_subscriptions(
self,
sid: Optional[int],
sids: Optional[tuple[int, ...]],
state: Optional[str],
scheduled_interval: Optional[int] = None,
) -> list[SubscriptionSnapshot]:
"""
指定目标允许暂停订阅补搜;常规调度只从请求的活动状态选取新任务。
"""
repository = self.subscription_repository
if sid:
subscribe = repository.get(sid)
return [subscribe] if subscribe else []
if sids is not None:
return [item for current_id in sids if (item := repository.get(current_id)) is not None]
subscribes = cast(list[SubscriptionSnapshot], repository.list(self.get_states_for_search(state or "N")))
if scheduled_interval is None:
return subscribes
now = datetime.now(timezone.utc)
return [item for item in subscribes if subscription_search_due(item, scheduled_interval, now)]
@staticmethod
def _recent_subscription_retry_at(
subscribe: SubscriptionSnapshot,
source: str,
) -> Optional[str]:
"""
新订阅保留一分钟编辑时间,并返回自动开始搜索的时间。
"""
if source != "new" or not subscribe.date:
return None
try:
subscribe_time = datetime.strptime(subscribe.date, "%Y-%m-%d %H:%M:%S")
except ValueError:
logger.warning(f"订阅《{subscribe.name}》的添加时间无法识别,将直接开始搜索")
return None
remaining = _NEW_SUBSCRIPTION_EDIT_SECONDS - (
datetime.now() - subscribe_time
).total_seconds()
if remaining <= 0:
return None
retry_seconds = max(1, int(remaining) + 1)
logger.debug(f"新订阅《{subscribe.name}》将在约 {retry_seconds} 秒后自动开始搜索")
return retry_at_after(retry_seconds)
def _SubscribeChain__queue_new_subscription_search(
self,
subscribe_id: int,
) -> Optional[str]:
"""
在订阅保存后安排自动搜索,定时扫描仍作为恢复保障。
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
if queue is None:
return None
subscribe = self.subscription_repository.get(subscribe_id)
if subscribe is None or subscribe.state != "N":
return None
available_at = self._recent_subscription_retry_at(subscribe, "new")
enqueued = queue.enqueue(
subscription_ids=(subscribe_id,),
source="new",
priority=50,
available_at_by_subscription={
subscribe_id: available_at or datetime.now(timezone.utc).isoformat(timespec="seconds")
},
)
if enqueued.created_count:
logger.info(f"已安排新订阅《{subscribe.name}》自动搜索,保存好设置后会自动开始")
return enqueued.active_batch_ids[0] if enqueued.active_batch_ids else None
async def _SubscribeChain__async_queue_new_subscription_search(
self,
subscribe_id: int,
) -> Optional[str]:
"""
在异步订阅提交后安排自动搜索。
"""
queue: Optional[SubscriptionSearchRepository] = getattr(
self, "subscription_search_repository", None
)
if queue is None:
return None
subscribe = await self.subscription_repository.async_get(subscribe_id)
if subscribe is None or subscribe.state != "N":
return None
available_at = self._recent_subscription_retry_at(subscribe, "new")
enqueued = await queue.async_enqueue(
subscription_ids=(subscribe_id,),
source="new",
priority=50,
available_at_by_subscription={
subscribe_id: available_at
or datetime.now(timezone.utc).isoformat(timespec="seconds")
},
)
if enqueued.created_count:
logger.info(f"已安排新订阅《{subscribe.name}》自动搜索,保存好设置后会自动开始")
return enqueued.active_batch_ids[0] if enqueued.active_batch_ids else None
@staticmethod
def _wait_before_scheduled_search(
sid: Optional[int],
sids: Optional[tuple[int, ...]],
state: Optional[str],
progress_callback: Optional[Callable[..., None]],
) -> None:
"""
未使用持久队列时为自动兜底搜索保留逐订阅随机间隔。
"""
if sid or sids is not None or state not in {"R", "P"}:
return
sleep_time = random.randint(60, 300)
logger.debug(f"为了避免连续访问站点,约 {sleep_time} 秒后继续搜索")
if progress_callback:
progress_callback(text=f"为了避免连续访问站点,约 {sleep_time} 秒后继续搜索 ...")
time.sleep(sleep_time)
def _process_search_subscription(
self,
subscribe: SubscriptionSnapshot,
searchchain: Optional[SearchChain],
execution_context: Optional[SubscriptionExecutionContext] = None,
) -> Optional[SubscriptionSnapshot]:
"""
处理单个订阅,并返回下载后重新读取的状态快照。
"""
with subscription_log_scope(subscribe.id, "inline"), subscription_activity_scope(
getattr(self, "subscription_activity", None), subscribe, "search",
getattr(execution_context, "source", None) or "automatic",
getattr(execution_context, "task_id", None),
):
logger.scoped_info(f"开始搜索订阅:{subscribe.name}")
result = self._search_subscription(subscribe, searchchain, execution_context)
if result is not None:
logger.scoped_info("本轮搜索结束,订阅继续等待")
else:
logger.scoped_info("本轮搜索结束,订阅状态已更新;请以完成记录为准")
return result
def _search_subscription(
self,
subscribe: SubscriptionSnapshot,
searchchain: Optional[SearchChain],
execution_context: Optional[SubscriptionExecutionContext] = None,
) -> Optional[SubscriptionSnapshot]:
"""
在订阅日志作用域内执行既有搜索、下载和完成判定。
"""
_ensure_execution_active(execution_context)
# 站点补查属于同一轮搜索,不能反复推迟下一轮健康站点的完整检查。
if execution_context is None or not execution_context.resuming_sites:
subscribe = self._SubscribeChain__apply_subscribe_update(
subscribe,
{"last_search": datetime.now(timezone.utc).isoformat(timespec="seconds")},
scene="search",
)
logger.debug(f"开始搜索订阅,标题:{subscribe.name} ...")
target = prepare_search_target(
self, subscribe, MediaChain(), partial(_ensure_execution_active, execution_context),
)
if target is None:
return subscribe
subscribe, meta, mediainfo = target.subscribe, target.meta, target.media
mediakey, no_exists = target.media_key, target.missing
if searchchain is None:
searchchain = SearchChain()
rule_key = (
SystemConfigKey.BestVersionFilterRuleGroups
if subscribe.best_version
else SystemConfigKey.SubscribeFilterRuleGroups
)
if execution_context:
execution_context.report_phase("searching")
if not isinstance(mediainfo, MusicInfo):
return _process_paged_subscription(self, subscribe, searchchain, target, rule_key, execution_context)
contexts = searchchain.process(
mediainfo=mediainfo,
keyword=subscribe.keyword,
no_exists=no_exists,
sites=self.get_sub_sites(subscribe),
rule_groups=subscribe.filter_groups or get_configured_system_config().get(rule_key) or [],
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,
filter_params=self.get_params(subscribe),
candidate_filter=partial(self._filter_search_contexts, subscribe),
)
site_budget_failures = searchchain.consume_subscription_site_budget_failures(
has_results=bool(contexts),
)
site_budget_deferrals = searchchain.consume_subscription_site_budget_deferrals()
_ensure_execution_active(execution_context)
if not contexts:
logger.debug(f"订阅 {subscribe.keyword or 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:
execution_context.report_phase("preparing")
_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(
contexts=contexts,
no_exists=no_exists,
subscribe=subscribe,
mediakey=mediakey,
username=subscribe.username,
save_path=subscribe.save_path,
downloader=subscribe.downloader,
source=self.get_subscribe_source_keyword(subscribe),
execution_context=execution_context,
)
activity = current_activity_run()
if activity:
for download in downloads or []:
activity.resource(download, "submitted")
current = self.subscription_repository.get(subscribe.id)
if current:
self.finish_subscribe_or_not(
subscribe=current,
meta=meta,
mediainfo=mediainfo,
downloads=downloads,
lefts=lefts,
)
raise_subscription_site_budget_failures(site_budget_failures)
raise_subscription_site_budget_deferral(site_budget_deferrals, execution_context)
return cast(Optional[SubscriptionSnapshot], current)
def _filter_search_contexts(
self,
subscribe: SubscriptionSnapshot,
contexts: list[Any],
*,
apply_best_version: bool = True,
) -> list[Any]:
"""
过滤不满足订阅范围或洗版优先级的候选,并释放原结果列表。
"""
matched = []
activity = current_activity_run()
try:
for context in contexts:
if activity:
activity.resource(context, "rejected", "不符合洗版范围或升级优先级")
if runtime_stop_state.is_system_stopped:
break
torrent_meta = context.meta_info
torrent_info = context.torrent_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 apply_best_version and subscribe.best_version and media.type == MediaType.TV:
if not self._SubscribeChain__is_full_season_best_version_resource(torrent_meta, subscribe):
logger.debug(f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源")
continue
if not self._is_episode_range_covered(torrent_meta, subscribe):
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围")
continue
if not self._SubscribeChain__prepare_best_version_tv_candidate(
subscribe,
context,
torrent_info.pri_order,
):
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级未达到当前模式的升级条件")
continue
if (
apply_best_version
and subscribe.best_version
and media.type != MediaType.TV
and subscribe.current_priority
and torrent_info.pri_order <= subscribe.current_priority
):
logger.debug(f"{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级")
continue
media = apply_subscription_classification(
media,
subscribe,
)
context.media_info = media
if subscribe.episode_group:
media.episode_group = subscribe.episode_group
matched.append(context)
if activity:
activity.resource(context, "qualified")
finally:
contexts.clear()
return matched
@staticmethod
def _report_search_progress(
progress_callback: Optional[Callable[..., None]],
subscribe: SubscriptionSnapshot,
index: int,
total: int,
finished: bool = False,
) -> None:
"""
发布单订阅搜索开始或完成进度。
"""
if not progress_callback:
return
done = index if finished else index - 1
data = {"total": total, "finished": done}
if not finished:
data["current"] = subscribe.id
progress_callback(
value=done / total * 100 if total else 100,
text=(
f"订阅搜索({index}/{total})处理完成"
if finished
else f"正在搜索订阅({index}/{total}){subscribe.name} ..."
),
data=data,
)
def _notify_manual_search(
self,
manual: Optional[bool],
sid: Optional[int],
sids: Optional[tuple[int, ...]],
subscribes: list[SubscriptionSnapshot],
processed: list[SubscriptionSnapshot],
) -> None:
"""
用清晰文案反馈手动搜索的完成或等待状态。
"""
if not manual:
return
if not subscribes:
self.messagehelper.put(
"没有找到订阅!", title="订阅搜索", role="system", mtype=MessageType.Subscribe, level="warning"
)
elif sid:
message = (
f"{subscribes[0].name} 搜索完成!"
if processed
else f"{subscribes[0].name} 已安排搜索,系统会自动继续处理。"
)
self.messagehelper.put(message, title="订阅搜索", role="system", mtype=MessageType.Subscribe)
elif sids is not None:
for subscribe in processed:
self.messagehelper.put(
f"{subscribe.name} 搜索完成!", title="订阅搜索", role="system", mtype=MessageType.Subscribe
)
else:
message = (
"所有订阅搜索完成!"
if len(processed) == len(subscribes)
else "订阅搜索已安排,暂未完成的项目会自动继续处理。"
)
self.messagehelper.put(message, title="订阅搜索", role="system", mtype=MessageType.Subscribe)