From bca79bc80950261bf00ce7deb814f92812633eb6 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Mon, 20 Jul 2026 18:22:15 +0800 Subject: [PATCH] fix(subscribeassistantenhanced): bound queued download timeout grace --- .../subscribeassistantenhanced/README.md | 1 + .../subscribeassistantenhanced/__init__.py | 1 + .../download/monitor.py | 88 ++++- .../download/torrent.py | 11 + .../form/__init__.py | 6 +- ...=> __federation_expose_Config-Cp4ctAFt.js} | 13 +- ...> __federation_expose_Config-DByuQ3DF.css} | 336 +++++++++--------- .../{index-B9gixNnF.js => index-BcyFOw8O.js} | 2 +- .../frontend/dist/assets/remoteEntry.js | 4 +- .../frontend/src/components/Config.vue | 1 + .../frontend/src/config/defaults.ts | 3 + .../frontend/src/config/fields.ts | 7 + .../frontend/src/config/i18n.ts | 1 + .../frontend/src/config/presentation.ts | 1 + .../shared/config.py | 5 + .../subscribeassistantenhanced/test_config.py | 6 + .../test_monitor.py | 146 +++++++- .../test_torrent.py | 11 + 18 files changed, 462 insertions(+), 181 deletions(-) rename plugins.v2/subscribeassistantenhanced/frontend/dist/assets/{__federation_expose_Config-BCp-eFlM.js => __federation_expose_Config-Cp4ctAFt.js} (99%) rename plugins.v2/subscribeassistantenhanced/frontend/dist/assets/{__federation_expose_Config-BVb7wRto.css => __federation_expose_Config-DByuQ3DF.css} (65%) rename plugins.v2/subscribeassistantenhanced/frontend/dist/assets/{index-B9gixNnF.js => index-BcyFOw8O.js} (82%) diff --git a/plugins.v2/subscribeassistantenhanced/README.md b/plugins.v2/subscribeassistantenhanced/README.md index bb1c3aa3..7ee1582f 100644 --- a/plugins.v2/subscribeassistantenhanced/README.md +++ b/plugins.v2/subscribeassistantenhanced/README.md @@ -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` | 带有这些标签的种子不自动删除 | 多个标签用逗号分隔 | diff --git a/plugins.v2/subscribeassistantenhanced/__init__.py b/plugins.v2/subscribeassistantenhanced/__init__.py index 6a96ee68..523dbd61 100644 --- a/plugins.v2/subscribeassistantenhanced/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/__init__.py @@ -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, diff --git a/plugins.v2/subscribeassistantenhanced/download/monitor.py b/plugins.v2/subscribeassistantenhanced/download/monitor.py index 67d85501..d7e0d635 100644 --- a/plugins.v2/subscribeassistantenhanced/download/monitor.py +++ b/plugins.v2/subscribeassistantenhanced/download/monitor.py @@ -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, @@ -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 [] @@ -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, @@ -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): @@ -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, } @@ -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: @@ -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} 次)" ) diff --git a/plugins.v2/subscribeassistantenhanced/download/torrent.py b/plugins.v2/subscribeassistantenhanced/download/torrent.py index a7e024de..57ba1b13 100644 --- a/plugins.v2/subscribeassistantenhanced/download/torrent.py +++ b/plugins.v2/subscribeassistantenhanced/download/torrent.py @@ -13,6 +13,12 @@ "forcedUP", } +DOWNLOAD_QUEUE_STATES = { + "queueddl", + "download pending", + "download_pending", +} + @dataclass class TorrentInfo: @@ -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 映射。""" diff --git a/plugins.v2/subscribeassistantenhanced/form/__init__.py b/plugins.v2/subscribeassistantenhanced/form/__init__.py index b9a96a03..f721f0bc 100644 --- a/plugins.v2/subscribeassistantenhanced/form/__init__.py +++ b/plugins.v2/subscribeassistantenhanced/form/__init__.py @@ -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": "排除标签", @@ -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": "需要排除的标签,多个标签用逗号分隔", @@ -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)], diff --git a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BCp-eFlM.js b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-Cp4ctAFt.js similarity index 99% rename from plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BCp-eFlM.js rename to plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-Cp4ctAFt.js index 99038db2..ca940acc 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BCp-eFlM.js +++ b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-Cp4ctAFt.js @@ -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", @@ -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": "下载连续超时重试次数", @@ -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"], @@ -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; @@ -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" @@ -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 }; diff --git a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BVb7wRto.css b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-DByuQ3DF.css similarity index 65% rename from plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BVb7wRto.css rename to plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-DByuQ3DF.css index ec3cb827..cc101127 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-BVb7wRto.css +++ b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/__federation_expose_Config-DByuQ3DF.css @@ -1,15 +1,15 @@ -.sae-config[data-v-4ce61814] { +.sae-config[data-v-a840fd50] { container-type: inline-size; min-inline-size: 0; color: rgb(var(--v-theme-on-surface)); letter-spacing: 0; } -.sae-config[data-v-4ce61814], -.sae-config[data-v-4ce61814] * { +.sae-config[data-v-a840fd50], +.sae-config[data-v-a840fd50] * { box-sizing: border-box; } -.sae-config__form[data-v-4ce61814] { +.sae-config__form[data-v-a840fd50] { min-inline-size: 0; } .sae-config-scroll-root { @@ -25,20 +25,20 @@ .sae-config-scroll-root.sae-config-scroll-root--active::-webkit-scrollbar-thumb { background: rgb(var(--v-theme-perfect-scrollbar-thumb)); } -.sae-config-header-sentinel[data-v-4ce61814] { +.sae-config-header-sentinel[data-v-a840fd50] { block-size: 1px; margin-block-end: -1px; pointer-events: none; } -.sae-field-section[data-v-4ce61814], -.sae-impact-preview[data-v-4ce61814] { +.sae-field-section[data-v-a840fd50], +.sae-impact-preview[data-v-a840fd50] { border: var(--app-surface-border); border-radius: var(--app-surface-radius); backdrop-filter: var(--app-grouped-list-backdrop-filter); background: var(--app-grouped-list-background); box-shadow: var(--app-surface-shadow); } -.sae-config-header[data-v-4ce61814] { +.sae-config-header[data-v-a840fd50] { position: sticky; z-index: 20; inset-block-start: 0; @@ -54,7 +54,7 @@ box-shadow: none; gap: 16px; } -.sae-config-header--scrolled[data-v-4ce61814] { +.sae-config-header--scrolled[data-v-a840fd50] { --sae-header-background: var(--app-grouped-list-background); --sae-header-backdrop-filter: var(--app-grouped-list-backdrop-filter); @@ -70,40 +70,40 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr --sae-header-background: rgba(var(--v-theme-surface), 0.92); --sae-header-backdrop-filter: none; } -.sae-config-header__brand[data-v-4ce61814] { +.sae-config-header__brand[data-v-a840fd50] { display: flex; flex: 1 1 auto; align-items: center; min-inline-size: 0; gap: 10px; } -.sae-config-header__logo[data-v-4ce61814] { +.sae-config-header__logo[data-v-a840fd50] { display: block; flex: 0 0 40px; block-size: 40px; inline-size: 40px; object-fit: contain; } -.sae-config-header__identity[data-v-4ce61814] { +.sae-config-header__identity[data-v-a840fd50] { min-inline-size: 0; } -.sae-config-header__crumbs[data-v-4ce61814], -.sae-config-header__title-row[data-v-4ce61814] { +.sae-config-header__crumbs[data-v-a840fd50], +.sae-config-header__title-row[data-v-a840fd50] { display: flex; align-items: center; min-inline-size: 0; } -.sae-config-header__crumbs[data-v-4ce61814] { +.sae-config-header__crumbs[data-v-a840fd50] { margin-block-end: 3px; color: rgba(var(--v-theme-on-surface), 0.55); font-size: 0.6875rem; line-height: 1rem; gap: 2px; } -.sae-config-header__title-row[data-v-4ce61814] { +.sae-config-header__title-row[data-v-a840fd50] { gap: 8px; } -.sae-config-header__title[data-v-4ce61814] { +.sae-config-header__title[data-v-a840fd50] { margin: 0; overflow-wrap: anywhere; font-size: 1.0625rem; @@ -111,59 +111,59 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr letter-spacing: 0; line-height: 1.4rem; } -.sae-config-header__actions[data-v-4ce61814] { +.sae-config-header__actions[data-v-a840fd50] { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } -.sae-config-header__run[data-v-4ce61814] { +.sae-config-header__run[data-v-a840fd50] { min-inline-size: 112px; font-weight: 600; } -.sae-config-header__save[data-v-4ce61814] { +.sae-config-header__save[data-v-a840fd50] { min-inline-size: 120px; font-weight: 600; } -.sae-config-header__close-action[data-v-4ce61814] { +.sae-config-header__close-action[data-v-a840fd50] { min-inline-size: 96px; color: rgba(var(--v-theme-on-surface), 0.78); font-weight: 600; transition: background-color 180ms ease, color 180ms ease; } @media (hover: hover) and (pointer: fine) { -.sae-config-header__close-action[data-v-4ce61814]:hover { +.sae-config-header__close-action[data-v-a840fd50]:hover { color: rgb(var(--v-theme-on-surface)); } } -.sae-config-header__close-action[data-v-4ce61814]:focus-visible { +.sae-config-header__close-action[data-v-a840fd50]:focus-visible { color: rgb(var(--v-theme-on-surface)); outline: 2px solid rgba(var(--v-theme-primary), 0.58); outline-offset: 2px; } -.sae-config-header__close-action[data-v-4ce61814]:active { +.sae-config-header__close-action[data-v-a840fd50]:active { background-color: rgba(var(--v-theme-on-surface), 0.04); color: rgb(var(--v-theme-on-surface)); } @media (prefers-reduced-motion: reduce) { -.sae-config-header__close-action[data-v-4ce61814], - .sae-config-header__close-action[data-v-4ce61814] .v-btn__overlay { +.sae-config-header__close-action[data-v-a840fd50], + .sae-config-header__close-action[data-v-a840fd50] .v-btn__overlay { transition: none; } -.sae-config-header__close-action[data-v-4ce61814] .v-ripple__container { +.sae-config-header__close-action[data-v-a840fd50] .v-ripple__container { display: none; } } -.sae-config-header__close-icon[data-v-4ce61814] { +.sae-config-header__close-icon[data-v-a840fd50] { flex: 0 0 40px; block-size: 40px; inline-size: 40px; } -.sae-config__body[data-v-4ce61814] { +.sae-config__body[data-v-a840fd50] { min-inline-size: 0; padding: 12px; } -.sae-config-layout[data-v-4ce61814] { +.sae-config-layout[data-v-a840fd50] { display: grid; min-inline-size: 0; margin-block-start: 12px; @@ -173,18 +173,18 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr 'preview'; grid-template-columns: minmax(0, 1fr); } -.sae-group-nav[data-v-4ce61814] { +.sae-group-nav[data-v-a840fd50] { display: none; min-inline-size: 0; grid-area: navigation; } -.sae-group-nav__heading[data-v-4ce61814] { +.sae-group-nav__heading[data-v-a840fd50] { padding: 6px 10px 10px; color: rgba(var(--v-theme-on-surface), 0.54); font-size: 0.75rem; font-weight: 600; } -.sae-group-nav > .sae-group-nav__list.v-list[data-v-4ce61814] { +.sae-group-nav > .sae-group-nav__list.v-list[data-v-a840fd50] { flex: 1 1 auto; min-block-size: 0; overflow-y: auto; @@ -193,27 +193,27 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr background: transparent; background-color: transparent; } -.sae-group-nav__list[data-v-4ce61814] .v-list-item { +.sae-group-nav__list[data-v-a840fd50] .v-list-item { position: relative; min-block-size: 50px; padding-inline: 12px; margin-block: 4px; } -.sae-group-nav__list[data-v-4ce61814] .v-list-item-title { +.sae-group-nav__list[data-v-a840fd50] .v-list-item-title { overflow-wrap: anywhere; font-size: 0.875rem; font-weight: 600; letter-spacing: 0; line-height: 1.2rem; } -.sae-group-nav__list[data-v-4ce61814] .v-list-item__prepend > .v-icon { +.sae-group-nav__list[data-v-a840fd50] .v-list-item__prepend > .v-icon { font-size: 1.25rem; } -.sae-group-nav__list[data-v-4ce61814] .v-list-item--active { +.sae-group-nav__list[data-v-a840fd50] .v-list-item--active { background: rgba(var(--v-theme-primary), 0.09); color: rgb(var(--v-theme-primary)); } -.sae-group-nav__list[data-v-4ce61814] .v-list-item--active::before { +.sae-group-nav__list[data-v-a840fd50] .v-list-item--active::before { position: absolute; inset-block: 8px; inset-inline-start: 0; @@ -222,7 +222,7 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr background: rgb(var(--v-theme-primary)); content: ''; } -.sae-group-nav__help[data-v-4ce61814] { +.sae-group-nav__help[data-v-a840fd50] { flex: 0 0 auto; padding: 12px; margin-block-start: 10px; @@ -230,29 +230,29 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr border-radius: var(--app-control-radius); background: rgba(var(--v-theme-on-surface), 0.025); } -.sae-group-nav__help-title[data-v-4ce61814] { +.sae-group-nav__help-title[data-v-a840fd50] { display: block; font-size: 0.8125rem; line-height: 1.1rem; } -.sae-group-nav__help p[data-v-4ce61814] { +.sae-group-nav__help p[data-v-a840fd50] { margin: 6px 0 0; color: rgba(var(--v-theme-on-surface), 0.56); font-size: 0.6875rem; line-height: 1rem; } -.sae-group-nav__help-link[data-v-4ce61814] { +.sae-group-nav__help-link[data-v-a840fd50] { min-inline-size: 0; min-block-size: 28px; padding-inline: 0; margin-block-start: 7px; font-size: 0.75rem; } -.sae-field-surface[data-v-4ce61814] { +.sae-field-surface[data-v-a840fd50] { min-inline-size: 0; grid-area: content; } -.sae-field-surface__heading[data-v-4ce61814] { +.sae-field-surface__heading[data-v-a840fd50] { display: flex; align-items: flex-start; justify-content: space-between; @@ -260,33 +260,33 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr padding: 2px 2px 12px; gap: 12px; } -.sae-field-surface__heading-copy[data-v-4ce61814] { +.sae-field-surface__heading-copy[data-v-a840fd50] { display: flex; align-items: flex-start; min-inline-size: 0; gap: 9px; } -.sae-field-surface__mobile-actions[data-v-4ce61814] { +.sae-field-surface__mobile-actions[data-v-a840fd50] { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; } -.sae-mobile-group-action[data-v-4ce61814], -.sae-mobile-help[data-v-4ce61814] { +.sae-mobile-group-action[data-v-a840fd50], +.sae-mobile-help[data-v-a840fd50] { block-size: 36px; inline-size: 36px; } -.sae-field-surface h2[data-v-4ce61814], -.sae-impact-preview h2[data-v-4ce61814] { +.sae-field-surface h2[data-v-a840fd50], +.sae-impact-preview h2[data-v-a840fd50] { margin: 0; font-size: 1rem; font-weight: 700; letter-spacing: 0; line-height: 1.25rem; } -.sae-field-surface__heading p[data-v-4ce61814], -.sae-impact-preview p[data-v-4ce61814] { +.sae-field-surface__heading p[data-v-a840fd50], +.sae-impact-preview p[data-v-a840fd50] { margin: 3px 0 0; overflow-wrap: anywhere; color: rgba(var(--v-theme-on-surface), 0.62); @@ -294,14 +294,14 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr letter-spacing: 0; line-height: 1.05rem; } -.sae-field-section[data-v-4ce61814] { +.sae-field-section[data-v-a840fd50] { overflow: hidden; min-inline-size: 0; } -.sae-field-section + .sae-field-section[data-v-4ce61814] { +.sae-field-section + .sae-field-section[data-v-a840fd50] { margin-block-start: 12px; } -.sae-field-section > h3[data-v-4ce61814] { +.sae-field-section > h3[data-v-a840fd50] { padding: 14px 16px 10px; margin: 0; font-size: 0.9375rem; @@ -309,10 +309,10 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr letter-spacing: 0; line-height: 1.25rem; } -.sae-field-section__rows[data-v-4ce61814] { +.sae-field-section__rows[data-v-a840fd50] { padding-inline: 16px; } -.sae-field-row[data-v-4ce61814] { +.sae-field-row[data-v-a840fd50] { display: grid; align-items: start; min-inline-size: 0; @@ -321,13 +321,13 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr gap: 18px; grid-template-columns: minmax(200px, 1.45fr) minmax(180px, 0.75fr); } -.sae-field-row--switch[data-v-4ce61814] { +.sae-field-row--switch[data-v-a840fd50] { align-items: center; } -.sae-field-row__copy[data-v-4ce61814] { +.sae-field-row__copy[data-v-a840fd50] { min-inline-size: 0; } -.sae-field-row__label[data-v-4ce61814] { +.sae-field-row__label[data-v-a840fd50] { display: flex; align-items: center; color: rgb(var(--v-theme-on-surface)); @@ -336,37 +336,37 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr line-height: 1.15rem; gap: 6px; } -.sae-field-row__copy p[data-v-4ce61814] { +.sae-field-row__copy p[data-v-a840fd50] { margin: 4px 0 0; color: rgba(var(--v-theme-on-surface), 0.57); font-size: 0.6875rem; line-height: 1rem; } -.sae-field-control[data-v-4ce61814], -.sae-field-control[data-v-4ce61814] .v-input { +.sae-field-control[data-v-a840fd50], +.sae-field-control[data-v-a840fd50] .v-input { min-inline-size: 0; max-inline-size: 100%; } -.sae-field-control[data-v-4ce61814] .v-select__selection { +.sae-field-control[data-v-a840fd50] .v-select__selection { justify-content: flex-end; margin-inline-start: auto; text-align: end; } -.sae-text-control[data-v-4ce61814] input { +.sae-text-control[data-v-a840fd50] input { text-align: end; } -.sae-select-summary__primary[data-v-4ce61814] { +.sae-select-summary__primary[data-v-a840fd50] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sae-select-summary__count[data-v-4ce61814] { +.sae-select-summary__count[data-v-a840fd50] { flex: 0 0 auto; color: rgba(var(--v-theme-on-surface), 0.58); margin-inline-start: 6px; white-space: nowrap; } -.sae-number-stepper[data-v-4ce61814] { +.sae-number-stepper[data-v-a840fd50] { display: grid; align-items: center; min-block-size: 40px; @@ -376,20 +376,20 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr border-radius: var(--app-control-radius); grid-template-columns: 40px minmax(54px, 1fr) 40px auto; } -.sae-number-stepper[data-v-4ce61814] .v-btn { +.sae-number-stepper[data-v-a840fd50] .v-btn { min-inline-size: 40px; block-size: 40px; border-radius: 0; } -.sae-number-stepper[data-v-4ce61814] .v-field__input { +.sae-number-stepper[data-v-a840fd50] .v-field__input { min-block-size: 40px; padding: 0 6px; text-align: center; } -.sae-number-stepper[data-v-4ce61814] input { +.sae-number-stepper[data-v-a840fd50] input { text-align: center; } -.sae-number-stepper__unit[data-v-4ce61814] { +.sae-number-stepper__unit[data-v-a840fd50] { min-inline-size: 38px; padding-inline: 8px; border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12); @@ -398,11 +398,11 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr text-align: center; white-space: nowrap; } -.sae-field-row--switch .sae-field-control[data-v-4ce61814] { +.sae-field-row--switch .sae-field-control[data-v-a840fd50] { display: flex; justify-content: flex-end; } -.sae-tracker-entry[data-v-4ce61814] { +.sae-tracker-entry[data-v-a840fd50] { display: flex; align-items: center; justify-content: space-between; @@ -410,23 +410,23 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr padding: 16px; gap: 12px; } -.sae-tracker-entry__copy[data-v-4ce61814] { +.sae-tracker-entry__copy[data-v-a840fd50] { display: flex; align-items: flex-start; min-inline-size: 0; gap: 9px; } -.sae-tracker-entry__copy > div[data-v-4ce61814] { +.sae-tracker-entry__copy > div[data-v-a840fd50] { min-inline-size: 0; } -.sae-tracker-entry strong[data-v-4ce61814] { +.sae-tracker-entry strong[data-v-a840fd50] { display: block; overflow-wrap: anywhere; font-size: 0.875rem; letter-spacing: 0; line-height: 1.2rem; } -.sae-tracker-entry p[data-v-4ce61814] { +.sae-tracker-entry p[data-v-a840fd50] { margin: 3px 0 0; overflow-wrap: anywhere; color: rgba(var(--v-theme-on-surface), 0.62); @@ -434,52 +434,52 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr letter-spacing: 0; line-height: 1.05rem; } -.sae-tracker-entry[data-v-4ce61814] .v-btn { +.sae-tracker-entry[data-v-a840fd50] .v-btn { flex: 0 1 auto; min-inline-size: 0; block-size: auto; min-block-size: 36px; padding-block: 7px; } -.sae-tracker-entry[data-v-4ce61814] .v-btn__content { +.sae-tracker-entry[data-v-a840fd50] .v-btn__content { white-space: normal; overflow-wrap: anywhere; } -.sae-impact-preview[data-v-4ce61814] { +.sae-impact-preview[data-v-a840fd50] { min-inline-size: 0; align-self: start; padding: 16px; grid-area: preview; } -.sae-impact-preview__title[data-v-4ce61814] { +.sae-impact-preview__title[data-v-a840fd50] { display: flex; align-items: center; min-inline-size: 0; gap: 8px; } -.sae-impact-preview strong[data-v-4ce61814] { +.sae-impact-preview strong[data-v-a840fd50] { display: block; overflow-wrap: anywhere; font-size: 0.875rem; letter-spacing: 0; line-height: 1.25rem; } -.sae-impact-preview__list[data-v-4ce61814] { +.sae-impact-preview__list[data-v-a840fd50] { padding: 0; margin: 10px 0 0; list-style: none; } -.sae-impact-preview__item[data-v-4ce61814], -.sae-runtime-summary__row[data-v-4ce61814], -.sae-runtime-summary__state[data-v-4ce61814], -.sae-runtime-summary__title[data-v-4ce61814] { +.sae-impact-preview__item[data-v-a840fd50], +.sae-runtime-summary__row[data-v-a840fd50], +.sae-runtime-summary__state[data-v-a840fd50], +.sae-runtime-summary__title[data-v-a840fd50] { display: grid; align-items: start; min-inline-size: 0; gap: 10px; grid-template-columns: 28px minmax(0, 1fr); } -.sae-impact-preview__item[data-v-4ce61814] { +.sae-impact-preview__item[data-v-a840fd50] { align-items: center; padding-block: 10px; color: rgba(var(--v-theme-on-surface), 0.72); @@ -487,49 +487,49 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr line-height: 1.25rem; grid-template-columns: 28px minmax(0, 1fr) minmax(0, auto); } -.sae-impact-preview__item > .v-icon[data-v-4ce61814], -.sae-runtime-summary__row > .v-icon[data-v-4ce61814] { +.sae-impact-preview__item > .v-icon[data-v-a840fd50], +.sae-runtime-summary__row > .v-icon[data-v-a840fd50] { justify-self: center; color: rgba(var(--v-theme-on-surface), 0.54); } -.sae-impact-preview__item > span[data-v-4ce61814], -.sae-impact-preview__item > strong[data-v-4ce61814] { +.sae-impact-preview__item > span[data-v-a840fd50], +.sae-impact-preview__item > strong[data-v-a840fd50] { min-inline-size: 0; overflow-wrap: anywhere; } -.sae-impact-preview__item > strong[data-v-4ce61814] { +.sae-impact-preview__item > strong[data-v-a840fd50] { text-align: end; } -.sae-runtime-summary[data-v-4ce61814], -.sae-change-summary[data-v-4ce61814] { +.sae-runtime-summary[data-v-a840fd50], +.sae-change-summary[data-v-a840fd50] { padding-block-start: 16px; margin-block-start: 16px; border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.1); } -.sae-summary-section__title[data-v-4ce61814] { +.sae-summary-section__title[data-v-a840fd50] { display: grid; align-items: center; gap: 10px; grid-template-columns: 28px minmax(0, 1fr); } -.sae-summary-section__title > .v-icon[data-v-4ce61814] { +.sae-summary-section__title > .v-icon[data-v-a840fd50] { block-size: 28px; inline-size: 28px; border-radius: var(--app-control-radius); background: rgba(var(--v-theme-primary), 0.1); } -.sae-change-summary .sae-summary-section__title > .v-icon[data-v-4ce61814] { +.sae-change-summary .sae-summary-section__title > .v-icon[data-v-a840fd50] { background: rgba(var(--v-theme-warning), 0.12); } -.sae-summary-section__title h3[data-v-4ce61814] { +.sae-summary-section__title h3[data-v-a840fd50] { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: 0; line-height: 1.25rem; } -.sae-runtime-summary__state[data-v-4ce61814], -.sae-runtime-summary__row[data-v-4ce61814] { +.sae-runtime-summary__state[data-v-a840fd50], +.sae-runtime-summary__row[data-v-a840fd50] { align-items: center; padding-block: 7px; color: rgba(var(--v-theme-on-surface), 0.7); @@ -537,34 +537,34 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr letter-spacing: 0; line-height: 1.25rem; } -.sae-runtime-summary__state[data-v-4ce61814] { +.sae-runtime-summary__state[data-v-a840fd50] { margin-block-start: 6px; } -.sae-runtime-summary__metrics[data-v-4ce61814] { +.sae-runtime-summary__metrics[data-v-a840fd50] { margin-block-start: 10px; } -.sae-runtime-summary__row[data-v-4ce61814] { +.sae-runtime-summary__row[data-v-a840fd50] { grid-template-columns: 28px minmax(0, 1fr) minmax(0, auto); } -.sae-runtime-summary__row span[data-v-4ce61814], -.sae-runtime-summary__row strong[data-v-4ce61814] { +.sae-runtime-summary__row span[data-v-a840fd50], +.sae-runtime-summary__row strong[data-v-a840fd50] { min-inline-size: 0; overflow-wrap: anywhere; } -.sae-runtime-summary__row strong[data-v-4ce61814] { +.sae-runtime-summary__row strong[data-v-a840fd50] { color: rgb(var(--v-theme-on-surface)); font-weight: 600; text-align: end; } -.sae-runtime-summary__unavailable[data-v-4ce61814] { +.sae-runtime-summary__unavailable[data-v-a840fd50] { margin-block-start: 9px; } -.sae-change-summary ul[data-v-4ce61814] { +.sae-change-summary ul[data-v-a840fd50] { padding: 0; margin: 8px 0 0; list-style: none; } -.sae-change-summary li[data-v-4ce61814] { +.sae-change-summary li[data-v-a840fd50] { display: grid; align-items: center; min-inline-size: 0; @@ -575,31 +575,31 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr gap: 8px; grid-template-columns: 12px minmax(0, 1fr); } -.sae-change-summary li span[data-v-4ce61814] { +.sae-change-summary li span[data-v-a840fd50] { min-inline-size: 0; overflow-wrap: anywhere; } -.sae-change-summary > p[data-v-4ce61814] { +.sae-change-summary > p[data-v-a840fd50] { margin: 4px 0 0 20px; color: rgb(var(--v-theme-warning)); font-size: 0.75rem; } -.sae-tracker-dialog__title[data-v-4ce61814] { +.sae-tracker-dialog__title[data-v-a840fd50] { display: flex; align-items: center; justify-content: space-between; min-inline-size: 0; gap: 12px; } -.sae-tracker-dialog__title > span[data-v-4ce61814] { +.sae-tracker-dialog__title > span[data-v-a840fd50] { min-inline-size: 0; overflow-wrap: anywhere; white-space: normal; } -.sae-tracker-dialog__actions[data-v-4ce61814] { +.sae-tracker-dialog__actions[data-v-a840fd50] { flex-wrap: wrap; } -.sae-mobile-save-dock[data-v-4ce61814] { +.sae-mobile-save-dock[data-v-a840fd50] { position: sticky; z-index: 20; inset-block-end: calc(12px + env(safe-area-inset-bottom)); @@ -617,7 +617,7 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-config-header--scr box-shadow: 0 -8px 24px rgba(var(--v-theme-on-surface), 0.06); gap: 12px; } -.sae-mobile-save-dock__save[data-v-4ce61814] { +.sae-mobile-save-dock__save[data-v-a840fd50] { min-inline-size: 128px; font-weight: 600; } @@ -629,142 +629,142 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-mobile-save-dock { background: rgba(var(--v-theme-surface), 0.98); backdrop-filter: none; } -.sae-yaml-dialog__content[data-v-4ce61814] { +.sae-yaml-dialog__content[data-v-a840fd50] { min-block-size: min(60dvh, 560px); padding: 0 !important; } -.sae-yaml-editor[data-v-4ce61814] { +.sae-yaml-editor[data-v-a840fd50] { block-size: min(60dvh, 560px); inline-size: 100%; } -.sae-mobile-group-sheet[data-v-4ce61814] .v-bottom-sheet__content { +.sae-mobile-group-sheet[data-v-a840fd50] .v-bottom-sheet__content { max-block-size: min(82dvh, 680px); } @container (width <= 480px) { -.sae-config-header[data-v-4ce61814] { +.sae-config-header[data-v-a840fd50] { min-block-size: 64px; padding-inline: 10px; gap: 8px; } -.sae-config-header__logo[data-v-4ce61814] { +.sae-config-header__logo[data-v-a840fd50] { flex-basis: 34px; block-size: 34px; inline-size: 34px; } -.sae-config-header__crumbs[data-v-4ce61814] { +.sae-config-header__crumbs[data-v-a840fd50] { display: none; } -.sae-config-header__title[data-v-4ce61814] { +.sae-config-header__title[data-v-a840fd50] { font-size: 0.875rem; line-height: 1.1rem; } -.sae-config-header__title-row[data-v-4ce61814] { +.sae-config-header__title-row[data-v-a840fd50] { gap: 5px; } -.sae-tracker-entry[data-v-4ce61814] { +.sae-tracker-entry[data-v-a840fd50] { align-items: stretch; flex-direction: column; } -.sae-tracker-entry[data-v-4ce61814] .v-btn { +.sae-tracker-entry[data-v-a840fd50] .v-btn { inline-size: 100%; } } @container (width < 720px) { -.sae-config-header__run[data-v-4ce61814], - .sae-config-header__save[data-v-4ce61814], - .sae-config-header__close-action[data-v-4ce61814], - .sae-change-summary[data-v-4ce61814] { +.sae-config-header__run[data-v-a840fd50], + .sae-config-header__save[data-v-a840fd50], + .sae-config-header__close-action[data-v-a840fd50], + .sae-change-summary[data-v-a840fd50] { display: none; } -.sae-field-row[data-v-4ce61814] { +.sae-field-row[data-v-a840fd50] { align-items: center; gap: 12px; grid-template-columns: minmax(0, 1fr) minmax(180px, 0.9fr); } -.sae-field-row--switch[data-v-4ce61814] { +.sae-field-row--switch[data-v-a840fd50] { grid-template-columns: minmax(0, 1fr) auto; } -.sae-field-row--switch .sae-field-control[data-v-4ce61814] { +.sae-field-row--switch .sae-field-control[data-v-a840fd50] { align-self: center; } } @container (width <= 30rem) { -.sae-field-row[data-v-4ce61814] { +.sae-field-row[data-v-a840fd50] { align-items: center; padding-block: 0.625rem; column-gap: 0.75rem; row-gap: 0; grid-template-columns: minmax(0, 1fr) minmax(8.5rem, 10rem); } -.sae-field-row__copy[data-v-4ce61814] { +.sae-field-row__copy[data-v-a840fd50] { display: contents; } -.sae-field-row__label[data-v-4ce61814] { +.sae-field-row__label[data-v-a840fd50] { grid-column: 1; grid-row: 1; } -.sae-field-row__copy p[data-v-4ce61814] { +.sae-field-row__copy p[data-v-a840fd50] { grid-column: 1 / -1; grid-row: 2; margin-block-start: 1px; line-height: 0.9375rem; } -.sae-field-control[data-v-4ce61814] { +.sae-field-control[data-v-a840fd50] { grid-column: 2; grid-row: 1; justify-self: end; inline-size: 100%; } -.sae-field-row--switch .sae-field-control[data-v-4ce61814] { +.sae-field-row--switch .sae-field-control[data-v-a840fd50] { justify-self: end; inline-size: auto; } -.sae-field-control[data-v-4ce61814] .v-field { +.sae-field-control[data-v-a840fd50] .v-field { min-block-size: 1.75rem; border-radius: 0; background: transparent; box-shadow: none; } -.sae-field-control[data-v-4ce61814] .v-field__outline { +.sae-field-control[data-v-a840fd50] .v-field__outline { display: none; } -.sae-field-control[data-v-4ce61814] .v-field__input { +.sae-field-control[data-v-a840fd50] .v-field__input { min-block-size: 1.75rem; padding-block: 0; padding-inline: 0; } -.sae-field-control[data-v-4ce61814] .v-field__append-inner { +.sae-field-control[data-v-a840fd50] .v-field__append-inner { padding-block-start: 0.25rem; padding-inline-start: 0.25rem; } -.sae-field-control[data-v-4ce61814] .v-select__selection { +.sae-field-control[data-v-a840fd50] .v-select__selection { justify-content: flex-end; } -.sae-number-stepper[data-v-4ce61814] { +.sae-number-stepper[data-v-a840fd50] { min-block-size: 1.75rem; border: 0; border-radius: 0; grid-template-columns: minmax(2.625rem, 1fr) auto; } -.sae-number-stepper[data-v-4ce61814] .v-btn { +.sae-number-stepper[data-v-a840fd50] .v-btn { display: none; } -.sae-number-stepper[data-v-4ce61814] .v-field__input, - .sae-number-stepper[data-v-4ce61814] input { +.sae-number-stepper[data-v-a840fd50] .v-field__input, + .sae-number-stepper[data-v-a840fd50] input { min-block-size: 1.75rem; text-align: end; } -.sae-number-stepper__unit[data-v-4ce61814] { +.sae-number-stepper__unit[data-v-a840fd50] { min-inline-size: auto; padding-inline-end: 0; border-inline-start: 0; } } @container (width >= 720px) { -.sae-config__body[data-v-4ce61814] { +.sae-config__body[data-v-a840fd50] { padding: 14px; } -.sae-config-layout[data-v-4ce61814] { +.sae-config-layout[data-v-a840fd50] { padding-block-end: 14px; gap: 14px; grid-template-areas: @@ -772,13 +772,13 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-mobile-save-dock { 'navigation preview'; grid-template-columns: 168px minmax(0, 1fr); } -.sae-config-header__close-icon[data-v-4ce61814], - .sae-field-row--mobile-only[data-v-4ce61814], - .sae-field-surface__mobile-actions[data-v-4ce61814], - .sae-mobile-save-dock[data-v-4ce61814] { +.sae-config-header__close-icon[data-v-a840fd50], + .sae-field-row--mobile-only[data-v-a840fd50], + .sae-field-surface__mobile-actions[data-v-a840fd50], + .sae-mobile-save-dock[data-v-a840fd50] { display: none; } -.sae-group-nav[data-v-4ce61814] { +.sae-group-nav[data-v-a840fd50] { position: sticky; inset-block-start: 86px; display: flex; @@ -789,47 +789,47 @@ html[data-theme='transparent'].transparent-blur-disabled .sae-mobile-save-dock { border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.1); padding-inline-end: 10px; } -.sae-impact-preview[data-v-4ce61814] { +.sae-impact-preview[data-v-a840fd50] { align-self: start; } } @container (width >= 880px) { -.sae-config__form[data-v-4ce61814] { +.sae-config__form[data-v-a840fd50] { display: grid; overflow: hidden; block-size: min(90dvh, 820px); grid-template-rows: 1px auto minmax(0, 1fr); } -.sae-config__body[data-v-4ce61814], - .sae-config-layout[data-v-4ce61814] { +.sae-config__body[data-v-a840fd50], + .sae-config-layout[data-v-a840fd50] { min-block-size: 0; block-size: 100%; } -.sae-config__body[data-v-4ce61814] { +.sae-config__body[data-v-a840fd50] { overflow: hidden; } -.sae-config-layout[data-v-4ce61814] { +.sae-config-layout[data-v-a840fd50] { align-items: stretch; grid-template-areas: 'navigation content preview'; grid-template-columns: 168px minmax(0, 1fr) 232px; } -.sae-group-nav[data-v-4ce61814] { +.sae-group-nav[data-v-a840fd50] { position: static; overflow: hidden; block-size: 100%; } -.sae-impact-preview[data-v-4ce61814] { +.sae-impact-preview[data-v-a840fd50] { position: static; overflow: hidden; block-size: 100%; } -.sae-field-surface[data-v-4ce61814] { +.sae-field-surface[data-v-a840fd50] { overflow-x: hidden; overflow-y: auto; min-block-size: 0; padding-inline-end: 4px; } -.sae-impact-preview[data-v-4ce61814] { +.sae-impact-preview[data-v-a840fd50] { align-self: stretch; } } diff --git a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-B9gixNnF.js b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-BcyFOw8O.js similarity index 82% rename from plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-B9gixNnF.js rename to plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-BcyFOw8O.js index 381fed19..b9eb0608 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-B9gixNnF.js +++ b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/index-BcyFOw8O.js @@ -1 +1 @@ -export { default as Config } from './__federation_expose_Config-BCp-eFlM.js'; +export { default as Config } from './__federation_expose_Config-Cp4ctAFt.js'; diff --git a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/remoteEntry.js b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/remoteEntry.js index 3798b48d..bea8249a 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/remoteEntry.js +++ b/plugins.v2/subscribeassistantenhanced/frontend/dist/assets/remoteEntry.js @@ -2,8 +2,8 @@ const currentImports = {}; const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']); let moduleMap = { "./Config":()=>{ - dynamicLoadingCss(["__federation_expose_Config-BVb7wRto.css"], false, './Config'); - return __federation_import('./__federation_expose_Config-BCp-eFlM.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},}; + dynamicLoadingCss(["__federation_expose_Config-DByuQ3DF.css"], false, './Config'); + return __federation_import('./__federation_expose_Config-Cp4ctAFt.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},}; const seen = {}; const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => { const metaUrl = import.meta.url; diff --git a/plugins.v2/subscribeassistantenhanced/frontend/src/components/Config.vue b/plugins.v2/subscribeassistantenhanced/frontend/src/components/Config.vue index 87bafaf8..4bfc4415 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/src/components/Config.vue +++ b/plugins.v2/subscribeassistantenhanced/frontend/src/components/Config.vue @@ -106,6 +106,7 @@ const sectionDefinitions: Record = { keys: [ 'download_timeout_minutes', 'download_progress_threshold', + 'download_queue_grace_multiplier', 'download_retry_limit', 'delete_exclude_tags', 'delete_record_retention_hours', diff --git a/plugins.v2/subscribeassistantenhanced/frontend/src/config/defaults.ts b/plugins.v2/subscribeassistantenhanced/frontend/src/config/defaults.ts index dc4ac1c1..41490f7d 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/src/config/defaults.ts +++ b/plugins.v2/subscribeassistantenhanced/frontend/src/config/defaults.ts @@ -30,6 +30,8 @@ export interface SaeConfig { download_timeout_minutes: number /** 下载超时进度阈值 */ download_progress_threshold: number + /** 下载排队宽限倍数 */ + download_queue_grace_multiplier: number /** 下载连续超时重试次数 */ download_retry_limit: number /** 排除标签 */ @@ -159,6 +161,7 @@ export const configDefaults: SaeConfig = { "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", diff --git a/plugins.v2/subscribeassistantenhanced/frontend/src/config/fields.ts b/plugins.v2/subscribeassistantenhanced/frontend/src/config/fields.ts index d05dfe1a..adcaf551 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/src/config/fields.ts +++ b/plugins.v2/subscribeassistantenhanced/frontend/src/config/fields.ts @@ -262,6 +262,13 @@ export const fields: FieldMeta[] = [ "hint": "超时窗口内下载进度增长低于N%时才删除", "advanced": true }, + { + "key": "download_queue_grace_multiplier", + "label": "下载排队宽限倍数", + "group": "cleanup", + "kind": "number", + "hint": "排队状态额外宽限N个超时窗口,0表示不宽限" + }, { "key": "download_retry_limit", "label": "下载连续超时重试次数", diff --git a/plugins.v2/subscribeassistantenhanced/frontend/src/config/i18n.ts b/plugins.v2/subscribeassistantenhanced/frontend/src/config/i18n.ts index 81defb67..c46e499c 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/src/config/i18n.ts +++ b/plugins.v2/subscribeassistantenhanced/frontend/src/config/i18n.ts @@ -210,6 +210,7 @@ const englishFields: Record = { 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'], diff --git a/plugins.v2/subscribeassistantenhanced/frontend/src/config/presentation.ts b/plugins.v2/subscribeassistantenhanced/frontend/src/config/presentation.ts index 51f9f56a..7c34674a 100644 --- a/plugins.v2/subscribeassistantenhanced/frontend/src/config/presentation.ts +++ b/plugins.v2/subscribeassistantenhanced/frontend/src/config/presentation.ts @@ -23,6 +23,7 @@ export function numberFieldUnit(key: ConfigKey, locale: SupportedLocale = '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 diff --git a/plugins.v2/subscribeassistantenhanced/shared/config.py b/plugins.v2/subscribeassistantenhanced/shared/config.py index 925eabb5..a2a9f93b 100644 --- a/plugins.v2/subscribeassistantenhanced/shared/config.py +++ b/plugins.v2/subscribeassistantenhanced/shared/config.py @@ -251,6 +251,11 @@ def download_progress_threshold(self) -> int: """下载进度阈值:超时窗口内进度增长低于该百分比才视为停滞。""" return self.get_int("download_progress_threshold", 10) + @property + def download_queue_grace_multiplier(self) -> int: + """下载排队宽限倍数:明确排队状态可额外抵扣的超时窗口倍数。""" + return max(self.get_int("download_queue_grace_multiplier", 2), 0) + @property def download_retry_limit(self) -> int: """下载连续超时重试次数:达到上限后保留任务并停止自动删种重试。""" diff --git a/tests/v2/subscribeassistantenhanced/test_config.py b/tests/v2/subscribeassistantenhanced/test_config.py index e24eb397..5d852f25 100644 --- a/tests/v2/subscribeassistantenhanced/test_config.py +++ b/tests/v2/subscribeassistantenhanced/test_config.py @@ -86,6 +86,12 @@ def test_best_version_cron_default(self): def test_download_timeout_minutes_default(self): assert self.cfg.download_timeout_minutes == 120 + def test_download_queue_grace_multiplier_default(self): + assert self.cfg.download_queue_grace_multiplier == 2 + + def test_download_queue_grace_multiplier_negative_disables_grace(self): + assert PluginConfig({"download_queue_grace_multiplier": -1}).download_queue_grace_multiplier == 0 + def test_download_progress_threshold_default(self): assert self.cfg.download_progress_threshold == 10 diff --git a/tests/v2/subscribeassistantenhanced/test_monitor.py b/tests/v2/subscribeassistantenhanced/test_monitor.py index 9424549f..c7b6475b 100644 --- a/tests/v2/subscribeassistantenhanced/test_monitor.py +++ b/tests/v2/subscribeassistantenhanced/test_monitor.py @@ -23,10 +23,10 @@ def _store_mgr(store=None): ) -def _info(hash="h1", progress=0.5, completed=False, tags=None, +def _info(hash="h1", progress=0.5, completed=False, state="downloading", tags=None, tracker_responses=None): return TorrentInfo( - hash=hash, progress=progress, completed=completed, + hash=hash, progress=progress, completed=completed, state=state, tags=tags or [], tracker_responses=tracker_responses or [], ) @@ -276,6 +276,148 @@ def test_no_progress_within_timeout_ok(self): result = mon.check_torrent(_info(progress=0.5), subscribe_id=1) assert result == "ok" + def test_qb_queue_wait_uses_bounded_grace_then_times_out(self, monkeypatch): + """qB 排队只延长当前低进度周期,达到三倍总时长后仍进入删种。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=2, + retry_limit=3, subscribe_oper=oper, + ) + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 7200.0) + assert mon.check_torrent(_info(progress=0.0, state="queuedDL"), 1) == "ok" + assert store["torrents"]["h1"]["queue_grace_seconds"] == 7200 + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 10801.0) + assert mon.check_torrent(_info(progress=0.0, state="queuedDL"), 1) == "timeout" + reason = mon.get_timeout_reason(1, store["torrents"]["h1"], _info(progress=0.0, state="queuedDL")) + assert "排队宽限 2.00/2 小时,超时窗口 1 小时内进度增长 0.00%" in reason + + def test_tr_download_pending_uses_same_bounded_grace(self, monkeypatch): + """TR download pending 与 qB queuedDL 使用同一有限排队宽限。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=2, + retry_limit=3, subscribe_oper=oper, + ) + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 7200.0) + assert mon.check_torrent(_info(progress=0.0, state="download pending"), 1) == "ok" + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 10801.0) + assert mon.check_torrent(_info(progress=0.0, state="download pending"), 1) == "timeout" + + def test_repeated_queue_transitions_do_not_reset_grace(self, monkeypatch): + """排队状态反复进入只累计有限额度,不重新获得完整宽限。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=2, + retry_limit=3, subscribe_oper=oper, + ) + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 3600.0) + assert mon.check_torrent(_info(progress=0.0, state="queuedDL"), 1) == "ok" + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 5400.0) + assert mon.check_torrent(_info(progress=0.0, state="downloading"), 1) == "ok" + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 9000.0) + assert mon.check_torrent(_info(progress=0.0, state="queuedDL"), 1) == "ok" + assert store["torrents"]["h1"]["queue_grace_seconds"] == 7200 + + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 10801.0) + assert mon.check_torrent(_info(progress=0.0, state="downloading"), 1) == "timeout" + + def test_non_queue_states_do_not_receive_grace(self, monkeypatch): + """无做种、元数据等待和用户暂停仍按普通低进度窗口删除。""" + for state in ("stalledDL", "metaDL", "pausedDL", "checkingDL"): + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=2, + retry_limit=3, subscribe_oper=oper, + ) + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 3601.0) + + assert mon.check_torrent(_info(progress=0.0, state=state), 1) == "timeout" + assert store["torrents"]["h1"].get("queue_grace_seconds", 0) == 0 + + def test_zero_queue_grace_keeps_original_timeout(self, monkeypatch): + """排队宽限设为 0 时,明确排队状态仍按原超时窗口删除。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=0, + retry_limit=3, subscribe_oper=oper, + ) + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 3601.0) + + assert mon.check_torrent(_info(progress=0.0, state="queuedDL"), 1) == "timeout" + assert store["torrents"]["h1"]["queue_grace_seconds"] == 0 + + def test_invalid_persisted_queue_grace_falls_back_to_zero(self, monkeypatch): + """旧任务中的异常宽限值不应打断巡检或绕过普通超时。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "queue_grace_seconds": "invalid", + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + oper = MagicMock() + oper.get.return_value = SimpleNamespace(id=1, type="电视剧", season=None) + mon = DownloadMonitor( + read, update, timeout_minutes=60, queue_grace_multiplier=2, + retry_limit=3, subscribe_oper=oper, + ) + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 3601.0) + + assert mon.check_torrent(_info(progress=0.0, state="downloading"), 1) == "timeout" + assert store["torrents"]["h1"]["queue_grace_seconds"] == 0 + + def test_progress_resets_used_queue_grace(self, monkeypatch): + """达到进度阈值后开始新的观察周期,不继承旧排队宽限。""" + store = {"torrents": {"h1": { + "baseline_progress": 0.0, "baseline_at": 0.0, + "queue_grace_seconds": 3600.0, + "last_timeout_check_at": 3600.0, + "subscribe_id": 1, "episodes": [1], + }}} + read, update, _ = _store_mgr(store) + mon = DownloadMonitor(read, update, timeout_minutes=60, progress_threshold=10) + monkeypatch.setattr("subscribeassistantenhanced.download.monitor.time.time", lambda: 5400.0) + + assert mon.check_torrent(_info(progress=0.2, state="queuedDL"), 1) == "ok" + task = store["torrents"]["h1"] + assert task["baseline_at"] == 5400.0 + assert task["queue_grace_seconds"] == 0 + assert task["last_timeout_check_at"] == 5400.0 + def test_timeout_after_retries_exhausted(self): """低进度超时未达保护上限时直接删种。""" store = {"torrents": {"h1": { diff --git a/tests/v2/subscribeassistantenhanced/test_torrent.py b/tests/v2/subscribeassistantenhanced/test_torrent.py index 9ae6e42c..1077d8bd 100644 --- a/tests/v2/subscribeassistantenhanced/test_torrent.py +++ b/tests/v2/subscribeassistantenhanced/test_torrent.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from qbittorrentapi.torrents import TorrentInfoList +from transmission_rpc.torrent import Status from subscribeassistantenhanced.download.torrent import TorrentAdapter, TorrentInfo from ..torrent_sdk_fixtures import make_tr_v7_torrent @@ -25,6 +26,16 @@ def test_tag_completion_and_progress_helpers(self): assert TorrentAdapter.is_completed(info) == (False, 0.0) assert TorrentAdapter.progress_percent(info) == 25.0 + def test_queue_waiting_states_are_provider_specific(self): + """只识别下载器明确排队状态,停滞、暂停和元数据等待仍参与超时。""" + assert TorrentInfo(state="queuedDL").queue_waiting is True + assert TorrentInfo(state="download pending").queue_waiting is True + assert TorrentInfo(state="download_pending").queue_waiting is True + assert TorrentInfo(state=Status.DOWNLOAD_PENDING).queue_waiting is True + assert TorrentInfo(state="stalledDL").queue_waiting is False + assert TorrentInfo(state="pausedDL").queue_waiting is False + assert TorrentInfo(state="metaDL").queue_waiting is False + class TestFromQB: