Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins.v2/subscribeassistantenhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
| 跳过近期删除资源 | `skip_deletion` | bool | `true` | 资源选择时跳过近期删除资源 | 内部按资源删除指纹匹配 |
| 下载超时时间(分钟) | `download_timeout_minutes` | int(分钟) | `120` | 进度观察窗口长度 | 不是总下载时长上限 |
| [下载超时进度阈值](#cfg-download_progress_threshold) | `download_progress_threshold` | int(%) | `10` | 窗口内进度低于阈值视为停滞 | 与重试次数共同决定是否删种 |
| 下载排队宽限倍数 | `download_queue_grace_multiplier` | int(倍) | `2` | 下载器明确排队时额外宽限 N 个超时窗口 | `0` 表示不宽限;默认最长观察时间为超时窗口的 3 倍 |
| 下载连续超时重试次数 | `download_retry_limit` | int | `3` | 同一订阅/集数范围允许的连续停滞次数 | 未达上限时自动删种并补搜,达到上限后保留当前种子并提示人工复核 |
| 删除记录保留(小时) | `delete_record_retention_hours` | int(小时) | `24` | 近期删除记录保留时间 | 到期由删除记录清理任务移除 |
| 排除标签 | `delete_exclude_tags` | str | `H&R` | 带有这些标签的种子不自动删除 | 多个标签用逗号分隔 |
Expand Down
1 change: 1 addition & 0 deletions plugins.v2/subscribeassistantenhanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ def _init_modules(self):
tm.read, tm.update,
timeout_minutes=cfg.download_timeout_minutes,
progress_threshold=cfg.download_progress_threshold,
queue_grace_multiplier=cfg.download_queue_grace_multiplier,
retry_limit=cfg.download_retry_limit,
tracker_keywords=tracker_keywords,
exclude_tags=exclude_tags,
Expand Down
88 changes: 83 additions & 5 deletions plugins.v2/subscribeassistantenhanced/download/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class DownloadMonitor:
def __init__(self, task_data_read: Callable, task_data_update: Callable,
timeout_minutes: int = 180,
progress_threshold: int = 5,
queue_grace_multiplier: int = 2,
retry_limit: int = 3,
tracker_keywords: Optional[list] = None,
exclude_tags: Optional[list] = None,
Expand All @@ -36,6 +37,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable,
self._update = task_data_update
self._timeout_seconds = timeout_minutes * 60
self._progress_threshold = progress_threshold
self._queue_grace_multiplier = max(int(queue_grace_multiplier or 0), 0)
self._retry_limit = retry_limit
self._tracker_keywords = tracker_keywords or []
self._exclude_tags = exclude_tags or []
Expand Down Expand Up @@ -160,6 +162,8 @@ def updater(data: dict) -> dict:
"description": description,
"baseline_progress": progress,
"baseline_at": now,
"queue_grace_seconds": 0,
"last_timeout_check_at": now,
"retry_count": 0,
"manual_review_count": 0,
"time": now,
Expand Down Expand Up @@ -554,8 +558,16 @@ def check_torrent(self, torrent_info: TorrentInfo, subscribe_id: int) -> str:
self._clear_timeout_state(subscribe_id, torrent_task)
return "ok"

elapsed = time.time() - torrent_task.get("baseline_at", time.time())
if elapsed < self._timeout_seconds:
queue_grace_seconds = self._observe_queue_grace(torrent_info, torrent_task)
_, effective_elapsed = self._timeout_elapsed(torrent_task, queue_grace_seconds)
if torrent_info.queue_waiting and self._max_queue_grace_seconds() > 0:
detail(
f"下载监控:种子 {torrent_info.hash} 处于下载器排队状态,"
f"已使用排队宽限 {queue_grace_seconds / 3600:.2f}/"
f"{self._max_queue_grace_seconds() / 3600:g} 小时,"
f"有效低进度观察 {effective_elapsed / 3600:.2f} 小时"
)
if effective_elapsed < self._timeout_seconds:
return "ok"

if self._is_timeout_ignore_active(subscribe_id, torrent_info.hash, torrent_task):
Expand Down Expand Up @@ -602,10 +614,14 @@ def _get_torrent_task(self, torrent_hash: str) -> Optional[dict]:
return data.get(torrent_hash)

def _init_torrent_task(self, info: TorrentInfo):
now = time.time()

def updater(data: dict) -> dict:
data[info.hash] = {
"baseline_progress": info.progress,
"baseline_at": time.time(),
"baseline_at": now,
"queue_grace_seconds": 0,
"last_timeout_check_at": now,
"retry_count": 0,
"manual_review_count": 0,
}
Expand All @@ -618,13 +634,68 @@ def _has_progress(self, info: TorrentInfo, task: dict) -> bool:
return diff >= self._progress_threshold

def _refresh_baseline(self, info: TorrentInfo):
now = time.time()

def updater(data: dict) -> dict:
task = data.get(info.hash, {})
task["baseline_progress"] = info.progress
task["baseline_at"] = time.time()
task["baseline_at"] = now
task["queue_grace_seconds"] = 0
task["last_timeout_check_at"] = now
data[info.hash] = task
return data
self._update("torrents", updater)

def _observe_queue_grace(self, info: TorrentInfo, torrent_task: dict) -> float:
"""累计本轮低进度周期的排队抵扣时间,并限制在配置的额外宽限内。"""
now = time.time()
last_checked_at = torrent_task.get("last_timeout_check_at")
if last_checked_at is None:
last_checked_at = torrent_task.get("baseline_at")
try:
last_checked_at = float(last_checked_at)
except (TypeError, ValueError):
last_checked_at = now
interval = max(now - last_checked_at, 0)
queue_grace_seconds = self._bounded_queue_grace_seconds(torrent_task)
if info.queue_waiting:
queue_grace_seconds = min(
queue_grace_seconds + interval,
self._max_queue_grace_seconds(),
)

def updater(data: dict) -> dict:
task = data.get(info.hash, {})
task["queue_grace_seconds"] = queue_grace_seconds
task["last_timeout_check_at"] = now
data[info.hash] = task
return data

self._update("torrents", updater)
return queue_grace_seconds

def _max_queue_grace_seconds(self) -> float:
"""返回单个低进度周期允许抵扣的最大排队时长。"""
return self._timeout_seconds * self._queue_grace_multiplier

def _bounded_queue_grace_seconds(self, torrent_task: dict) -> float:
"""读取已使用排队宽限;持久化异常值按 0 处理并裁剪到当前配置上限。"""
try:
queue_grace_seconds = max(float(torrent_task.get("queue_grace_seconds") or 0), 0)
except (TypeError, ValueError):
queue_grace_seconds = 0
return min(queue_grace_seconds, self._max_queue_grace_seconds())

@staticmethod
def _timeout_elapsed(torrent_task: dict, queue_grace_seconds: float) -> tuple[float, float]:
"""返回自进度基线起的实际时长与扣除有限排队宽限后的有效观察时长。"""
now = time.time()
try:
baseline_at = float(torrent_task.get("baseline_at"))
except (TypeError, ValueError):
baseline_at = now
elapsed = max(now - baseline_at, 0)
return elapsed, max(elapsed - queue_grace_seconds, 0)

def _increment_retry(self, torrent_hash: str, current: int):
def updater(data: dict) -> dict:
Expand Down Expand Up @@ -696,10 +767,17 @@ def get_timeout_reason(self, subscribe_id: int, torrent_task: dict, torrent_info
download_hours = max(time.time() - started_at, 0) / 3600
progress_delta = (torrent_info.progress - torrent_task.get("baseline_progress", 0.0)) * 100
timeout_hours = self._timeout_seconds / 3600
queue_grace_seconds = self._bounded_queue_grace_seconds(torrent_task)
retry_limit = max(int(self._retry_limit or 1), 1)
queue_grace_text = ""
if queue_grace_seconds:
queue_grace_text = (
f"排队宽限 {queue_grace_seconds / 3600:.2f}/"
f"{self._max_queue_grace_seconds() / 3600:g} 小时,"
)
return (
f"订阅种子,下载时长 {download_hours:.2f} 小时,"
f"超时窗口 {timeout_hours:g} 小时内进度增长 {progress_delta:.2f}%,"
f"{queue_grace_text}超时窗口 {timeout_hours:g} 小时内进度增长 {progress_delta:.2f}%,"
f"低于 {self._progress_threshold:g}%"
f"(低进度删除 {timeout_state.get('fail_count', 0)}/{retry_limit} 次)"
)
Expand Down
11 changes: 11 additions & 0 deletions plugins.v2/subscribeassistantenhanced/download/torrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
"forcedUP",
}

DOWNLOAD_QUEUE_STATES = {
"queueddl",
"download pending",
"download_pending",
}


@dataclass
class TorrentInfo:
Expand All @@ -38,6 +44,11 @@ class TorrentInfo:
completed: bool = False
completion_time: float = 0.0

@property
def queue_waiting(self) -> bool:
"""任务是否处于下载器明确排队状态;停滞、暂停和元数据等待不属于排队。"""
return str(self.state or "").strip().lower() in DOWNLOAD_QUEUE_STATES


class TorrentAdapter:
"""种子操作统一接口,内部按下载器类型分发 QB/TR 映射。"""
Expand Down
6 changes: 4 additions & 2 deletions plugins.v2/subscribeassistantenhanced/form/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"skip_deletion": "跳过近期删除资源",
"download_timeout_minutes": "下载超时时间(分钟)",
"download_progress_threshold": "下载超时进度阈值",
"download_queue_grace_multiplier": "下载排队宽限倍数",
"download_retry_limit": "下载连续超时重试次数",
"delete_record_retention_hours": "删除记录保留(小时)",
"delete_exclude_tags": "排除标签",
Expand Down Expand Up @@ -105,6 +106,7 @@
"skip_deletion": "跳过最近删除的种子,避免再次下载",
"download_timeout_minutes": "作为下载进度观察窗口,窗口内进度增长低于阈值时视为超时",
"download_progress_threshold": "超时窗口内下载进度增长低于N%时才删除",
"download_queue_grace_multiplier": "排队状态额外宽限N个超时窗口,0表示不宽限",
"download_retry_limit": "连续低进度超时N次后保留种子并通知",
"delete_record_retention_hours": "定时清理N小时前的删除记录",
"delete_exclude_tags": "需要排除的标签,多个标签用逗号分隔",
Expand Down Expand Up @@ -178,8 +180,8 @@
["download_monitor_enabled", "manual_delete_listen", "tracker_response_listen"],
["auto_search_when_delete", "skip_deletion"],
[("subscription_cleanup_history_type", 4), ("subscription_cleanup_history_scenes", 8)],
["download_timeout_minutes", "download_progress_threshold", "download_retry_limit"],
["delete_record_retention_hours", "delete_exclude_tags"],
["download_timeout_minutes", "download_progress_threshold", "download_queue_grace_multiplier"],
["download_retry_limit", "delete_record_retention_hours", "delete_exclude_tags"],
]),
("订阅待定", [
[("pending_download_enabled", 4), ("pending_enhanced_enabled", 4), ("pending_use_volatility", 4)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const configDefaults = {
"skip_deletion": true,
"download_timeout_minutes": 120,
"download_progress_threshold": 10,
"download_queue_grace_multiplier": 2,
"download_retry_limit": 3,
"delete_exclude_tags": "H&R",
"default_tracker_response": "torrent not registered with this tracker\ntorrent banned",
Expand Down Expand Up @@ -350,6 +351,13 @@ const fields = [
"hint": "超时窗口内下载进度增长低于N%时才删除",
"advanced": true
},
{
"key": "download_queue_grace_multiplier",
"label": "下载排队宽限倍数",
"group": "cleanup",
"kind": "number",
"hint": "排队状态额外宽限N个超时窗口,0表示不宽限"
},
{
"key": "download_retry_limit",
"label": "下载连续超时重试次数",
Expand Down Expand Up @@ -1182,6 +1190,7 @@ const englishFields = {
skip_deletion: ["Skip recently removed releases", "Avoid downloading recently removed torrents again"],
download_timeout_minutes: ["Download timeout (minutes)", "Observation window used to detect downloads with insufficient progress"],
download_progress_threshold: ["Download progress threshold", "Remove only when progress increases by less than N% during the timeout window"],
download_queue_grace_multiplier: ["Download queue grace multiplier", "Allow N additional timeout windows while the downloader explicitly reports the task as queued; 0 disables the grace period"],
download_retry_limit: ["Consecutive timeout limit", "Keep the torrent and notify after N consecutive low-progress timeouts"],
delete_exclude_tags: ["Excluded tags", "Comma-separated tags that must not be processed"],
default_tracker_response: ["Tracker response keywords", "One keyword per line; case-insensitive regular expressions are supported"],
Expand Down Expand Up @@ -1565,6 +1574,7 @@ function numberFieldUnit(key, locale = "zh-CN") {
if (key === "cadence_min_episodes") return units.episode;
if (key === "cadence_multiplier") return units.multiplier;
if (key === "download_progress_threshold") return units.percent;
if (key === "download_queue_grace_multiplier") return units.multiplier;
if (key === "download_retry_limit") return units.count;
if (key === "recognition_guard_cache_maxsize") return units.item;
if (key === "recognition_guard_notify_interval") return units.second;
Expand Down Expand Up @@ -1737,6 +1747,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
keys: [
"download_timeout_minutes",
"download_progress_threshold",
"download_queue_grace_multiplier",
"download_retry_limit",
"delete_exclude_tags",
"delete_record_retention_hours"
Expand Down Expand Up @@ -2702,6 +2713,6 @@ const _export_sfc = (sfc, props) => {
return target;
};

const Config = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-4ce61814"]]);
const Config = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a840fd50"]]);

export { Config as default };
Loading