fix(subscribe): preserve accepted searches for paused subscriptions

This commit is contained in:
InfinityPacer
2026-10-04 00:15:28 +08:00
committed by InfinityPacer
parent 1d30d6e8a4
commit 6e295d5d2f
9 changed files with 361 additions and 23 deletions

View File

@@ -491,7 +491,10 @@ class MusicSubscribeMixin:
if current_subscribe is None:
logger.info(f"音乐订阅 {subscribe.id} 已删除,放弃本轮下载提交")
return
if current_subscribe.state == "S":
# 与影视订阅一致,暂停不撤销已接纳的搜索,自动匹配仍不能为暂停订阅提交下载。
if current_subscribe.state == "S" and not (
execution_context and execution_context.lease.operation == "search"
):
logger.info(f"音乐订阅 {current_subscribe.name} 已暂停,放弃本轮下载提交")
return
if self._SubscribeChain__candidate_contract_changed(subscribe, current_subscribe):

View File

@@ -302,7 +302,10 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner):
if current is None:
logger.info(f"订阅 {subscribe.id} 已删除,放弃本轮下载提交")
return [], no_exists
if current.state == "S":
# 已接纳的 Search 可完成本次下载;无搜索上下文的 Match 等路径仍遵守暂停状态。
if current.state == "S" and not (
execution_context and execution_context.lease.operation == "search"
):
logger.info(f"订阅 {current.name} 已暂停,放弃本轮下载提交")
return [], no_exists
if self._SubscribeChain__candidate_contract_changed(subscribe, current):
@@ -482,7 +485,7 @@ class SubscribePolicyOwner(_SubscribePriorityPolicyOwner):
N: New(新建,未处理)
R: Resolved(订阅中)
P: Pending(待定,信息待进一步更新,允许搜索,不允许完成)
S: Suspended(暂停,订阅不参与任何动作,暂时停止处理)
S: Suspended(暂停自动调度,已接纳搜索及指定订阅补搜可继续执行)
:return: 需要查询的状态列表(多个状态用逗号分隔)
"""
# 如果状态是 R 或 P,则视为一起搜索,返回 R,P 作为查询条件

View File

@@ -165,6 +165,7 @@ class _SubscribeSearchQueueCoordinator(_SubscribeOwnerBase):
执行订阅搜索。
scheduled_interval 仅供定时调度传入系统间隔;手动和指定目标搜索不受周期限制。
指定目标可对暂停订阅搜索一次,已接纳的搜索不因暂停中断,也不会仅因搜索而恢复订阅。
"""
return self._execute_search(
sid=sid,
@@ -281,9 +282,8 @@ class _SubscribeSearchQueueCoordinator(_SubscribeOwnerBase):
reason: Optional[str] = "not_eligible"
try:
current = self.subscription_repository.get(subscribe.id)
if current is None or current.state == "S":
if current and current.state == "S":
logger.debug(f"订阅《{current.name}》已暂停,本次没有搜索")
# 与持久队列保持一致,暂停不取消本轮已选取的搜索。
if current is None:
continue
processed_result = self._process_search_subscription(
current,
@@ -613,7 +613,7 @@ class SubscribeSearchOwner(_SubscribeSearchQueueOwner):
state: Optional[str],
scheduled_interval: Optional[int] = None,
) -> list[SubscriptionSnapshot]:
"""按单条、指定批次或状态读取本轮搜索订阅。"""
"""指定目标允许暂停订阅补搜;常规调度只从请求的活动状态选取新任务。"""
repository = self.subscription_repository
if sid:
subscribe = repository.get(sid)

View File

@@ -198,10 +198,7 @@ class SubscriptionSearchTaskRunner:
if current is None:
self._finish_missing_subscription(task_id, lease_token)
return None
if current.state == "S":
skip_search_task(self.queue, self.task, "订阅已暂停,这次没有搜索")
self.summary.record("skipped", "paused")
return None
# 暂停只影响后续自动调度;持久任务已接纳的一次搜索继续执行,停止须显式取消任务。
self.searchchain.configure_subscription_site_budget(
SubscriptionSiteBudget(
repository=self.queue,

View File

@@ -87,6 +87,11 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普
### 自动搜索与站点恢复
暂停订阅不会被常规自动调度选取,但可以通过指定订阅搜索执行一次补搜。暂停不取消已经入队或
正在执行的搜索,包括等待站点恢复的任务;需要停止本次执行时,应取消对应搜索批次。
搜索及下载提交沿用同一执行上下文,不会为了补搜临时启用订阅,也不会在搜索无结果时恢复订阅。
下载成功后插件可以根据关联订阅的下载事件恢复订阅,已经完成的订阅仍按正常完成规则收口。
`last_search` 记录完整搜索轮次的开始时间,供订阅独立周期或系统周期判断下一次搜索是否到期。
因站点暂时不可用而仅补查剩余站点时,不刷新此时间,避免坏站点持续推迟正常站点的新一轮搜索。
同一轮恢复保留待搜索站点;经过到期筛选的新周期会把尚未运行的恢复任务重新覆盖当前启用站点。

View File

@@ -59,7 +59,7 @@
"app/chain/subscribe/query.py:SubscribeQueryOwner.follow": 19,
"app/chain/subscribe/query.py:SubscribeQueryOwner.subscribe_files_info": 24,
"app/chain/subscribe/refresh.py:SubscribeRefreshOwner.backfill_existing_episodes": 16,
"app/chain/subscribe/search.py:_SubscribeSearchQueueCoordinator._execute_inline_search": 17,
"app/chain/subscribe/search.py:_SubscribeSearchQueueCoordinator._execute_inline_search": 16,
"app/chain/transfer/execution.py:TransferExecutionOwner._TransferChain__perform_transfer": 31,
"app/chain/transfer/filter.py:FileFilterMixin._restore_music_download_context": 24,
"app/chain/transfer/music.py:_release_groups": 16,

View File

@@ -151,12 +151,12 @@ def _configure_subscription_write(chain, repository) -> None:
chain.sync_subscription_mutation_scope = mutation_scope
def _execution_context(*, cancelled=None) -> SubscriptionExecutionContext:
def _execution_context(*, cancelled=None, operation="search") -> SubscriptionExecutionContext:
"""构造音乐订阅使用的独立执行上下文。"""
admission = SubscriptionExecutionAdmission()
lease = admission.try_acquire(
subscription_id=7,
operation="search",
operation=operation,
ttl_seconds=60,
)
assert lease is not None
@@ -330,7 +330,7 @@ def test_music_download_marks_shared_execution_context_before_side_effect():
def test_music_download_rechecks_paused_state_before_submission():
"""候选准备后暂停的音乐订阅不得进入下载器。"""
"""没有搜索执行上下文时,暂停的音乐订阅不得提交下载。"""
subscribe = _subscribe()
paused = _subscribe(state="S")
repository = Mock()
@@ -343,12 +343,101 @@ def test_music_download_rechecks_paused_state_before_submission():
subscribe,
_music_info(),
[Context()],
execution_context=_execution_context(),
)
download_chain.assert_not_called()
@pytest.mark.parametrize("prepared_state", ["R", "S"])
def test_music_accepted_search_downloads_paused_subscription(prepared_state):
"""已接纳搜索和指定暂停订阅补搜能提交候选,不主动恢复订阅状态。"""
prepared = _subscribe(state=prepared_state)
current = _subscribe(state="S")
target = _music_info()
candidate = Context(
torrent_info=TorrentInfo(title="周杰伦 - 晴天 FLAC", category=MediaType.MUSIC.value),
meta_info=MetaMusic.from_music_info(target),
media_info=target,
)
execution = _execution_context()
repository = Mock()
repository.get.return_value = current
chain = SubscribeChain()
chain.subscription_repository = repository
chain.finish_subscribe_or_not = Mock()
download_chain = Mock()
def submit(**kwargs):
"""模拟外部提交,并验证策略传入真实搜索取消和副作用信号。"""
assert kwargs["contexts"] == [candidate]
assert kwargs["governance"].cancelled() is False
kwargs["governance"].mark_started()
return [candidate], None
download_chain.batch_download.side_effect = submit
with patch("app.chain._music.DownloadChain", return_value=download_chain):
chain._download_music_subscribe(prepared, target, [candidate], execution_context=execution)
download_chain.batch_download.assert_called_once()
assert execution.download_started is True
chain.finish_subscribe_or_not.assert_called_once()
assert chain.finish_subscribe_or_not.call_args.kwargs["subscribe"] is current
assert chain.finish_subscribe_or_not.call_args.kwargs["downloads"] == [candidate]
repository.update.assert_not_called()
assert current.state == "S"
assert execution.admission.release(execution.lease) is True
def test_music_paused_match_cannot_submit_download():
"""匹配执行上下文不能使用搜索任务的暂停放行规则。"""
repository = Mock()
repository.get.return_value = _subscribe(state="S")
chain = SubscribeChain()
chain.subscription_repository = repository
chain.finish_subscribe_or_not = Mock()
execution = _execution_context(operation="match")
with patch("app.chain._music.DownloadChain") as download_chain:
chain._download_music_subscribe(
_subscribe(), _music_info(), [Context()], execution_context=execution,
)
download_chain.assert_not_called()
chain.finish_subscribe_or_not.assert_not_called()
assert execution.download_started is False
assert execution.admission.release(execution.lease) is True
@pytest.mark.parametrize("cancel_at", ["entry", "preparing"])
def test_music_paused_search_still_cancels_before_submission(cancel_at):
"""暂停搜索的放行不能绕过入口或资源准备阶段的取消。"""
cancelled = [cancel_at == "entry"]
execution = _execution_context(cancelled=lambda: cancelled[0])
def on_phase(phase, _site_id):
if phase == cancel_at:
cancelled[0] = True
execution.phase_changed = on_phase
repository = Mock()
repository.get.return_value = _subscribe(state="S")
chain = SubscribeChain()
chain.subscription_repository = repository
chain.finish_subscribe_or_not = Mock()
with patch("app.chain._music.DownloadChain") as download_chain:
with pytest.raises(SubscriptionSearchCancelled):
chain._download_music_subscribe(
_subscribe(), _music_info(), [Context()], execution_context=execution,
)
download_chain.assert_not_called()
chain.finish_subscribe_or_not.assert_not_called()
assert execution.download_started is False
assert execution.admission.release(execution.lease) is True
def test_music_subscribe_filters_declared_bitrate_and_format():
"""音乐订阅应按规范化格式和最低码率过滤站点资源。"""
subscribe = _subscribe(audio_format="MP3", min_bitrate=320000)

View File

@@ -0,0 +1,174 @@
"""暂停订阅中已接纳搜索的真实下载边界回归。"""
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import app.chain.download.batch as download_batch
import app.chain.download.selection as download_selection
from app.application.subscription.contract import SubscriptionSnapshot
from app.application.subscription.execution import (
SubscriptionExecutionAdmission,
SubscriptionExecutionContext,
)
from app.chain.subscribe import policy as subscribe_policy
from app.chain.subscribe.facade import SubscribeChain
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MediaSource, MediaType
from tests.test_subscription_download_governance import (
_context,
_download_chain,
)
from tests.test_subscription_download_governance import (
_submission_dependencies as _submission_dependencies,
)
pytestmark = pytest.mark.usefixtures("_submission_dependencies")
@pytest.fixture
def download_scope(monkeypatch):
"""保留订阅策略、候选选择与单资源提交,只隔离存储和外部下载器。"""
current = SubscriptionSnapshot(
id=7,
name="Demo Show",
type=MediaType.TV.value,
media_source=MediaSource.TMDB,
media_id="77",
season=1,
total_episode=1,
state="S",
save_path="/downloads",
downloader="qb",
)
context = _context()
missing = {"tmdb:77": {1: NotExistMediaInfo(season=1, episodes=[1], total_episode=1)}}
download = _download_chain()
download.download_torrent = MagicMock(return_value=(b"torrent", None, [1]))
download._active_download_failure_fingerprints = MagicMock(return_value={})
download.download = MagicMock(return_value=("qb", "hash-paused-search", "Original", "accepted"))
chain = SubscribeChain.__new__(SubscribeChain)
chain.subscription_repository = SimpleNamespace(get=MagicMock(return_value=current))
chain.check_and_handle_existing_media = MagicMock(return_value=(False, missing))
helper = SimpleNamespace(sort_torrents=lambda contexts: contexts)
monkeypatch.setattr(download_selection, "_new_torrent_helper", lambda: helper)
monkeypatch.setattr(download_batch, "runtime_stop_state", SimpleNamespace(is_system_stopped=False))
monkeypatch.setattr(download_selection, "runtime_stop_state", SimpleNamespace(is_system_stopped=False))
monkeypatch.setattr(subscribe_policy, "DownloadChain", lambda: download)
return SimpleNamespace(
current=current,
context=context,
missing=missing,
download=download,
chain=chain,
)
def _execution(operation="search", cancelled=lambda: False):
"""固定执行时钟,保留真实租约、取消与副作用阶段信号。"""
admission = SubscriptionExecutionAdmission(clock=lambda: 0)
lease = admission.try_acquire(subscription_id=7, operation=operation, ttl_seconds=60)
assert lease is not None
return SubscriptionExecutionContext(
lease=lease,
admission=admission,
task_id="search-task-7",
cancel_requested=cancelled,
)
def _submit(scope, prepared, execution_context):
return scope.chain._SubscribeChain__download_best_version_with_full_pack_first(
contexts=[scope.context],
no_exists={"tmdb:77": {1: NotExistMediaInfo(season=1, episodes=[1], total_episode=1)}},
subscribe=prepared,
mediakey="tmdb:77",
execution_context=execution_context,
)
@pytest.mark.parametrize("prepared_state", ["R", "S"])
def test_accepted_search_downloads_while_subscription_is_paused(download_scope, prepared_state):
"""搜索中途暂停和指定暂停订阅补搜均能提交,不主动恢复订阅状态。"""
scope = download_scope
execution = _execution()
downloads, remaining = _submit(scope, replace(scope.current, state=prepared_state), execution)
assert downloads == [scope.context]
assert remaining == {}
scope.download.download.assert_called_once()
assert scope.download.download.call_args.kwargs["downloader"] == "qb"
assert scope.download.download.call_args.kwargs["content"] == b"torrent"
scope.download._settle_download_success.assert_called_once()
assert execution.download_started is True
assert scope.current.state == "S"
assert execution.admission.release(execution.lease) is True
@pytest.mark.parametrize("operation", [None, "match"])
def test_paused_subscription_blocks_download_without_search_execution(download_scope, operation):
"""无搜索上下文和自动匹配不能借暂停订阅创建下载任务。"""
scope = download_scope
execution = _execution(operation) if operation else None
downloads, remaining = _submit(scope, scope.current, execution)
assert downloads == []
assert remaining["tmdb:77"][1].episodes == [1]
scope.chain.check_and_handle_existing_media.assert_not_called()
scope.download.download.assert_not_called()
scope.download._settle_download_success.assert_not_called()
if execution:
assert execution.download_started is False
assert execution.admission.release(execution.lease) is True
def test_paused_search_still_honors_cancellation_at_downloader_boundary(download_scope):
"""资源准备期间的取消仍在真实下载器调用前生效。"""
scope = download_scope
cancelled = [False]
execution = _execution(cancelled=lambda: cancelled[0])
def prepare_torrent(_torrent, **_kwargs):
cancelled[0] = True
return b"torrent", None, [1]
scope.download.download_torrent.side_effect = prepare_torrent
downloads, remaining = _submit(scope, scope.current, execution)
scope.chain.check_and_handle_existing_media.assert_called_once()
scope.download.download_torrent.assert_called_once()
assert downloads == []
assert remaining["tmdb:77"][1].episodes == [1]
scope.download.download.assert_not_called()
scope.download._settle_download_success.assert_not_called()
assert execution.download_started is False
assert execution.admission.release(execution.lease) is True
@pytest.mark.parametrize("change", ["deleted", "filter", "identity"])
def test_paused_search_discards_invalidated_candidates(download_scope, change):
"""暂停搜索的放行不能绕过删除、筛选或媒体身份变化。"""
scope = download_scope
prepared = replace(scope.current, state="R")
current = {
"deleted": None,
"filter": replace(scope.current, quality="2160p"),
"identity": replace(scope.current, media_id="88"),
}[change]
scope.chain.subscription_repository.get.return_value = current
execution = _execution()
downloads, remaining = _submit(scope, prepared, execution)
assert downloads == []
assert remaining["tmdb:77"][1].episodes == [1]
scope.chain.check_and_handle_existing_media.assert_not_called()
scope.download.download.assert_not_called()
scope.download._settle_download_success.assert_not_called()
assert execution.download_started is False
assert execution.admission.release(execution.lease) is True

View File

@@ -703,25 +703,92 @@ def test_manual_search_bypasses_new_subscription_edit_wait(tmp_path, monkeypatch
assert chain.get_search_batch(batch_id).state == "completed"
def test_paused_subscription_is_skipped_after_admission_refresh(tmp_path, monkeypatch):
"""准入后重新读取到暂停订阅时不得开始搜索或伪造完成。"""
def test_accepted_search_continues_after_subscription_is_paused(tmp_path, monkeypatch):
"""暂停不等于取消,已接纳的定时搜索继续使用最新订阅快照。"""
subscribe = _subscribe(9)
paused = replace(subscribe, state="S")
chain = _chain(tmp_path, [subscribe])
_make_tasks_ready(monkeypatch)
chain.subscription_repository.get = Mock(side_effect=(subscribe, paused))
process = Mock(side_effect=AssertionError("暂停订阅不应进入 Search"))
process = Mock(return_value=paused)
monkeypatch.setattr(chain, "_process_search_subscription", process)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
batch_id = chain.search(state="R")
batch = chain.get_search_batch(batch_id)
process.assert_called_once()
assert process.call_args.args[0] == paused
assert batch.state == "completed"
assert batch.finished_count == 1
assert batch.skipped_count == 0
@pytest.mark.parametrize("queued", [False, True])
@pytest.mark.parametrize("manual", [False, True])
def test_targeted_paused_search_preserves_subscription_state(tmp_path, monkeypatch, queued, manual):
"""插件及手动指定补搜均能执行一次,未产生下载时不能擅自恢复订阅。"""
subscribe = replace(_subscribe(9), state="S")
chain = _chain(tmp_path, [subscribe])
if not queued:
del chain.subscription_search_repository
_make_tasks_ready(monkeypatch)
process = Mock(return_value=subscribe)
reset = Mock(side_effect=AssertionError("补搜不能自行恢复暂停订阅"))
monkeypatch.setattr(chain, "_process_search_subscription", process)
monkeypatch.setattr(chain, "_SubscribeChain__apply_subscribe_update", reset)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
batch_id = chain.search(sid=subscribe.id, manual=manual)
process.assert_called_once()
assert process.call_args.kwargs["execution_context"].lease.operation == "search"
assert chain.subscription_repository.get(subscribe.id).state == "S"
reset.assert_not_called()
if queued:
assert chain.get_search_batch(batch_id).state == "completed"
def test_scheduled_search_does_not_enqueue_paused_subscription(tmp_path, monkeypatch):
"""暂停订阅不进入常规状态选取,放行已有任务不能生成新的自动搜索。"""
subscribe = replace(_subscribe(9), state="S")
chain = _chain(tmp_path, [subscribe])
chain.subscription_repository.list = Mock(
side_effect=lambda states: [subscribe] if subscribe.state in states.split(",") else [],
)
process = Mock(side_effect=AssertionError("暂停订阅不应产生新自动任务"))
monkeypatch.setattr(chain, "_process_search_subscription", process)
batch_id = chain.search(state="R", scheduled_interval=24)
assert batch_id is None
chain.subscription_repository.list.assert_called_once_with("R")
assert chain.subscription_search_repository.claim_next(owner="test") is None
process.assert_not_called()
assert batch.state == "skipped"
assert batch.finished_count == 0
assert batch.skipped_count == 1
assert batch.last_error == "订阅已暂停,这次没有搜索"
@pytest.mark.parametrize("cancelled", [False, True])
def test_paused_search_survives_queue_reopen_unless_cancelled(tmp_path, monkeypatch, cancelled):
"""暂停后的持久任务跨消费者恢复继续执行,显式取消仍阻止搜索。"""
subscribe = _subscribe(9)
chain = _chain(tmp_path, [subscribe])
_make_tasks_ready(monkeypatch)
with chain._search_queue_lock:
batch_id = chain.search(sid=subscribe.id)
assert chain.get_search_batch(batch_id).state == "queued"
if cancelled:
assert chain.cancel_search_batch(batch_id)
paused = replace(subscribe, state="S")
resumed = _chain(tmp_path, [paused])
process = Mock(return_value=paused)
monkeypatch.setattr(resumed, "_process_search_subscription", process)
with patch("app.chain.subscribe.search.SearchChain", return_value=Mock()):
resumed.resume_search_queue()
assert process.call_count == (0 if cancelled else 1)
assert resumed.get_search_batch(batch_id).state == ("cancelled" if cancelled else "completed")
assert resumed.subscription_repository.get(subscribe.id).state == "S"
def test_cleanup_failures_cannot_leak_subscription_admission(tmp_path, monkeypatch):