diff --git a/.githooks/pre-push b/.githooks/pre-push index 6edcbe65..dd946eb1 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -5,4 +5,4 @@ set -eu repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" -python3 .github/scripts/check_plugin_versions.py package.json package.v2.json +python3 .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json diff --git a/.github/scripts/check_plugin_versions.py b/.github/scripts/check_plugin_versions.py index 3b90d6d9..07ccd988 100644 --- a/.github/scripts/check_plugin_versions.py +++ b/.github/scripts/check_plugin_versions.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 -"""校验插件市场版本与插件源码版本一致。 +"""校验可发布到 MoviePilot V3 的插件市场版本与源码版本一致。 -Release workflow 依赖 package.json/package.v2.json 生成 tag 和资产名; -若插件目录内的 plugin_version 不同步,运行时会继续展示旧版本。这里在打包前失败退出, -避免发布资产与插件自报版本不一致。 +Release workflow 同时处理 V1、V2 兼容实现和 V3 专用实现。旧索引中显式声明 +``v3: false`` 的实现不会再发布;V3 专用实现还必须满足迁移版本和元数据合同。 """ from __future__ import annotations import ast import json +import re import sys import warnings from pathlib import Path @@ -18,7 +18,7 @@ def _load_package(path: Path) -> dict: - """读取 package 文件;文件不存在时返回空字典,便于同一脚本兼容 v1/v2。""" + """读取 package 文件;文件不存在时返回空字典。""" if not path.exists(): return {} with path.open("r", encoding="utf-8") as file_obj: @@ -26,9 +26,16 @@ def _load_package(path: Path) -> dict: def _plugin_dir(package_file: Path, plugin_id: str) -> Path | None: - """按 package 文件定位对应插件目录,避免 v1/v2 同名插件互相串线。""" + """按 package 文件定位对应插件目录,避免不同代际同名插件互相串线。""" plugin_id_lc = plugin_id.lower() - base_dir = Path("plugins.v2") if package_file.name == "package.v2.json" else Path("plugins") + base_dirs = { + "package.json": Path("plugins"), + "package.v2.json": Path("plugins.v2"), + "package.v3.json": Path("plugins.v3"), + } + base_dir = base_dirs.get(package_file.name) + if base_dir is None: + return None candidate = package_file.parent / base_dir / plugin_id_lc return candidate if candidate.is_dir() else None @@ -36,10 +43,75 @@ def _plugin_dir(package_file: Path, plugin_id: str) -> Path | None: def _expected_plugin_dir(package_file: Path, plugin_id: str) -> Path: """返回 package 条目对应的插件目录,用于缺失目录时输出可定位错误。""" plugin_id_lc = plugin_id.lower() - base_dir = Path("plugins.v2") if package_file.name == "package.v2.json" else Path("plugins") + base_dirs = { + "package.json": Path("plugins"), + "package.v2.json": Path("plugins.v2"), + "package.v3.json": Path("plugins.v3"), + } + base_dir = base_dirs.get(package_file.name, Path("plugins.v3")) return package_file.parent / base_dir / plugin_id_lc +def _version_parts(value: object) -> tuple[int, ...] | None: + """解析只含数字段的插件版本。""" + match = re.fullmatch(r"\d+(?:\.\d+)*", str(value or "").strip()) + if not match: + return None + return tuple(int(part) for part in match.group().split(".")) + + +def _normalized_version(value: object, width: int = 4) -> tuple[int, ...] | None: + """补齐版本段用于大小比较。""" + parts = _version_parts(value) + if parts is None: + return None + return parts + (0,) * max(0, width - len(parts)) + + +def _check_v3_metadata(path: Path, plugin_id: str, metadata: dict) -> list[str]: + """校验 V3 专用实现的系统版本、history 与旧版本迁移规则。""" + errors: list[str] = [] + version = str(metadata.get("version") or "").strip() + version_parts = _version_parts(version) + if version_parts is None or len(version_parts) < 2: + errors.append(f"{path}: {plugin_id} V3 版本必须至少包含主版本和小版本:{version}") + + if metadata.get("system_version") != ">=3.0.0": + errors.append(f'{path}: {plugin_id} system_version 必须为 ">=3.0.0"') + + history = metadata.get("history") + expected_history_key = f"v{version}" + if not isinstance(history, dict) or list(history) != [expected_history_key]: + errors.append(f"{path}: {plugin_id} history 必须只保留当前版本 {expected_history_key}") + else: + expected_changelog = f'MoviePilot V3 版本{metadata.get("name", "")}插件' + if history[expected_history_key] != expected_changelog: + errors.append(f"{path}: {plugin_id} history 文案必须为 {expected_changelog}") + + legacy_path = path.with_name("package.v2.json") + legacy_metadata = _load_package(legacy_path).get(plugin_id) + if not isinstance(legacy_metadata, dict): + errors.append(f"{path}: {plugin_id} 在 {legacy_path.name} 中没有对应旧版本条目") + return errors + if legacy_metadata.get("v3") is not False: + errors.append(f"{legacy_path}: {plugin_id} 必须声明 v3=false") + + old_version = str(legacy_metadata.get("version") or "").strip() + old_parts = _version_parts(old_version) + normalized_old = _normalized_version(old_version) + normalized_new = _normalized_version(version) + if old_parts is None or normalized_old is None or normalized_new is None or version_parts is None: + errors.append(f"{path}: {plugin_id} 无法比较版本 {old_version} -> {version}") + return errors + if version_parts[0] != old_parts[0]: + errors.append(f"{path}: {plugin_id} V3 不得提升主版本:{old_version} -> {version}") + if version_parts[1] <= (old_parts[1] if len(old_parts) > 1 else 0): + errors.append(f"{path}: {plugin_id} V3 必须提升小版本:{old_version} -> {version}") + if normalized_new <= normalized_old: + errors.append(f"{path}: {plugin_id} V3 版本必须高于旧版本:{old_version} -> {version}") + return errors + + def _plugin_version(init_file: Path) -> str | None: """从 __init__.py 类级属性中提取 plugin_version 字面量。""" tree = ast.parse(init_file.read_text(encoding="utf-8"), filename=str(init_file)) @@ -60,12 +132,26 @@ def _plugin_version(init_file: Path) -> str | None: return None +def _is_v3_release_entry(path: Path, metadata: dict) -> bool: + """按主程序索引回退规则判断旧代条目是否仍面向 V3 发布。""" + if metadata.get("release") is not True or metadata.get("v3") is False: + return False + if path.name == "package.v2.json": + return True + if path.name == "package.json": + return metadata.get("v3") is True or metadata.get("v2") is True + return False + + def check_package(path: Path) -> list[str]: - """校验单个 package 文件,返回所有错误文本。""" + """校验单个 package 文件中仍面向 V3 发布的条目。""" errors: list[str] = [] package = _load_package(path) for plugin_id, meta in package.items(): - if not isinstance(meta, dict) or meta.get("release") is not True: + if not isinstance(meta, dict): + errors.append(f"{path}: {plugin_id} 元数据必须为对象") + continue + if path.name != "package.v3.json" and not _is_v3_release_entry(path, meta): continue package_version = str(meta.get("version") or "").strip() plugin_dir = _plugin_dir(path, plugin_id) @@ -85,12 +171,18 @@ def check_package(path: Path) -> list[str]: f"{path}: {plugin_id} 版本不一致,package={package_version}, " f"plugin_version={source_version} ({init_file})" ) + if path.name == "package.v3.json": + errors.extend(_check_v3_metadata(path, plugin_id, meta)) return errors def main() -> int: """命令入口:所有 package 均通过时返回 0,否则打印错误并返回 1。""" - package_files = [Path(arg) for arg in sys.argv[1:]] or [Path("package.json"), Path("package.v2.json")] + package_files = [Path(arg) for arg in sys.argv[1:]] or [ + Path("package.json"), + Path("package.v2.json"), + Path("package.v3.json"), + ] errors: list[str] = [] for package_file in package_files: errors.extend(check_package(package_file)) diff --git a/.github/scripts/select_plugin_release_dir.sh b/.github/scripts/select_plugin_release_dir.sh index 6073b5e4..03be4db7 100644 --- a/.github/scripts/select_plugin_release_dir.sh +++ b/.github/scripts/select_plugin_release_dir.sh @@ -11,6 +11,9 @@ case "$(basename "$package_file")" in package.v2.json) plugin_dir="plugins.v2/${plugin_id}" ;; + package.v3.json) + plugin_dir="plugins.v3/${plugin_id}" + ;; *) echo "Unsupported package file: ${package_file}" >&2 exit 2 diff --git a/.github/workflows/frontend-test.yml b/.github/workflows/frontend-test.yml index 99d53b4e..88e1c50c 100644 --- a/.github/workflows/frontend-test.yml +++ b/.github/workflows/frontend-test.yml @@ -6,8 +6,8 @@ on: - main paths: - '.github/workflows/frontend-test.yml' - - 'plugins.v2/subscribeassistantenhanced/frontend/**' - - 'tests/v2/subscribeassistantenhanced/frontend/**' + - 'plugins.v3/subscribeassistantenhanced/frontend/**' + - 'tests/v3/subscribeassistantenhanced/frontend/**' permissions: contents: read @@ -22,7 +22,7 @@ jobs: timeout-minutes: 20 defaults: run: - working-directory: plugins.v2/subscribeassistantenhanced/frontend + working-directory: plugins.v3/subscribeassistantenhanced/frontend steps: - name: Checkout uses: actions/checkout@v7 @@ -32,7 +32,7 @@ jobs: with: node-version: '24' cache: yarn - cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock + cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock - name: Install dependencies run: yarn --frozen-lockfile @@ -45,7 +45,7 @@ jobs: timeout-minutes: 20 defaults: run: - working-directory: plugins.v2/subscribeassistantenhanced/frontend + working-directory: plugins.v3/subscribeassistantenhanced/frontend steps: - name: Checkout uses: actions/checkout@v7 @@ -55,7 +55,7 @@ jobs: with: node-version: '24' cache: yarn - cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock + cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock - name: Install dependencies run: yarn --frozen-lockfile @@ -68,7 +68,7 @@ jobs: timeout-minutes: 20 defaults: run: - working-directory: plugins.v2/subscribeassistantenhanced/frontend + working-directory: plugins.v3/subscribeassistantenhanced/frontend steps: - name: Checkout uses: actions/checkout@v7 @@ -78,7 +78,7 @@ jobs: with: node-version: '24' cache: yarn - cache-dependency-path: plugins.v2/subscribeassistantenhanced/frontend/yarn.lock + cache-dependency-path: plugins.v3/subscribeassistantenhanced/frontend/yarn.lock - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/plugin-gate.yml b/.github/workflows/plugin-gate.yml index 7c2e4508..fe5ab4a7 100644 --- a/.github/workflows/plugin-gate.yml +++ b/.github/workflows/plugin-gate.yml @@ -19,13 +19,47 @@ jobs: fetch-depth: 0 - name: Check plugin versions - run: python .github/scripts/check_plugin_versions.py package.json package.v2.json + run: python .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json - name: Check new plugin tests run: | git fetch origin main:refs/remotes/origin/main --depth=1 python scripts/check_new_plugin_tests.py --base-ref origin/main + plugin-test-gate: + name: Plugin test gate + runs-on: ubuntu-latest + steps: + - name: Checkout plugin repository + uses: actions/checkout@v6 + with: + path: MoviePilot-Plugins + + - name: Checkout MoviePilot V3 backend + uses: actions/checkout@v6 + with: + repository: jxxghp/MoviePilot + ref: v3 + path: MoviePilot + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: MoviePilot/requirements-dev.in + + - name: Install backend test dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + pip install -r MoviePilot/requirements-dev.in + + - name: Run V3 plugin tests + env: + MOVIEPILOT_BACKEND_PATH: ${{ github.workspace }}/MoviePilot + working-directory: MoviePilot-Plugins + run: python tests/run.py + plugin-coverage-gate: name: Plugin coverage gate runs-on: ubuntu-latest @@ -60,7 +94,7 @@ jobs: } for item in config.get("coverage", []): generation = item["generation"] - base = "plugins.v2" if generation == "v2" else "plugins" + base = f"plugins.{generation}" plugin = item["plugin"] paths.add(f"{base}/{plugin}/") paths.add(f"tests/{generation}/{plugin}/") @@ -89,7 +123,7 @@ jobs: uses: actions/checkout@v6 with: repository: jxxghp/MoviePilot - ref: v2 + ref: v3 path: MoviePilot - name: Set up Python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c16fbc50..73203b64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: paths: - 'package.json' - 'package.v2.json' + - 'package.v3.json' workflow_dispatch: permissions: @@ -27,7 +28,7 @@ jobs: sudo apt-get install -y jq zip - name: Check plugin versions - run: python .github/scripts/check_plugin_versions.py package.json package.v2.json + run: python .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json - name: Build and release changed plugins env: @@ -44,8 +45,24 @@ jobs: echo "Processing $pkg_file" - # Only entries explicitly marked release=true are packaged. - mapfile -t entries < <(jq -r 'to_entries | map(select(.value.release == true)) | .[] | "\(.key)|\(.value.version)"' "$pkg_file") + case "$pkg_file" in + package.json) + # Default-index implementations need an explicit V2/V3 opt-in before V3 can load them. + entry_filter='select(.value.release == true and .value.v3 != false and (.value.v3 == true or .value.v2 == true))' + ;; + package.v2.json) + # V3 inherits V2 implementations unless the entry explicitly opts out. + entry_filter='select(.value.release == true and .value.v3 != false)' + ;; + package.v3.json) + entry_filter='select(.value.release == true)' + ;; + *) + echo "Unsupported package file: $pkg_file" >&2 + exit 2 + ;; + esac + mapfile -t entries < <(jq -r "to_entries | map($entry_filter) | .[] | \"\\(.key)|\\(.value.version)\"" "$pkg_file") if [ "${#entries[@]}" -eq 0 ]; then echo "No plugins with release=true in $pkg_file" @@ -58,8 +75,8 @@ jobs: plugin_id_lc="$(echo "$plugin_id" | tr '[:upper:]' '[:lower:]')" if ! plugin_dir="$(bash .github/scripts/select_plugin_release_dir.sh "$pkg_file" "$plugin_id_lc")"; then - echo "WARN: directory for $plugin_id not found under plugins/ or plugins.v2/, skip." - continue + echo "Missing plugin directory for $plugin_id in $pkg_file" >&2 + exit 1 fi tag="${plugin_id}_v${plugin_version}" @@ -119,3 +136,4 @@ jobs: process_package "package.json" process_package "package.v2.json" + process_package "package.v3.json" diff --git a/package.v2.json b/package.v2.json index fe9aa6f0..4ce60e91 100644 --- a/package.v2.json +++ b/package.v2.json @@ -76,6 +76,7 @@ "v0.1.2": "补齐订阅状态、洗版订阅创建、分集转全集、删种善后与自动纠错重建的通知及事件联动。", "v0.1.0": "多场景管理订阅,实现订阅全生命周期管理。" }, + "v3": false, "release": true }, "BrushFlowLowFreq": { @@ -95,6 +96,7 @@ "v4.0": "站点独立配置项支持配置NexusPHP 站点自动跳过下载提示页", "v3.9": "MoviePilot V2 版本站点刷流(低频版)插件" }, + "v3": false, "release": true }, "PluginReOrder": { @@ -212,6 +214,7 @@ "v1.2": "优化执行周期输入,需要MoviePilot v2.2.1+", "v1.1": "MoviePilot V2 版本PlexEdition插件" }, + "v3": false, "release": true }, "PlexPersonMeta": { @@ -231,6 +234,7 @@ "v1.8": "优化执行周期输入,需要MoviePilot v2.2.1+", "v1.7": "MoviePilot V2 版本Plex演职人员刮削插件" }, + "v3": false, "release": true }, "TrafficAssistant": { @@ -406,6 +410,7 @@ "v1.1": "增加自动洗版以及修复一些细节问题", "v1.0": "增加订阅助手插件,支持多场景管理订阅,实现订阅种子删除以及自动待定/暂停/洗版" }, + "v3": false, "release": true }, "PlexMatch": { @@ -420,6 +425,7 @@ "v1.3": "修复 PostgreSQL 下历史记录查询错误,并保持 SQLite 兼容", "v1.2": "MoviePilot V2 版本PlexMatch插件" }, + "v3": false, "release": true } } diff --git a/package.v3.json b/package.v3.json new file mode 100644 index 00000000..1e517bc7 --- /dev/null +++ b/package.v3.json @@ -0,0 +1,72 @@ +{ + "SubscribeAssistantEnhanced": { + "name": "订阅助手(增强版)", + "description": "多场景管理订阅,实现订阅全生命周期管理。", + "labels": "订阅", + "version": "0.7", + "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png", + "author": "InfinityPacer", + "level": 1, + "system_version": ">=3.0.0", + "history": { + "v0.7": "MoviePilot V3 版本订阅助手(增强版)插件" + }, + "release": true + }, + "BrushFlowLowFreq": { + "name": "站点刷流(低频版)", + "description": "自动托管刷流,将会提高对应站点的访问频率。(基于官方插件BrushFlow二次开发)", + "labels": "站点,刷流,仪表板", + "version": "4.5", + "icon": "brush.jpg", + "author": "jxxghp,InfinityPacer", + "level": 2, + "system_version": ">=3.0.0", + "history": { + "v4.5": "MoviePilot V3 版本站点刷流(低频版)插件" + }, + "release": true + }, + "PlexEdition": { + "name": "PlexEdition", + "description": "根据入库记录修改Edition为电影版本/资源类型/特效信息。", + "labels": "Plex", + "version": "1.3", + "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexedition.png", + "author": "InfinityPacer", + "level": 1, + "system_version": ">=3.0.0", + "history": { + "v1.3": "MoviePilot V3 版本PlexEdition插件" + }, + "release": true + }, + "PlexPersonMeta": { + "name": "Plex演职人员刮削", + "description": "实现刮削演职人员中文名称及角色。", + "labels": "Plex,刮削", + "version": "2.4", + "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexpersonmeta.png", + "author": "InfinityPacer", + "level": 1, + "system_version": ">=3.0.0", + "history": { + "v2.4": "MoviePilot V3 版本Plex演职人员刮削插件" + }, + "release": true + }, + "PlexMatch": { + "name": "PlexMatch", + "description": "实现入库时添加 .plexmatch 文件,提高识别准确率。", + "labels": "Plex,刮削", + "version": "1.4", + "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexmatch.png", + "author": "InfinityPacer", + "level": 1, + "system_version": ">=3.0.0", + "history": { + "v1.4": "MoviePilot V3 版本PlexMatch插件" + }, + "release": true + } +} diff --git a/plugin_quality.json b/plugin_quality.json index 8eb17926..572e2bf7 100644 --- a/plugin_quality.json +++ b/plugin_quality.json @@ -1,14 +1,7 @@ { "coverage": [ { - "generation": "v2", - "plugin": "subscribeassistant", - "line": 90, - "method": 90, - "changed_line": 90 - }, - { - "generation": "v2", + "generation": "v3", "plugin": "subscribeassistantenhanced", "line": 90, "method": 90, diff --git a/plugins.v3/brushflowlowfreq/README.md b/plugins.v3/brushflowlowfreq/README.md new file mode 100644 index 00000000..b7b8e780 --- /dev/null +++ b/plugins.v3/brushflowlowfreq/README.md @@ -0,0 +1,242 @@ +# 站点刷流(低频版) + +在官方刷流插件的基础上,新增了若干项功能优化了部分细节逻辑,目前已逐步PR至官方插件。在此,再次感谢 [@jxxghp](https://github.com/jxxghp) 提供那么优秀的开源作品。 + +**本文档适用于 MoviePilot V3 专用实现;旧实现文档见 [V2 插件](../../plugins.v2/brushflowlowfreq/README.md)。** + +## 版本更新日志 + +- v4.5 + - MoviePilot V3 版本站点刷流(低频版)插件 + +- v4.3.2 + - 兼容新版 Transmission SDK 字段,修正刷流统计读取异常 + +- v4.3 + - 优化执行周期输入,需要MoviePilot v2.2.1+ + +- v4.2 + - 支持带宽采样并计算平均值,以优化刷流效率 + +- v4.1 + - 支持通过CRON表达式配置开启时间,固定10分钟为执行周期 + +- v4.0 + - 站点独立配置项支持配置NexusPHP 站点自动跳过下载提示页 + +- v3.9 + - MoviePilot V2 版本站点刷流(低频版)插件 + +## 开发计划 + +- [ ] **站点独立配置支持保种体积**:新增保种体积配置,删除种子时按站点独立配置执行。若站点未配置保种体积,则使用全局配置。 +- [x] **适配跳过站点提示页**:部分站点存在下载种子提示页,新增自动跳过功能。 +- [ ] **自定义 RSS 支持**:针对部分站点首页置顶过多的问题,通过配置 RSS 实现刷流逻辑(不支持免费、H&R 等需要解析种子网页的规则)。 + +## 定时服务 + +- **刷流服务**:每 10 分钟运行一次,用于请求站点下载刷流种子。 +- **刷流检查服务**:每 5 分钟运行一次,用于同步检查下载器的刷流种子信息、删除种子和更新统计。 + +## 配置说明 + +| 配置项 | 标识 | 说明 | 备注 | +| ---------------------- | -------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| 启用插件 | `enabled` | 控制插件是否启用 | | +| 发送通知 | `notify` | 是否启用发送通知功能 | | +| 立即运行一次 | `onlyonce` | 立即运行一次操作 | 刷流和刷流检查服务均会执行 | +| 刷流站点 | `brushsites` | 选择要刷流的站点 | 若未选择站点但启用了插件,刷流服务不会执行,但刷流检查服务会正常运行 | +| 下载器 | `downloader` | 选择使用的下载客户端 | | +| 保种体积(GB) | `disksize` | 刷流任务达到指定体积后停止刷流 | 保种体积 >(刷流任务体积 + 种子大小下限)时允许继续刷流 | +| 种子分类 | `qb_category` | 刷流推送下载器的种子分类 | 仅支持 QB,配置后保存目录配置项失效,需提前在下载器中创建 | +| 促销 | `freeleech` | 根据促销类型过滤任务 | 包括全部、免费和 2X 免费,免费包括 2X 免费 | +| 排除 H&R | `hr` | 是否排除有 H&R 要求的任务 | | +| 总上传带宽(KB/s) | `maxupspeed` | 达到设定的上传带宽后停止刷流 | | +| 总下载带宽(KB/s) | `maxdlspeed` | 达到设定的下载带宽后停止刷流 | | +| 同时下载任务数 | `maxdlcount` | 设置同时下载的最大任务数 | | +| 包含规则 | `include` | 设置刷流包含的规则(支持正则表达式) | 种子标题或副标题任一匹配即可 | +| 排除规则 | `exclude` | 设置刷流排除的规则(支持正则表达式) | 种子标题或副标题任一匹配即可 | +| 种子大小(GB) | `size` | 设置任务的种子大小过滤 | 示例:
5,种子体积 ≥ 5GB
5-10,5GB ≤ 种子体积 ≤ 10GB | +| 做种人数 | `seeder` | 设置做种人数过滤 | 示例:
5,做种人数 ≤ 5
5-10,5 ≤ 做种人数 ≤ 10 | +| 发布时间(分钟) | `pubtime` | 设置任务的发布时间过滤 | 示例:
5,发布时间 ≤ 5
5-10,5 ≤ 发布时间 ≤ 10 | +| 站点全局 H&R | `site_hr_active` | 标记站点是否开启全局 H&R | 开启后新增的刷流任务会标记为 H&R 种子,仅联动删种规则,不会联动「排除 H&R」选项和消息推送 | +| 忽略站点提示 | `site_skip_tips` | 是否忽略站点下载提示 | 开启后,支持部分 NexusPHP 站点忽略下载提示,如低分享率等场景 | +| 做种时间(小时) | `seed_time` | 达到指定做种时间后删除任务 | | +| H&R 做种时间(小时) | `hr_seed_time` | 对 H&R 任务,达到指定做种时间后删除 | 如果未配置 H&R 做种时间/分享率,则普通种子的删除规则也适用于 H&R 种子 | +| 动态删种阈值(GB) | `delete_size_range` | 设置动态删种的体积阈值 | 详情见[动态删除规则](#动态删除规则) | +| 分享率 | `seed_ratio` | 达到设定分享率后删除任务 | | +| 上传量(GB) | `seed_size` | 达到设定上传量后删除任务 | | +| 下载超时时间(小时) | `download_time` | 达到指定下载超时时间后删除任务 | | +| 平均上传速度(KB/s) | `seed_avgspeed` | 低于设定平均上传速度时删除任务 | 刷流任务做种 30 分钟后生效 | +| 未活动时间(分钟) | `seed_inactivetime` | 超过设定未活动时间后删除任务 | | +| 删除排除标签 | `delete_except_tags` | 删除任务时排除的种子任务标签 | 默认值为 MOVIEPILOT,H&R,用于联动 H&R 助手或 MoviePilot 任务 | +| 单任务上传限速(KB/s) | `up_speed` | 设置每个种子的上传限速 | | +| 单任务下载限速(KB/s) | `dl_speed` | 设置每个种子的下载限速 | | +| 保存目录 | `save_path` | 设置种子保存目录 | | +| 自动归档记录天数 | `auto_archive_days` | 定时归档已删除种子任务 | | +| 开启时间段 | `active_time_range` | 设置插件刷流的活动时间段 | 示例:00:00-08:00 | +| 执行周期 | `cron` | 设置插件刷流的活动周期 | 执行周期固定为 10 分钟,配置项仅用于设置活动周期。例如:`0 0-1 * * FRI,SUN`,建议使用标准缩写(如 `FRI`)表示星期 | +| 站点顺序刷流 | `brush_sequential` | 是否按站点顺序刷流 | 关闭选项时,按站点随机顺序刷流 | +| 排除订阅 | `except_subscribe` | 刷流时排除订阅内容相关的种子 | **实验性功能**,开启后可能导致刷流时无法正常下载种子 | +| 动态删除种子 | `proxy_delete` | 是否启用动态删除种子 | **实验性功能**,可能导致刷流数据异常,甚至清空数据,请慎重开启。详情见[动态删除规则](#动态删除规则) | +| 清除统计数据 | `clear_task` | 是否清除统计数据 | 一次性任务,自动重置插件数据页中的所有数据 | +| 站点独立配置 | `enable_site_config` | 是否启用站点独立配置 | 详情见[站点独立配置](#站点独立配置) | +| 打开站点配置窗口 | `dialog_closed` | 控制站点独立配置页面的显示状态 | 点击后可打开站点独立配置页面 | +| 双向同步官方插件数据 | `sync_official` | 是否双向同步官方插件数据 | 如存在重复任务,默认以本插件的任务为准 | + +## 动态删除规则 + +- 开启动态删除种子和设置动态删除阈值后 + +,动态删除规则开始生效: + 1. 无论做种体积是否超过阈值,优先执行排除 H&R 种子后满足「下载超时时间」的种子。 + 2. 执行完上述规则后,若做种体积仍超过阈值,继续执行下述规则。 + 3. 优先删除满足用户配置删除规则的种子,即使删除过程中体积已低于阈值,也会继续删除。 + 4. 若删除后仍未达到阈值,则在已完成的种子中排除 H&R 种子后按做种时间倒序删除。 + 5. 动态删除阈值示例: + - 阈值 100GB:当做种体积 > 100GB 时,开始删除种子,直至体积降至 100GB。 + - 阈值 50-100GB:当做种体积 > 100GB 时,开始删除种子,直至体积降至 50GB。 + +## 站点独立配置 + +站点独立配置支持以下配置项。配置格式为 JSON,通过 `sitename` 进行匹配。若未找到对应配置项,则以全局配置为准。**请注意,与全局保持一致的配置项不应在站点配置中重复配置。为确保配置正确,可开启 `DEBUG` 日志进行排查**。 + +- `sitename`:站点名称 +- `freeleech`:促销类型 + - `''`:全部 + - `'free'`:免费 + - `'2xfree'`:2X 免费 +- `hr`:排除 H&R + - `'yes'`:是 + - `'no'`:否 +- `include`:包含规则 +- `exclude`:排除规则 +- `size`:种子大小 +- `seeder`:做种人数 +- `pubtime`:发布时间 +- `seed_time`:做种时间 +- `seed_ratio`:分享率 +- `seed_size`:上传量 +- `download_time`:下载超时时间 +- `seed_avgspeed`:平均上传速度 +- `seed_inactivetime`:未活动时间 +- `save_path`:保存目录 +- `proxy_delete`:动态删除种子(实验性功能) +- `hr_seed_time`:H&R 做种时间 +- `qb_category`:种子分类 +- `site_hr_active`:站点全局 H&R +- `site_skip_tips`:忽略站点提示 + +### 配置示例 + +```json +// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/README.md 进行配置 +// 如与全局保持一致的配置项,请勿在站点配置中配置 +// 注意无关内容需使用 // 注释 +[{ + "sitename": "站点1", + "seed_time": 96, + "hr_seed_time": 144 +}, { + "sitename": "站点2", + "hr": "yes", + "size": "10-500", + "seeder": "5-10", + "pubtime": "5-120", + "seed_time": 96, + "save_path": "/downloads/site2", + "hr_seed_time": 144 +}, { + "sitename": "站点3", + "freeleech": "free", + "hr": "yes", + "include": "", + "exclude": "", + "size": "10-500", + "seeder": "1", + "pubtime": "5-120", + "seed_time": 120, + "hr_seed_time": 144, + "seed_ratio": "", + "seed_size": "", + "download_time": "", + "seed_avgspeed": "", + "seed_inactivetime": "", + "save_path": "/downloads/site1", + "proxy_delete": false, + "qb_category": "刷流", + "site_hr_active": true, + "site_skip_tips": true +}] +``` + +## 注意事项 + + - **启用官方刷流插件时,本插件无法正常使用,可尝试停用官方插件后通过双向同步官方插件数据再开启使用,请不要同时启用两个插件,否则可能导致种子异常甚至数据丢失!** + - **排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用!** + +## FAQ + +- **官版和低频版有什么区别?** + - 低频版是在官版 v1.4 的基础上进行的二次开发,目前已逐步向官方仓库提交 PR。低频版的更新速度相对更快,除数据页外,短期内功能上与官版基本无差别。 + +- **如何开启 `DEBUG` 日志?** + - 请在环境变量或 DEV 文件中,添加 `DEBUG=true` 或 `LOG_LEVEL=DEBUG` 配置项。 + +- **为什么我配置了刷流,但没有下载到种子,或者下载的种子不符合我的规则?** + - 请开启 `DEBUG` 日志,并根据日志排查问题。 + - 尝试在 MP 中,进入站点管理->对应站点->浏览,检查是否能够正常浏览种子。 + - 如果配置了「发布时间」,但某些站点(如 OB、Frds)的时区为 UTC+0,请通过日志排查确认时间是否为 UTC+0。如果确认为 UTC+0,可通过站点独立配置「pubtime」,如 480-530 来适配。 + +- **为什么我在站点中配置了 RSS,但刷流没有按 RSS 执行?** + - 目前刷流插件不支持 RSS 功能,请关注后续开发计划。 + +- **为什么我配置了站点独立配置,但没有生效,且插件自动关闭了?** + - 请开启 `DEBUG` 日志,并根据日志排查问题。 + +- **为什么刷流日志中提示「获取种子 Hash 失败」「添加刷流任务失败」「不是有效的 torrent 文件」等错误,且下载器中没有添加刷流任务?** + - 请检查 MP 与下载器的连接是否正常。 + - 检查 QB 日志是否有异常。 + - 某些站点首次下载种子时需要在网页上确认下载提示,请确认不是首次下载。 + - 尝试在 MP 中,进入站点管理->对应站点->浏览,找到种子并点击下载,确认是否能正常下载。 + +- **为什么下载器中经常出现乱码标签?** + - 由于 qBittorrent 的添加种子接口没有返回种子 Hash,目前通过随机标签获取种子 Hash 以便后续刷流管理。当下载器连接性较差时,可能会出现超时,导致种子已经开始下载,但 MP 未获取到相应信息,从而认为种子未下载,随机标签未被删除。 + +- **为什么我被标记 H&R 了,会不会被封号?** + - H&R 的排除功能不保证适配所有站点(部分站点列表页不显示 H&R 标志,但实际上存在 H&R 风险)。 + - 请查看各站点的 H&R 规则以了解详情。 + +- **为什么选择只刷免费种,但仍然统计了下载量?** + - 请检查是否被标记为盒子种子。 + - 检查种子的免费期限是否已结束。 + +- **为什么被标记为盒子种子了?** + - 请检查下载器的网络环境是否正常。 + +- **为什么开启了动态删除种子,结果我的种子全被删除了?** + - 该功能为实验性功能,目前删除规则还在不断优化调整中,可能会导致刷流数据异常,甚至清空种子,请谨慎使用。 + - 目前的删除规则是:当做种体积超过动态删除阈值时,系统会按**用户设置的删除规则**优先删除所有符合条件的种子,即使在删除过程中体积已低于阈值,也会继续执行删除操作。 + - 如果删除后仍未达到阈值,系统会在已完成的种子中,排除 H&R 种子后,按做种时间倒序删除。 + +- **为什么刷流插件不支持配置标签?** + - 请使用「种子关键字分类整理」、「下载任务分类和标签」、「下载器助手」插件。 + +- **为什么种子在站点显示为免费,但在刷流日志中提示为非免费种子,或者促销条件与站点不符?** + - 请检查免费期是否已结束。 + - 尝试在 MP 中,进入站点管理->对应站点->浏览,找到种子并查看促销条件是否与站点一致。如不一致,请在 [MoviePilot](https://github.com/jxxghp/MoviePilot/issues) 反馈适配问题。 + +- **为什么配置了选种规则,但未按规则执行?** + - 请开启 `DEBUG` 日志,并根据日志排查问题。 + - 部分种子可能会在发布后修改促销条件或 H&R 状态,请检查日志并确认。 + - 尝试在 MP 中,进入站点管理->对应站点->浏览,找到种子并核对信息。如不一致,可在 [MoviePilot](https://github.com/jxxghp/MoviePilot/issues) 反馈适配问题。 + +- **为什么刷流插件显示的种子信息与站点信息不一致?** + - 请开启 `DEBUG` 日志,并根据日志排查问题。 + - 部分种子可能在发布后修改了促销条件或 H&R 状态,请查看日志进行排查。 + - 尝试在 MP 中,进入站点管理->对应站点->浏览,查看种子相关信息与站点是否一致。如不一致,请在 [MoviePilot](https://github.com/jxxghp/MoviePilot/issues) 反馈适配问题。 + +- **为什么配置了保存目录,但仍然保存到了 QB 的默认下载目录?** + - 请检查下载器是否开启了「自动分类管理」,开启后下载目录由 QB 管理,此时「保存目录」配置项将失效。请提前在 QB 中配置相关选项。 + +![](../../images/2024-05-02-03-16-42.png) diff --git a/plugins.v3/brushflowlowfreq/__init__.py b/plugins.v3/brushflowlowfreq/__init__.py new file mode 100644 index 00000000..68b79380 --- /dev/null +++ b/plugins.v3/brushflowlowfreq/__init__.py @@ -0,0 +1,4124 @@ +import base64 +import json +import random +import re +import threading +import time +from datetime import datetime, timedelta +from typing import Any, List, Dict, Tuple, Optional, Union, Set +from urllib.parse import urlparse, parse_qs, unquote, parse_qsl, urlencode, urlunparse + +import pytz +from app.helper.sites import SitesHelper +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app import schemas +from app.chain.torrents import TorrentsChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.metainfo import MetaInfo +from app.db.site_oper import SiteOper +from app.db.subscribe_oper import SubscribeOper +from app.helper.downloader import DownloaderHelper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission +from app.plugins import _PluginBase +from app.schemas import NotificationType, TorrentInfo, MediaType, ServiceInfo +from app.schemas.types import EventType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class BrushConfig: + """ + 刷流配置 + """ + + def __init__(self, config: dict, process_site_config=True): + self.enabled = config.get("enabled", False) + self.notify = config.get("notify", True) + self.onlyonce = config.get("onlyonce", False) + self.brushsites = config.get("brushsites", []) + self.downloader = config.get("downloader") + self.disksize = self.__parse_number(config.get("disksize")) + self.freeleech = config.get("freeleech", "free") + self.hr = config.get("hr", "no") + self.maxupspeed = self.__parse_number(config.get("maxupspeed")) + self.maxdlspeed = self.__parse_number(config.get("maxdlspeed")) + self.maxdlcount = self.__parse_number(config.get("maxdlcount")) + self.include = config.get("include") + self.exclude = config.get("exclude") + self.size = config.get("size") + self.seeder = config.get("seeder") + self.pubtime = config.get("pubtime") + self.seed_time = self.__parse_number(config.get("seed_time")) + self.hr_seed_time = self.__parse_number(config.get("hr_seed_time")) + self.seed_ratio = self.__parse_number(config.get("seed_ratio")) + self.seed_size = self.__parse_number(config.get("seed_size")) + self.download_time = self.__parse_number(config.get("download_time")) + self.seed_avgspeed = self.__parse_number(config.get("seed_avgspeed")) + self.seed_inactivetime = self.__parse_number(config.get("seed_inactivetime")) + self.delete_size_range = config.get("delete_size_range") + self.up_speed = self.__parse_number(config.get("up_speed")) + self.dl_speed = self.__parse_number(config.get("dl_speed")) + self.auto_archive_days = self.__parse_number(config.get("auto_archive_days")) + self.save_path = config.get("save_path") + self.clear_task = config.get("clear_task", False) + self.delete_except_tags = config.get("delete_except_tags") + self.except_subscribe = config.get("except_subscribe", True) + self.brush_sequential = config.get("brush_sequential", False) + self.proxy_delete = config.get("proxy_delete", False) + self.active_time_range = config.get("active_time_range") + self.cron = config.get("cron") + self.qb_category = config.get("qb_category") + self.site_hr_active = config.get("site_hr_active", False) + self.site_skip_tips = config.get("site_skip_tips", False) + + self.brush_tag = "刷流" + # 站点独立配置 + self.enable_site_config = config.get("enable_site_config", False) + self.site_config = config.get("site_config", "[]") + self.group_site_configs = {} + + # 如果开启了独立站点配置,那么则初始化,否则判断配置是否为空,如果为空,则恢复默认配置 + if process_site_config: + if self.enable_site_config: + self.__initialize_site_config() + elif not self.site_config: + self.site_config = self.get_demo_site_config() + + def __initialize_site_config(self): + if not self.site_config: + logger.error(f"没有设置站点配置,已关闭站点独立配置并恢复默认配置示例,请检查配置项") + self.site_config = self.get_demo_site_config() + self.group_site_configs = {} + self.enable_site_config = False + return + + # 定义允许覆盖的字段列表 + allowed_fields = { + "freeleech", + "hr", + "include", + "exclude", + "size", + "seeder", + "pubtime", + "seed_time", + "hr_seed_time", + "seed_ratio", + "seed_size", + "download_time", + "seed_avgspeed", + "seed_inactivetime", + "save_path", + "proxy_delete", + "qb_category", + "site_hr_active", + "site_skip_tips" + # 当新增支持字段时,仅在此处添加字段名 + } + try: + # site_config中去掉以//开始的行 + site_config = re.sub(r'//.*?\n', '', self.site_config).strip() + site_configs = json.loads(site_config) + self.group_site_configs = {} + for config in site_configs: + sitename = config.get("sitename") + if not sitename: + continue + + # 只从站点特定配置中获取允许的字段 + site_specific_config = {key: config[key] for key in allowed_fields & set(config.keys())} + + full_config = {key: getattr(self, key) for key in vars(self) if + key not in ["group_site_configs", "site_config"]} + full_config.update(site_specific_config) + + self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False) + except Exception as e: + logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项,错误详情: {e}") + self.group_site_configs = {} + self.enable_site_config = False + self.enabled = False + + @staticmethod + def get_demo_site_config() -> str: + desc = ( + "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v3/brushflowlowfreq/README.md 进行配置\n" + "// 如与全局保持一致的配置项,请勿在站点配置中配置\n" + "// 注意无关内容需使用 // 注释\n") + config = """[{ + "sitename": "站点1", + "seed_time": 96, + "hr_seed_time": 144 +}, { + "sitename": "站点2", + "hr": "yes", + "size": "10-500", + "seeder": "5-10", + "pubtime": "5-120", + "seed_time": 96, + "save_path": "/downloads/site2", + "hr_seed_time": 144 +}, { + "sitename": "站点3", + "freeleech": "free", + "hr": "yes", + "include": "", + "exclude": "", + "size": "10-500", + "seeder": "1", + "pubtime": "5-120", + "seed_time": 120, + "hr_seed_time": 144, + "seed_ratio": "", + "seed_size": "", + "download_time": "", + "seed_avgspeed": "", + "seed_inactivetime": "", + "save_path": "/downloads/site1", + "proxy_delete": false, + "qb_category": "刷流", + "site_hr_active": true, + "site_skip_tips": true +}]""" + return desc + config + + def get_site_config(self, sitename): + """ + 根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置,则返回全局的BrushConfig实例。 + """ + if not self.enable_site_config: + return self + return self if not sitename else self.group_site_configs.get(sitename, self) + + @staticmethod + def __parse_number(value): + if value is None or value == "": # 更精确地检查None或空字符串 + return value + elif isinstance(value, int): # 直接判断是否为int + return value + elif isinstance(value, float): # 直接判断是否为float + return value + else: + try: + number = float(value) + # 检查number是否等于其整数形式 + if number == int(number): + return int(number) + else: + return number + except (ValueError, TypeError): + return 0 + + def __format_value(self, v): + """ + Format the value to mimic JSON serialization. This is now an instance method. + """ + if isinstance(v, str): + return f'"{v}"' + elif isinstance(v, (int, float, bool)): + return str(v).lower() if isinstance(v, bool) else str(v) + elif isinstance(v, list): + return '[' + ', '.join(self.__format_value(i) for i in v) + ']' + elif isinstance(v, dict): + return '{' + ', '.join(f'"{k}": {self.__format_value(val)}' for k, val in v.items()) + '}' + else: + return str(v) + + def __str__(self): + attrs = vars(self) + # Note the use of self.format_value(v) here to call the instance method + attrs_str = ', '.join(f'"{k}": {self.__format_value(v)}' for k, v in attrs.items()) + return f'{{ {attrs_str} }}' + + def __repr__(self): + return self.__str__() + + +class BrushFlowLowFreq(_PluginBase): + # region 全局定义 + + # 插件名称 + plugin_name = "站点刷流(低频版)" + # 插件描述 + plugin_desc = "自动托管刷流,将会提高对应站点的访问频率。(基于官方插件BrushFlow二次开发)" + # 插件图标 + plugin_icon = "brush.jpg" + # 插件版本 + plugin_version = "4.5" + # 插件作者 + plugin_author = "jxxghp,InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "brushflowlowfreq_" + # 加载顺序 + plugin_order = 22 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + sites_helper = None + site_oper = None + torrents_chain = None + subscribe_oper = None + downloader_helper = None + # 刷流配置 + _brush_config = None + # Brush任务是否启动 + _task_brush_enable = False + # 订阅缓存信息 + _subscribe_infos = None + # Brush定时 + _brush_interval = 10 + # Check定时 + _check_interval = 5 + # 退出事件 + _event = threading.Event() + _scheduler = None + # tabs + _tabs = None + + # endregion + + def init_plugin(self, config: dict = None): + self.sites_helper = SitesHelper() + self.site_oper = SiteOper() + self.torrents_chain = TorrentsChain() + self.subscribe_oper = SubscribeOper() + self.downloader_helper = DownloaderHelper() + self._task_brush_enable = False + + if not config: + logger.info("站点刷流任务出错,无法获取插件配置") + return False + + self._tabs = config.get("_tabs", None) + + # 如果配置校验没有通过,那么这里修改配置文件后退出 + if not self.__validate_and_fix_config(config=config): + self._brush_config = BrushConfig(config=config) + self._brush_config.enabled = False + self.__update_config() + return + + self._brush_config = BrushConfig(config=config) + + brush_config = self._brush_config + + # 判断是否存在插件冲突,如果存在则停用 + if not self.__check_and_resolve_plugin_conflict(): + self._brush_config.enabled = False + self.__update_config() + return + + # 这里先过滤掉已删除的站点并保存,特别注意的是,这里保留了界面选择站点时的顺序,以便后续站点随机刷流或顺序刷流 + if brush_config.brushsites: + site_id_to_public_status = {site.get("id"): site.get("public") for site in self.sites_helper.get_indexers()} + brush_config.brushsites = [ + site_id for site_id in brush_config.brushsites + if site_id in site_id_to_public_status and not site_id_to_public_status[site_id] + ] + + self.__update_config() + + if brush_config.clear_task: + self.__clear_tasks() + brush_config.clear_task = False + self.__update_config() + + # 同步官方插件 + self.__sync_official(config=config) + + if brush_config.enable_site_config: + logger.debug(f"已开启站点独立配置,配置信息:{brush_config}") + else: + logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}") + + # 停止现有任务 + self.stop_service() + + # 如果站点都没有配置,则不开启定时刷流服务 + if not brush_config.brushsites: + logger.info(f"站点刷流定时服务停止,没有配置站点") + + # 如果开启&存在站点时,才需要启用后台任务 + self._task_brush_enable = brush_config.enabled and brush_config.brushsites + + # 如果下载器都没有配置,那么这里也不需要继续 + if not brush_config.downloader: + brush_config.enabled = False + self.__update_config() + logger.info(f"站点刷流服务停止,没有配置下载器") + return + + if not self.service_info: + return + + # 检查是否启用了一次性任务 + if brush_config.onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + logger.info(f"站点刷流服务启动,立即运行一次") + self._scheduler.add_job(self.brush, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流服务") + + logger.info(f"站点刷流检查服务启动,立即运行一次") + self._scheduler.add_job(self.check, "date", + run_date=datetime.now( + tz=pytz.timezone(settings.TZ) + ) + timedelta(seconds=3), + name="站点刷流检查服务") + + # 关闭一次性开关 + brush_config.onlyonce = False + self.__update_config() + + # 存在任务则启动任务 + if self._scheduler.get_jobs(): + # 启动服务 + self._scheduler.print_jobs() + self._scheduler.start() + + @property + def service_info(self) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + brush_config = self.__get_brush_config() + service = self.downloader_helper.get_service(name=brush_config.downloader) + if not service: + self.__log_and_notify_error("站点刷流任务出错,获取下载器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + self.__log_and_notify_error("站点刷流任务出错,下载器未连接") + return None + + return service + + @property + def downloader(self) -> Optional[Union[Qbittorrent, Transmission]]: + """ + 下载器实例 + """ + return self.service_info.instance if self.service_info else None + + def get_state(self) -> bool: + brush_config = self.__get_brush_config() + return True if brush_config and brush_config.enabled else False + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + services = [] + + brush_config = self.__get_brush_config() + if not brush_config: + return services + + # 判断是否存在插件冲突,如果存在则停用 + if not self.__check_and_resolve_plugin_conflict(): + return services + + if self._task_brush_enable: + if brush_config.cron: + values = brush_config.cron.split() + values[0] = f"{datetime.now().minute % 10}/10" + cron = " ".join(values) + logger.info(f"站点刷流定时服务启动,执行周期 {cron}") + cron_trigger = CronTrigger.from_crontab(cron) + services.append({ + "id": "BrushFlowLowFreq", + "name": "站点刷流(低频版)服务", + "trigger": cron_trigger, + "func": self.brush + }) + else: + logger.info(f"站点刷流定时服务启动,时间间隔 {self._brush_interval} 分钟") + services.append({ + "id": "BrushFlowLowFreq", + "name": "站点刷流(低频版)服务", + "trigger": "interval", + "func": self.brush, + "kwargs": {"minutes": self._brush_interval} + }) + + if brush_config.enabled: + logger.info(f"站点刷流检查定时服务启动,时间间隔 {self._check_interval} 分钟") + services.append({ + "id": "BrushFlowLowFreqCheck", + "name": "站点刷流(低频版)检查服务", + "trigger": "interval", + "func": self.check, + "kwargs": {"minutes": self._check_interval} + }) + + if not services: + logger.info("站点刷流服务未开启") + + return services + + def __get_total_elements(self) -> List[dict]: + """ + 组装汇总元素 + """ + # 统计数据 + statistic_info = self.__get_statistic_info() + # 总上传量 + total_uploaded = StringUtils.str_filesize(statistic_info.get("uploaded") or 0) + # 总下载量 + total_downloaded = StringUtils.str_filesize(statistic_info.get("downloaded") or 0) + # 下载种子数 + total_count = statistic_info.get("count") or 0 + # 删除种子数 + total_deleted = statistic_info.get("deleted") or 0 + # 待归档种子数 + total_unarchived = statistic_info.get("unarchived") or 0 + # 活跃种子数 + total_active = statistic_info.get("active") or 0 + # 活跃上传量 + total_active_uploaded = StringUtils.str_filesize(statistic_info.get("active_uploaded") or 0) + # 活跃下载量 + total_active_downloaded = StringUtils.str_filesize(statistic_info.get("active_downloaded") or 0) + + return [ + # 总上传量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/upload.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总上传量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_uploaded} / {total_active_uploaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 总下载量 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/download.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '总下载量 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_downloaded} / {total_active_downloaded}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 下载种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/seed.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '下载种子数 / 活跃' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_count} / {total_active}" + } + ] + } + ] + } + ] + } + ] + }, + ] + }, + # 删除种子数 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + 'sm': 6 + }, + 'content': [ + { + 'component': 'VCard', + 'props': { + 'variant': 'tonal', + }, + 'content': [ + { + 'component': 'VCardText', + 'props': { + 'class': 'd-flex align-center', + }, + 'content': [ + { + 'component': 'VAvatar', + 'props': { + 'rounded': True, + 'variant': 'text', + 'class': 'me-3' + }, + 'content': [ + { + 'component': 'VImg', + 'props': { + 'src': '/plugin_icon/delete.png' + } + } + ] + }, + { + 'component': 'div', + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-caption' + }, + 'text': '删除种子数 / 待归档' + }, + { + 'component': 'div', + 'props': { + 'class': 'd-flex align-center flex-wrap' + }, + 'content': [ + { + 'component': 'span', + 'props': { + 'class': 'text-h6' + }, + 'text': f"{total_deleted} / {total_unarchived}" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + ] + + def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: + """ + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) + 1、col配置参考: + { + "cols": 12, "md": 6 + } + 2、全局配置参考: + { + "refresh": 10 // 自动刷新时间,单位秒 + } + 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + """ + # 列配置 + cols = { + "cols": 12 + } + # 全局配置 + attrs = {} + # 拼装页面元素 + elements = [ + { + 'component': 'VRow', + 'content': self.__get_total_elements() + } + ] + return cols, attrs, elements + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + + # 站点选项 + site_options = [{"title": site.get("name"), "value": site.get("id")} + for site in self.sites_helper.get_indexers()] + # 下载器选项 + downloader_options = [{"title": config.name, "value": config.name} + for config in self.downloader_helper.get_configs().values()] + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'brushsites', + 'label': '刷流站点', + 'items': site_options + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'downloader', + 'label': '下载器', + 'items': downloader_options + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 3 + }, + 'content': [ + { + 'component': 'VCronField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '如:0 0-1 * * FRI,SUN' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'active_time_range', + 'label': '开启时间段', + 'placeholder': '如:00:00-08:00' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 3 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_size_range', + 'label': '动态删种阈值(GB)', + 'placeholder': '如:500 或 500-1000,达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VTabs', + 'props': { + 'model': '_tabs', + 'style': { + 'margin-top': '8px', + 'margin-bottom': '16px' + }, + 'stacked': True, + 'fixed-tabs': True + }, + 'content': [ + { + 'component': 'VTab', + 'props': { + 'value': 'base_tab' + }, + 'text': '基本配置' + }, { + 'component': 'VTab', + 'props': { + 'value': 'download_tab' + }, + 'text': '选种规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'delete_tab' + }, + 'text': '删除规则' + }, { + 'component': 'VTab', + 'props': { + 'value': 'other_tab' + }, + 'text': '更多配置' + } + ] + }, + { + 'component': 'VWindow', + 'props': { + 'model': '_tabs' + }, + 'content': [ + { + 'component': 'VWindowItem', + 'props': { + 'value': 'base_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlcount', + 'label': '同时下载任务数', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'disksize', + 'label': '保种体积(GB)', + 'placeholder': '如:500,达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'qb_category', + 'label': '种子分类', + 'placeholder': '仅支持qBittorrent,需提前创建' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxupspeed', + 'label': '总上传带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'maxdlspeed', + 'label': '总下载带宽(KB/s)', + 'placeholder': '达到后停止新增任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'save_path', + 'label': '保存目录', + 'placeholder': '留空自动' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'up_speed', + 'label': '单任务上传限速(KB/s)', + 'placeholder': '种子上传限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'dl_speed', + 'label': '单任务下载限速(KB/s)', + 'placeholder': '种子下载限速' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'auto_archive_days', + 'label': '自动归档记录天数', + 'placeholder': '超过此天数后自动归档', + 'type': 'number', + "min": "0" + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'download_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'hr', + 'label': '排除H&R', + 'items': [ + {'title': '是', 'value': 'yes'}, + {'title': '否', 'value': 'no'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'freeleech', + 'label': '促销', + 'items': [ + {'title': '全部(包括普通)', 'value': ''}, + {'title': '免费', 'value': 'free'}, + {'title': '2X免费', 'value': '2xfree'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'pubtime', + 'label': '发布时间(分钟)', + 'placeholder': '如:5 或 5-10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '种子大小(GB)', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seeder', + 'label': '做种人数', + 'placeholder': '如:5 或 5-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'include', + 'label': '包含规则', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'exclude', + 'label': '排除规则', + 'placeholder': '支持正式表达式' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'delete_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '0px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_time', + 'label': '做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'hr_seed_time', + 'label': 'H&R做种时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_ratio', + 'label': '分享率', + 'placeholder': '达到后删除任务' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_size', + 'label': '上传量(GB)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_avgspeed', + 'label': '平均上传速度(KB/s)', + 'placeholder': '低于时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'download_time', + 'label': '下载超时时间(小时)', + 'placeholder': '达到后删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'seed_inactivetime', + 'label': '未活动时间(分钟)', + 'placeholder': '超过时删除任务' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + "cols": 12, + "md": 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delete_except_tags', + 'label': '删除排除标签', + 'placeholder': '如:MOVIEPILOT,H&R' + } + } + ] + } + ] + } + ] + }, + { + 'component': 'VWindowItem', + 'props': { + 'value': 'other_tab' + }, + 'content': [ + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '-16px' + } + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'brush_sequential', + 'label': '站点顺序刷流', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'except_subscribe', + 'label': '排除订阅(实验性功能)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'proxy_delete', + 'label': '动态删除种子(实验性功能)', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_task', + 'label': '清除统计数据', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_site_config', + 'label': '站点独立配置', + } + } + ] + }, + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "label": "打开站点配置窗口" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + "content": [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'sync_official', + 'label': '双向同步官方数据', + } + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '12px' + }, + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:详细配置说明以及刷流规则请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v3/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'warning', + 'variant': 'tonal', + 'text': '注意:启用官方刷流插件时,本插件无法正常使用,可尝试停用官方插件后通过双向同步官方数据再开启使用,请不要同时启用两个插件,否则可能导致种子异常甚至数据丢失' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用' + } + } + ] + } + ] + }, + { + "component": "VDialog", + "props": { + "model": "dialog_closed", + "max-width": "65rem", + "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked", + "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t" + }, + "content": [ + { + "component": "VCard", + "props": { + "title": "设置站点配置" + }, + "content": [ + { + "component": "VDialogCloseBtn", + "props": { + "model": "dialog_closed" + } + }, + { + "component": "VCardText", + "props": {}, + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAceEditor', + 'props': { + 'modelvalue': 'site_config', + 'lang': 'json', + 'theme': 'monokai', + 'style': 'height: 30rem', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:只有启用站点独立配置时,该配置项才会生效,详细配置参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins.v3/brushflowlowfreq/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": True, + "onlyonce": False, + "clear_task": False, + "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R", + "except_subscribe": True, + "brush_sequential": False, + "proxy_delete": False, + "freeleech": "free", + "hr": "yes", + "enable_site_config": False, + "site_config": BrushConfig.get_demo_site_config() + } + + def get_page(self) -> List[dict]: + # 种子明细 + torrents = self.get_data("torrents") or {} + + if not torrents: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + else: + data_list = torrents.values() + # 按time倒序排序 + data_list = sorted(data_list, key=lambda x: x.get("time") or 0, reverse=True) + + # 种子数据明细 + torrent_trs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': data.get("site_name") + }, + { + 'component': 'td', + 'html': f'{data.get("title")}' + + (f'
{data.get("description")}' if data.get( + "description") else "") + + }, + { + 'component': 'td', + 'text': StringUtils.str_filesize(data.get("size")) + }, + { + 'component': 'td', + 'text': StringUtils.str_filesize(data.get("uploaded") or 0) + }, + { + 'component': 'td', + 'text': StringUtils.str_filesize(data.get("downloaded") or 0) + }, + { + 'component': 'td', + 'text': round(data.get('ratio') or 0, 2) + }, + { + 'component': 'td', + 'text': "是" if data.get("hit_and_run") else "否" + }, + { + 'component': 'td', + 'text': f"{data.get('seeding_time') / 3600:.1f}" if data.get('seeding_time') else "N/A" + }, + { + 'component': 'td', + 'props': { + 'class': 'text-no-wrap' + }, + 'text': "已删除" if data.get("deleted") else "正常" + } + ] + } for data in data_list + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': self.__get_total_elements() + [ + # 种子明细 + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'props': { + 'class': 'text-no-wrap' + }, + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '站点' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '标题' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '大小' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '上传量' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '下载量' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '分享率' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'HR' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '做种时间' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '状态' + } + ] + }, + { + 'component': 'tbody', + 'content': torrent_trs + } + ] + } + ] + } + ] + } + ] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) + + # region Brush + + def brush(self): + """ + 定时刷流,添加下载任务 + """ + if not self.__check_and_resolve_plugin_conflict(): + return + + brush_config = self.__get_brush_config() + + if not brush_config.brushsites or not brush_config.downloader or not self.downloader: + return + + if not self.__is_current_time_in_range(): + logger.info(f"当前不在指定的刷流时间区间内,刷流操作将暂时暂停") + return + + with lock: + logger.info(f"开始执行刷流任务 ...") + + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + # 判断能否通过保种体积前置条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size) + self.__log_brush_conditions(passed=size_condition_passed, reason=reason) + if not size_condition_passed: + logger.info(f"刷流任务执行完成") + return + + # 判断能否通过刷流前置条件 + pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush() + self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) + if not pre_condition_passed: + logger.info(f"刷流任务执行完成") + return + + statistic_info = self.__get_statistic_info() + + # 获取所有站点的信息,并过滤掉不存在的站点 + site_infos = [] + for siteid in brush_config.brushsites: + siteinfo = self.site_oper.get(siteid) + if siteinfo: + site_infos.append(siteinfo) + + # 根据是否开启顺序刷流来决定是否需要打乱顺序 + if not brush_config.brush_sequential: + random.shuffle(site_infos) + + logger.info(f"即将针对站点 {', '.join(site.name for site in site_infos)} 开始刷流") + + # 获取订阅标题 + subscribe_titles = self.__get_subscribe_titles() + + # 处理所有站点 + for site in site_infos: + # 如果站点刷流没有正确响应,说明没有通过前置条件,其他站点也不需要继续刷流了 + if not self.__brush_site_torrents(siteid=site.id, torrent_tasks=torrent_tasks, + statistic_info=statistic_info, + subscribe_titles=subscribe_titles): + logger.info(f"站点 {site.name} 刷流中途结束,停止后续刷流") + break + else: + logger.info(f"站点 {site.name} 刷流完成") + + # 保存数据 + self.save_data("torrents", torrent_tasks) + # 保存统计数据 + self.save_data("statistic", statistic_info) + logger.info(f"刷流任务执行完成") + + def __brush_site_torrents(self, siteid, torrent_tasks: Dict[str, dict], statistic_info: Dict[str, int], + subscribe_titles: Set[str]) -> bool: + """ + 针对站点进行刷流 + """ + siteinfo = self.site_oper.get(siteid) + if not siteinfo: + logger.warning(f"站点不存在:{siteid}") + return True + + logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...") + torrents = self.torrents_chain.browse(domain=siteinfo.domain) + if not torrents: + logger.info(f"站点 {siteinfo.name} 没有获取到种子") + return True + + brush_config = self.__get_brush_config(sitename=siteinfo.name) + + if brush_config.site_hr_active: + logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子") + + # 排除包含订阅的种子 + if brush_config.except_subscribe: + torrents = self.__filter_torrents_contains_subscribe(torrents=torrents, subscribe_titles=subscribe_titles) + + # 按发布日期降序排列 + torrents.sort(key=lambda x: x.pubdate or '', reverse=True) + + torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info(f"正在准备种子刷流,数量 {len(torrents)}") + + # 过滤种子 + for torrent in torrents: + # 判断能否通过刷流前置条件 + pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False) + self.__log_brush_conditions(passed=pre_condition_passed, reason=reason) + if not pre_condition_passed: + return False + + logger.debug(f"种子详情:{torrent}") + + # 判断能否通过保种体积刷流条件 + size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size, + add_torrent_size=torrent.size) + self.__log_brush_conditions(passed=size_condition_passed, reason=reason, torrent=torrent) + if not size_condition_passed: + continue + + # 判断能否通过刷流条件 + condition_passed, reason = self.__evaluate_conditions_for_brush(torrent=torrent, + torrent_tasks=torrent_tasks) + self.__log_brush_conditions(passed=condition_passed, reason=reason, torrent=torrent) + if not condition_passed: + continue + + # 添加下载任务 + hash_string = self.__download(torrent=torrent) + if not hash_string: + logger.warning(f"{torrent.title} 添加刷流任务失败!") + continue + + # 触发刷流下载时间并保存任务信息 + torrent_task = { + "site": siteinfo.id, + "site_name": siteinfo.name, + "title": torrent.title, + "size": torrent.size, + "pubdate": torrent.pubdate, + # "site_cookie": torrent.site_cookie, + # "site_ua": torrent.site_ua, + # "site_proxy": torrent.site_proxy, + # "site_order": torrent.site_order, + "description": torrent.description, + "imdbid": torrent.imdbid, + # "enclosure": torrent.enclosure, + "page_url": torrent.page_url, + # "seeders": torrent.seeders, + # "peers": torrent.peers, + # "grabs": torrent.grabs, + "date_elapsed": torrent.date_elapsed, + "freedate": torrent.freedate, + "uploadvolumefactor": torrent.uploadvolumefactor, + "downloadvolumefactor": torrent.downloadvolumefactor, + "hit_and_run": torrent.hit_and_run or brush_config.site_hr_active, + "volume_factor": torrent.volume_factor, + "freedate_diff": torrent.freedate_diff, + # "labels": torrent.labels, + # "pri_order": torrent.pri_order, + # "category": torrent.category, + "ratio": 0, + "downloaded": 0, + "uploaded": 0, + "seeding_time": 0, + "deleted": False, + "time": time.time() + } + + self.eventmanager.send_event(etype=EventType.PluginTriggered, data={ + "plugin_id": self.__class__.__name__, + "event_name": "brushflow_download_added", + "hash": hash_string, + "data": torrent_task, + "downloader": self.service_info.name + }) + torrent_tasks[hash_string] = torrent_task + + # 统计数据 + torrents_size += torrent.size + statistic_info["count"] += 1 + logger.info(f"站点 {siteinfo.name},新增刷流种子下载:{torrent.title}|{torrent.description}") + self.__send_add_message(torrent) + + return True + + def __evaluate_size_condition_for_brush(self, torrents_size: float, + add_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]: + """ + 过滤体积不符合条件的种子 + """ + brush_config = self.__get_brush_config() + + # 如果没有明确指定增加的种子大小,则检查配置中是否有种子大小下限,如果有,使用这个大小作为增加的种子大小 + preset_condition = False + if not add_torrent_size and brush_config.size: + size_limits = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + add_torrent_size = size_limits[0] # 使用配置的种子大小下限 + preset_condition = True + + total_size = self.__bytes_to_gb(torrents_size + add_torrent_size) # 预计总做种体积 + + def generate_message(config): + if add_torrent_size: + if preset_condition: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子下限 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"刷流种子大小 {self.__bytes_to_gb(add_torrent_size):.1f} GB," + f"预计做种体积 {total_size:.1f} GB," + f"超过设定的保种体积 {config} GB") + else: + return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB," + f"超过设定的保种体积 {config} GB,暂时停止新增任务") + + reasons = [ + ("disksize", + lambda config: torrents_size + add_torrent_size > float(config) * 1024 ** 3, generate_message) + ] + + for condition, check, message in reasons: + config_value = getattr(brush_config, condition, None) + if config_value and check(config_value): + reason = message(config_value) + return False, reason + + return True, None + + def __evaluate_pre_conditions_for_brush(self, include_network_conditions: bool = True) \ + -> Tuple[bool, Optional[str]]: + """ + 前置过滤不符合条件的种子 + """ + reasons = [ + ("maxdlcount", lambda config: self.__get_downloading_count() >= int(config), + lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务") + ] + + if include_network_conditions: + # 获取平均带宽 + avg_upload_speed, avg_download_speed = self.__get_average_bandwidth() + if avg_upload_speed is not None and avg_download_speed is not None: + reasons.extend([ + ("maxupspeed", lambda config: avg_upload_speed >= float(config) * 1024, + lambda config: f"当前总上传带宽 {StringUtils.str_filesize(avg_upload_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ("maxdlspeed", lambda config: avg_download_speed >= float(config) * 1024, + lambda config: f"当前总下载带宽 {StringUtils.str_filesize(avg_download_speed)}," + f"已达到最大值 {config} KB/s,暂时停止新增任务"), + ]) + + brush_config = self.__get_brush_config() + for condition, check, message in reasons: + config_value = getattr(brush_config, condition, None) + if config_value and check(config_value): + reason = message(config_value) + return False, reason + + return True, None + + def __evaluate_conditions_for_brush(self, torrent, torrent_tasks) -> Tuple[bool, Optional[str]]: + """ + 过滤不符合条件的种子 + """ + brush_config = self.__get_brush_config(torrent.site_name) + + # 排除重复种子 + # 默认根据标题和站点名称进行排除 + task_key = f"{torrent.site_name}{torrent.title}" + if any(task_key == f"{task.get('site_name')}{task.get('title')}" for task in torrent_tasks.values()): + return False, "重复种子" + + # 部分站点标题会上新时携带后缀,这里进一步根据种子详情地址进行排除 + if torrent.page_url: + task_page_url = f"{torrent.site_name}{torrent.page_url}" + if any(task_page_url == f"{task.get('site_name')}{task.get('page_url')}" for task in + torrent_tasks.values()): + return False, "重复种子" + + # 不同站点如果遇到相同种子,判断前一个种子是否已经在做种,否则排除处理 + if torrent.title: + if any(torrent.site_name != f"{task.get('site_name')}" and torrent.title == f"{task.get('title')}" + and not task.get("seed_time") for task in torrent_tasks.values()): + return False, "其他站点存在尚未下载完成的相同种子" + + # 促销条件 + if brush_config.freeleech and torrent.downloadvolumefactor != 0: + return False, "非免费种子" + if brush_config.freeleech == "2xfree" and torrent.uploadvolumefactor != 2: + return False, "非双倍上传种子" + + # H&R + if brush_config.hr == "yes" and torrent.hit_and_run: + return False, "存在H&R" + + # 包含规则 + if brush_config.include and not ( + re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include, + torrent.description, re.I)): + return False, "不符合包含规则" + + # 排除规则 + if brush_config.exclude and ( + re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude, + torrent.description, re.I)): + return False, "符合排除规则" + + # 种子大小(GB) + if brush_config.size: + sizes = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")] + if len(sizes) == 1 and torrent.size < sizes[0]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不符合条件" + elif len(sizes) > 1 and not sizes[0] <= torrent.size <= sizes[1]: + return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不在指定范围内" + + # 做种人数 + if brush_config.seeder: + seeders_range = [float(n) for n in brush_config.seeder.split("-")] + # 检查是否仅指定了一个数字,即做种人数需要小于等于该数字 + if len(seeders_range) == 1: + # 当做种人数大于该数字时,不符合条件 + if torrent.seeders > seeders_range[0]: + return False, f"做种人数 {torrent.seeders},超过单个指定值" + # 如果指定了一个范围 + elif len(seeders_range) > 1: + # 检查做种人数是否在指定的范围内(包括边界) + if not (seeders_range[0] <= torrent.seeders <= seeders_range[1]): + return False, f"做种人数 {torrent.seeders},不在指定范围内" + + # 发布时间 + pubdate_minutes = self.__get_pubminutes(torrent.pubdate) + # 已支持独立站点配置,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配 + # pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent) + if brush_config.pubtime: + pubtimes = [float(n) for n in brush_config.pubtime.split("-")] + if len(pubtimes) == 1: + # 单个值:选择发布时间小于等于该值的种子 + if pubdate_minutes > pubtimes[0]: + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不符合条件" + else: + # 范围值:选择发布时间在范围内的种子 + if not (pubtimes[0] <= pubdate_minutes <= pubtimes[1]): + return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不在指定范围内" + + return True, None + + @staticmethod + def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None): + """ + 记录刷流日志 + """ + if not passed: + if not torrent: + logger.warning(f"没有通过前置刷流条件校验,原因:{reason}") + else: + logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}") + + # endregion + + # region Check + + def check(self): + """ + 定时检查,删除下载任务 + """ + if not self.__check_and_resolve_plugin_conflict(): + return + + brush_config = self.__get_brush_config() + + if not brush_config.downloader or not self.downloader: + return + + with lock: + logger.info("开始检查刷流下载任务 ...") + torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {} + unmanaged_tasks: Dict[str, dict] = self.get_data("unmanaged") or {} + + downloader = self.downloader + seeding_torrents, error = downloader.get_torrents() + if error: + logger.warning("连接下载器出错,将在下个时间周期重试") + return + + seeding_torrents_dict = {self.__get_hash(torrent): torrent for torrent in seeding_torrents} + + # 检查种子刷流标签变更情况 + self.__update_seeding_tasks_based_on_tags(torrent_tasks=torrent_tasks, unmanaged_tasks=unmanaged_tasks, + seeding_torrents_dict=seeding_torrents_dict) + + torrent_check_hashes = list(torrent_tasks.keys()) + if not torrent_tasks or not torrent_check_hashes: + logger.info("没有需要检查的刷流下载任务") + return + + logger.info(f"共有 {len(torrent_check_hashes)} 个任务正在刷流,开始检查任务状态") + + # 获取到当前所有做种数据中需要被检查的种子数据 + check_torrents = [seeding_torrents_dict[th] for th in torrent_check_hashes if th in seeding_torrents_dict] + + # 先更新刷流任务的最新状态,上下传,分享率 + self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks) + + # 更新刷流任务列表中在下载器中删除的种子为删除状态 + self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents) + + # 根据配置的标签进行种子排除 + if check_torrents: + logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除") + # 初始化一个空的列表来存储需要排除的标签 + tags_to_exclude = set() + # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中 + if brush_config.delete_except_tags and brush_config.delete_except_tags.strip(): + tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(',')) + # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔 + combined_tags = ",".join(tags_to_exclude) + if combined_tags: # 确保有标签需要排除 + pre_filter_count = len(check_torrents) # 获取过滤前的任务数量 + check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags) + post_filter_count = len(check_torrents) # 获取过滤后的任务数量 + excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量 + logger.info( + f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后," + f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}") + else: + logger.info("没有配置有效的排除标签,所有种子均参与后续处理") + + # 种子删除检查 + if not check_torrents: + logger.info("没有需要检查的任务,跳过") + else: + need_delete_hashes = [] + + # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理 + if brush_config.proxy_delete and brush_config.delete_size_range: + logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务") + proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(proxy_delete_hashes) + # 否则均认为是没有开启动态删种 + else: + logger.info("没有开启动态删种,按用户设置删种条件开始检查任务") + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + + if need_delete_hashes: + # 如果是QB,则重新汇报Tracker + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes) + # 删除种子 + if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True): + for torrent_hash in need_delete_hashes: + torrent_tasks[torrent_hash]["deleted"] = True + torrent_tasks[torrent_hash]["deleted_time"] = time.time() + + # 归档数据 + self.__auto_archive_tasks(torrent_tasks=torrent_tasks) + + self.__update_and_save_statistic_info(torrent_tasks) + + self.save_data("torrents", torrent_tasks) + + logger.info("刷流下载任务检查完成") + + def __update_torrent_tasks_state(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 更新刷流任务的最新状态,上下传,分享率 + """ + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + torrent_info = self.__get_torrent_info(torrent) + + # 更新上传量、下载量 + torrent_task.update({ + "downloaded": torrent_info.get("downloaded"), + "uploaded": torrent_info.get("uploaded"), + "ratio": torrent_info.get("ratio"), + "seeding_time": torrent_info.get("seeding_time"), + }) + + def __update_seeding_tasks_based_on_tags(self, torrent_tasks: Dict[str, dict], unmanaged_tasks: Dict[str, dict], + seeding_torrents_dict: Dict[str, Any]): + brush_config = self.__get_brush_config() + + if not self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + logger.info("同步种子刷流标签记录目前仅支持qbittorrent") + return + + # 初始化汇总信息 + added_tasks = [] + reset_tasks = [] + removed_tasks = [] + # 基于 seeding_torrents_dict 的信息更新或添加到 torrent_tasks + for torrent_hash, torrent in seeding_torrents_dict.items(): + tags = self.__get_label(torrent=torrent) + # 判断是否包含刷流标签 + if brush_config.brush_tag in tags: + # 如果包含刷流标签又不在刷流任务中,则需要加入管理 + if torrent_hash not in torrent_tasks: + # 检查该种子是否在 unmanaged_tasks 中 + if torrent_hash in unmanaged_tasks: + # 如果在 unmanaged_tasks 中,移除并转移到 torrent_tasks + torrent_task = unmanaged_tasks.pop(torrent_hash) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子再次加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 否则,创建一个新的任务 + torrent_task = self.__convert_torrent_info_to_task(torrent) + torrent_tasks[torrent_hash] = torrent_task + added_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子加入:{torrent_task.get('title')}|{torrent_task.get('description')}") + # 包含刷流标签又在刷流任务中,这里额外处理一个特殊逻辑,就是种子在刷流任务中可能被标记删除但实际上又还在下载器中,这里进行重置 + else: + torrent_task = torrent_tasks[torrent_hash] + if torrent_task.get("deleted"): + torrent_task["deleted"] = False + reset_tasks.append(torrent_task) + logger.info( + f"站点 {torrent_task.get('site_name')},在下载器中找到已标记删除的刷流任务对应的种子信息," + f"更新刷流任务状态为正常:{torrent_task.get('title')}|{torrent_task.get('description')}") + else: + # 不包含刷流标签但又在刷流任务中,则移除管理 + if torrent_hash in torrent_tasks: + # 如果种子不符合刷流条件但在 torrent_tasks 中,移除并加入 unmanaged_tasks + torrent_task = torrent_tasks.pop(torrent_hash) + unmanaged_tasks[torrent_hash] = torrent_task + removed_tasks.append(torrent_task) + logger.info(f"站点 {torrent_task.get('site_name')}," + f"刷流任务种子移除:{torrent_task.get('title')}|{torrent_task.get('description')}") + + self.save_data("torrents", torrent_tasks) + self.save_data("unmanaged", unmanaged_tasks) + + # 发送汇总消息 + if added_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子加入】", status="纳入刷流管理", + reason="刷流标签添加", torrent_tasks=added_tasks) + if removed_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务种子移除】", status="移除刷流管理", + reason="刷流标签移除", torrent_tasks=removed_tasks) + if reset_tasks: + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为正常", + reason="在下载器中找到已标记删除的刷流任务对应的种子信息", + torrent_tasks=reset_tasks) + + def __group_torrents_by_proxy_delete(self, torrents: List[Any], torrent_tasks: Dict[str, dict]): + """ + 根据是否启用动态删种进行分组 + """ + proxy_delete_torrents = [] + not_proxy_delete_torrents = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + site_name = torrent_task.get("site_name", "") + + brush_config = self.__get_brush_config(site_name) + if brush_config.proxy_delete: + proxy_delete_torrents.append(torrent) + else: + not_proxy_delete_torrents.append(torrent) + + return proxy_delete_torrents, not_proxy_delete_torrents + + def __evaluate_conditions_for_delete(self, site_name: str, torrent_info: dict, torrent_task: dict) \ + -> Tuple[bool, str]: + """ + 评估删除条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足设置的删除条件" + + # 当配置了H&R做种时间/分享率时,则H&R种子只有达到预期行为时,才会进行删除,如果没有配置H&R做种时间/分享率,则普通种子的删除规则也适用于H&R种子 + # 判断是否为H&R种子并且是否配置了特定的H&R条件 + hit_and_run = torrent_task.get("hit_and_run", False) + hr_specific_conditions_configured = hit_and_run and (brush_config.hr_seed_time or brush_config.seed_ratio) + if hr_specific_conditions_configured: + if (brush_config.hr_seed_time and torrent_info.get("seeding_time") + >= float(brush_config.hr_seed_time) * 3600): + return True, (f"H&R种子,做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时," + f"大于 {brush_config.hr_seed_time} 小时") + if brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + return True, f"H&R种子,分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + return False, "H&R种子,未能满足设置的H&R删除条件" + + # 处理其他场景,1. 不是H&R种子;2. 是H&R种子但没有特定条件配置 + reason = reason if not hit_and_run else "H&R种子(未设置H&R条件),未能满足设置的删除条件" + if brush_config.seed_time and torrent_info.get("seeding_time") >= float(brush_config.seed_time) * 3600: + reason = f"做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时,大于 {brush_config.seed_time} 小时" + elif brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio): + reason = f"分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}" + elif brush_config.seed_size and torrent_info.get("uploaded") >= float(brush_config.seed_size) * 1024 ** 3: + reason = f"上传量 {torrent_info.get('uploaded') / 1024 ** 3:.1f} GB,大于 {brush_config.seed_size} GB" + elif brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get( + "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600: + reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时" + elif brush_config.seed_avgspeed and torrent_info.get("avg_upspeed") <= float( + brush_config.seed_avgspeed) * 1024 and torrent_info.get("seeding_time") >= 30 * 60: + reason = f"平均上传速度 {torrent_info.get('avg_upspeed') / 1024:.1f} KB/s,低于 {brush_config.seed_avgspeed} KB/s" + elif brush_config.seed_inactivetime and torrent_info.get("iatime") >= float( + brush_config.seed_inactivetime) * 60: + reason = f"未活动时间 {torrent_info.get('iatime') / 60:.0f} 分钟,大于 {brush_config.seed_inactivetime} 分钟" + else: + return False, reason + + return True, reason if not hit_and_run else "H&R种子(未设置H&R条件)," + reason + + def __evaluate_proxy_pre_conditions_for_delete(self, site_name: str, torrent_info: dict) -> Tuple[bool, str]: + """ + 评估动态删除前置条件并返回是否应删除种子及其原因 + """ + brush_config = self.__get_brush_config(sitename=site_name) + + reason = "未能满足动态删除设置的前置删除条件" + + if brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get( + "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600: + reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时" + else: + return False, reason + + return True, reason + + def __delete_torrent_for_evaluate_conditions(self, torrents: List[Any], torrent_tasks: Dict[str, dict], + proxy_delete: bool = False) -> List: + """ + 根据条件删除种子并获取已删除列表 + """ + delete_hashes = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + + torrent_info = self.__get_torrent_info(torrent) + + # 删除种子的具体实现可能会根据实际情况略有不同 + should_delete, reason = self.__evaluate_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info, + torrent_task=torrent_task) + if should_delete: + delete_hashes.append(torrent_hash) + reason = "触发动态删除阈值," + reason if proxy_delete else reason + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + else: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + def __delete_torrent_for_evaluate_proxy_pre_conditions(self, torrents: List[Any], + torrent_tasks: Dict[str, dict]) -> List: + """ + 根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表 + """ + delete_hashes = [] + + for torrent in torrents: + torrent_hash = self.__get_hash(torrent) + torrent_task = torrent_tasks.get(torrent_hash, None) + # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过 + if not torrent_task: + continue + + # 如果是H&R种子,前置条件中不进行处理 + if torrent_task.get('hit_and_run', False): + continue + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + + torrent_info = self.__get_torrent_info(torrent) + + # 删除种子的具体实现可能会根据实际情况略有不同 + should_delete, reason = self.__evaluate_proxy_pre_conditions_for_delete(site_name=site_name, + torrent_info=torrent_info) + if should_delete: + delete_hashes.append(torrent_hash) + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + else: + logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}") + + return delete_hashes + + def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List: + """ + 动态删除种子,删除规则如下; + - 不管做种体积是否超过设定的动态删除阈值,默认优先执行排除H&R种子后满足「下载超时时间」的种子 + - 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则 + - 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除 + - 若删除后还没有达到阈值,则在已完成种子中排除H&R种子后按做种时间倒序进行删除 + - 动态删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G + - 动态删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G + """ + brush_config = self.__get_brush_config() + + # 如果没有启用动态删除或没有设置删除阈值,则不执行删除操作 + if not (brush_config.proxy_delete and brush_config.delete_size_range): + return [] + + # 获取种子信息Map + torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents} + + # 计算当前总做种体积 + total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks) + + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,正在准备计算满足动态前置删除条件的种子") + + # 执行排除H&R种子后满足前置删除条件的种子 + pre_delete_hashes = self.__delete_torrent_for_evaluate_proxy_pre_conditions(torrents=torrents, + torrent_tasks=torrent_tasks) or [] + + # 如果存在前置删除种子,这里进行额外判断,总做种体积排除前置删除种子的体积 + if pre_delete_hashes: + pre_delete_total_size = sum(torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) + for torrent in torrents if self.__get_hash(torrent) in pre_delete_hashes) + total_torrent_size = total_torrent_size - pre_delete_total_size + torrents = [torrent for torrent in torrents if self.__get_hash(torrent) not in pre_delete_hashes] + logger.info( + f"满足动态删除前置条件的种子共 {len(pre_delete_hashes)} 个,体积 {self.__bytes_to_gb(pre_delete_total_size):.1f} GB," + f"删除种子后,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + else: + logger.info(f"没有找到任何满足动态删除前置条件的种子") + + # 解析删除阈值范围 + sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")] + min_size = sizes[0] # 至少需要达到的做种体积 + max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的做种体积上限 + + # 判断是否为区间删除 + proxy_size_range = len(sizes) > 1 + + # 当总体积未超过最大阈值时,不需要执行删除操作 + if total_torrent_size < max_size: + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,未进一步触发动态删除") + return pre_delete_hashes or [] + else: + logger.info( + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB," + f"下限 {self.__bytes_to_gb(min_size):.1f} GB,进一步触发动态删除") + + need_delete_hashes = [] + need_delete_hashes.extend(pre_delete_hashes) + + # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除 + proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents, + torrent_tasks=torrent_tasks) + logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}") + if not_proxy_delete_torrents: + not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=not_proxy_delete_torrents, + torrent_tasks=torrent_tasks) or [] + need_delete_hashes.extend(not_proxy_delete_hashes) + total_torrent_size -= sum( + torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in not_proxy_delete_torrents + if self.__get_hash(torrent) in not_proxy_delete_hashes) + + # 如果删除非托管种子后仍未达到最小体积要求,则处理托管种子 + if total_torrent_size > min_size and proxy_delete_torrents: + proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=proxy_delete_torrents, + torrent_tasks=torrent_tasks, + proxy_delete=True) or [] + need_delete_hashes.extend(proxy_delete_hashes) + total_torrent_size -= sum( + torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in proxy_delete_torrents if + self.__get_hash(torrent) in proxy_delete_hashes) + + # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按做种时间正序进行删除 + if total_torrent_size > min_size: + # 重新计算当前的种子列表,排除已删除的种子 + remaining_hashes = list( + {self.__get_hash(torrent) for torrent in proxy_delete_torrents} - set(need_delete_hashes)) + # 这里根据排除后的种子列表,再次从下载器中找到已完成的任务 + downloader = self.downloader + completed_torrents = downloader.get_completed_torrents(ids=remaining_hashes) + remaining_hashes = {self.__get_hash(torrent) for torrent in completed_torrents} + remaining_torrents = [(_hash, torrent_info_map[_hash]) for _hash in remaining_hashes] + + # 准备一个列表,用于存放满足条件的种子,即非HR种子且有明确做种时间 + filtered_torrents = [(_hash, info['seeding_time']) for _hash, info in remaining_torrents if + not torrent_tasks[_hash].get("hit_and_run", False)] + sorted_torrents = sorted(filtered_torrents, key=lambda x: x[1], reverse=True) + + # 进行额外的删除操作,直到满足最小阈值或没有更多种子可删除 + for torrent_hash, _ in sorted_torrents: + if total_torrent_size <= min_size: + break + torrent_task = torrent_tasks.get(torrent_hash, None) + torrent_info = torrent_info_map.get(torrent_hash, None) + if not torrent_task or not torrent_info: + continue + + need_delete_hashes.append(torrent_hash) + total_torrent_size -= torrent_info.get("total_size", 0) + + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + seeding_time = torrent_task.get("seeding_time", 0) + if seeding_time: + reason = (f"触发动态删除阈值,系统自动删除,做种时间 {seeding_time / 3600:.1f} 小时," + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB") + # 如果是区间删除,一次性删除的数据过多,取消消息推送 + if not proxy_size_range: + self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, + torrent_desc=torrent_desc, + reason=reason) + logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}") + + delete_sites = {torrent_tasks[hash_key].get('site_name', '') for hash_key in need_delete_hashes if + hash_key in torrent_tasks} + msg = (f"站点:{','.join(delete_sites)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除," + f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB\n原因:触发动态删除阈值,系统自动删除") + logger.info(msg) + + # 如果是区间删除,这里则进行统一推送 + if proxy_size_range: + self.__send_message(title="【刷流任务种子删除】", text=msg) + + # 返回所有需要删除的种子的哈希列表 + return need_delete_hashes + + def __update_undeleted_torrents_missing_in_downloader(self, torrent_tasks, torrent_check_hashes, torrents): + """ + 处理已经被删除,但是任务记录中还没有被标记删除的种子 + """ + # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子 + torrent_all_hashes = self.__get_all_hashes(torrents) + missing_hashes = [hash_value for hash_value in torrent_check_hashes if hash_value not in torrent_all_hashes] + undeleted_hashes = [hash_value for hash_value in missing_hashes if not torrent_tasks[hash_value].get("deleted")] + + if not undeleted_hashes: + return + + # 初始化汇总信息 + delete_tasks = [] + for hash_value in undeleted_hashes: + # 获取对应的任务信息 + torrent_task = torrent_tasks[hash_value] + # 标记为已删除 + torrent_task["deleted"] = True + torrent_task["deleted_time"] = time.time() + # 处理日志相关内容 + delete_tasks.append(torrent_task) + site_name = torrent_task.get("site_name", "") + torrent_title = torrent_task.get("title", "") + torrent_desc = torrent_task.get("description", "") + logger.info( + f"站点:{site_name},无法在下载器中找到对应种子信息,更新刷流任务状态为已删除,种子:{torrent_title}|{torrent_desc}") + + self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为已删除", + reason="无法在下载器中找到对应的种子信息", + torrent_tasks=delete_tasks) + + def __convert_torrent_info_to_task(self, torrent: Any) -> dict: + """ + 根据torrent_info转换成torrent_task + """ + torrent_info = self.__get_torrent_info(torrent=torrent) + + site_id, site_name = self.__get_site_by_torrent(torrent=torrent) + + torrent_task = { + "site": site_id, + "site_name": site_name, + "title": torrent_info.get("title", ""), + "size": torrent_info.get("total_size", 0), # 假设total_size对应于size + "pubdate": None, + "description": None, + "imdbid": None, + "page_url": None, + "date_elapsed": None, + "freedate": None, + "uploadvolumefactor": None, + "downloadvolumefactor": None, + "hit_and_run": None, + "volume_factor": None, + "freedate_diff": None, # 假设无法从torrent_info直接获取 + "ratio": torrent_info.get("ratio", 0), + "downloaded": torrent_info.get("downloaded", 0), + "uploaded": torrent_info.get("uploaded", 0), + "deleted": False, + "time": torrent_info.get("add_on", time.time()) + } + return torrent_task + + # endregion + + def __update_and_save_statistic_info(self, torrent_tasks): + """ + 更新并保存统计信息 + """ + total_count, total_uploaded, total_downloaded, total_deleted = 0, 0, 0, 0 + active_uploaded, active_downloaded, active_count, total_unarchived = 0, 0, 0, 0 + + statistic_info = self.__get_statistic_info() + archived_tasks = self.get_data("archived") or {} + combined_tasks = {**torrent_tasks, **archived_tasks} + + for task in combined_tasks.values(): + if task.get("deleted", False): + total_deleted += 1 + total_downloaded += task.get("downloaded", 0) + total_uploaded += task.get("uploaded", 0) + + # 计算torrent_tasks中未标记为删除的活跃任务的统计信息,及待归档的任务数 + for task in torrent_tasks.values(): + if not task.get("deleted", False): + active_uploaded += task.get("uploaded", 0) + active_downloaded += task.get("downloaded", 0) + active_count += 1 + else: + total_unarchived += 1 + + # 更新统计信息 + total_count = len(combined_tasks) + statistic_info.update({ + "uploaded": total_uploaded, + "downloaded": total_downloaded, + "deleted": total_deleted, + "unarchived": total_unarchived, + "count": total_count, + "active": active_count, + "active_uploaded": active_uploaded, + "active_downloaded": active_downloaded + }) + + logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted}," + f"待归档:{total_unarchived}," + f"活跃上传量:{StringUtils.str_filesize(active_uploaded)}," + f"活跃下载量:{StringUtils.str_filesize(active_downloaded)}," + f"总上传量:{StringUtils.str_filesize(total_uploaded)}," + f"总下载量:{StringUtils.str_filesize(total_downloaded)}") + + self.save_data("statistic", statistic_info) + self.save_data("torrents", torrent_tasks) + + def __get_brush_config(self, sitename: str = None) -> BrushConfig: + """ + 获取BrushConfig + """ + return self._brush_config if not sitename else self._brush_config.get_site_config(sitename=sitename) + + def __validate_and_fix_config(self, config: dict = None) -> bool: + """ + 检查并修正配置值 + """ + if config is None: + logger.error("配置为None,无法验证和修正") + return False + + # 设置一个标志,用于跟踪是否发现校验错误 + found_error = False + + config_number_attr_to_desc = { + "disksize": "保种体积", + "maxupspeed": "总上传带宽", + "maxdlspeed": "总下载带宽", + "maxdlcount": "同时下载任务数", + "seed_time": "做种时间", + "hr_seed_time": "H&R做种时间", + "seed_ratio": "分享率", + "seed_size": "上传量", + "download_time": "下载超时时间", + "seed_avgspeed": "平均上传速度", + "seed_inactivetime": "未活动时间", + "up_speed": "单任务上传限速", + "dl_speed": "单任务下载限速", + "auto_archive_days": "自动清理记录天数" + } + + config_range_number_attr_to_desc = { + "pubtime": "发布时间", + "size": "种子大小", + "seeder": "做种人数", + "delete_size_range": "动态删种阈值" + } + + for attr, desc in config_number_attr_to_desc.items(): + value = config.get(attr) + if value and not self.__is_number(value): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + for attr, desc in config_range_number_attr_to_desc.items(): + value = config.get(attr) + # 检查 value 是否存在且是否符合数字或数字-数字的模式 + if value and not self.__is_number_or_range(str(value)): + self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}") + config[attr] = None + found_error = True # 更新错误标志 + + active_time_range = config.get("active_time_range") + if active_time_range and not self.__is_valid_time_range(time_range=active_time_range): + self.__log_and_notify_error(f"站点刷流任务出错,开启时间段设置错误:{active_time_range}") + config["active_time_range"] = None + found_error = True # 更新错误标志 + + # 如果发现任何错误,返回False;否则返回True + return not found_error + + def __update_config(self, brush_config: BrushConfig = None): + """ + 根据传入的BrushConfig实例更新配置 + """ + if brush_config is None: + brush_config = self._brush_config + + if brush_config is None: + return + + # 创建一个将配置属性名称映射到BrushConfig属性值的字典 + config_mapping = { + "onlyonce": brush_config.onlyonce, + "enabled": brush_config.enabled, + "notify": brush_config.notify, + "brushsites": brush_config.brushsites, + "downloader": brush_config.downloader, + "disksize": brush_config.disksize, + "freeleech": brush_config.freeleech, + "hr": brush_config.hr, + "maxupspeed": brush_config.maxupspeed, + "maxdlspeed": brush_config.maxdlspeed, + "maxdlcount": brush_config.maxdlcount, + "include": brush_config.include, + "exclude": brush_config.exclude, + "size": brush_config.size, + "seeder": brush_config.seeder, + "pubtime": brush_config.pubtime, + "seed_time": brush_config.seed_time, + "hr_seed_time": brush_config.hr_seed_time, + "seed_ratio": brush_config.seed_ratio, + "seed_size": brush_config.seed_size, + "download_time": brush_config.download_time, + "seed_avgspeed": brush_config.seed_avgspeed, + "seed_inactivetime": brush_config.seed_inactivetime, + "delete_size_range": brush_config.delete_size_range, + "up_speed": brush_config.up_speed, + "dl_speed": brush_config.dl_speed, + "auto_archive_days": brush_config.auto_archive_days, + "save_path": brush_config.save_path, + "clear_task": brush_config.clear_task, + "delete_except_tags": brush_config.delete_except_tags, + "except_subscribe": brush_config.except_subscribe, + "brush_sequential": brush_config.brush_sequential, + "proxy_delete": brush_config.proxy_delete, + "active_time_range": brush_config.active_time_range, + "cron": brush_config.cron, + "qb_category": brush_config.qb_category, + "enable_site_config": brush_config.enable_site_config, + "site_config": brush_config.site_config, + "_tabs": self._tabs + } + + # 使用update_config方法或其等效方法更新配置 + self.update_config(config_mapping) + + @staticmethod + def __get_redict_url(url: str, proxies: str = None, ua: str = None, cookie: str = None) -> Optional[str]: + """ + 获取下载链接, url格式:[base64]url + """ + # 获取[]中的内容 + m = re.search(r"\[(.*)](.*)", url) + if m: + # 参数 + base64_str = m.group(1) + # URL + url = m.group(2) + if not base64_str: + return url + # 解码参数 + req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8') + req_params: Dict[str, dict] = json.loads(req_str) + # 是否使用cookie + if not req_params.get('cookie'): + cookie = None + # 请求头 + if req_params.get('header'): + headers = req_params.get('header') + else: + headers = None + if req_params.get('method') == 'get': + # GET请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).get_res(url, params=req_params.get('params')) + else: + # POST请求 + res = RequestUtils( + ua=ua, + proxies=proxies, + cookies=cookie, + headers=headers + ).post_res(url, params=req_params.get('params')) + if not res: + return None + if not req_params.get('result'): + return res.text + else: + data = res.json() + for key in str(req_params.get('result')).split("."): + data = data.get(key) + if not data: + return None + logger.debug(f"获取到下载地址:{data}") + return data + return None + + def __reset_download_url(self, torrent_url, site_id) -> str: + """ + 处理下载地址 + """ + try: + # 检查 torrent_url 是否为有效的下载 URL,并且 site 是 NexusPHP + if not torrent_url or torrent_url.startswith("magnet"): + return torrent_url + + indexers = self.sites_helper.get_indexers() + if not indexers: + return torrent_url + + unsupported_sites = {"天空"} + site = next((item for item in indexers if item.get("id") == site_id), None) + if site.get("name") in unsupported_sites or not site.get("schema", "").startswith("Nexus"): + return torrent_url + + # 解析 URL + parsed_url = urlparse(torrent_url) + + # 如果 URL 中已有查询参数,使用 urlencode 进行拼接 + query_params = dict(parse_qsl(parsed_url.query)) + query_params["letdown"] = "1" + + # 重新构造带有新参数的 URL + new_query = urlencode(query_params) + new_url = str(urlunparse(parsed_url._replace(query=new_query))) + return new_url + except Exception as e: + logger.error(f"Error while resetting downloader URL for torrent: {torrent_url}. Error: {str(e)}") + return torrent_url + + def __download(self, torrent: TorrentInfo) -> Optional[str]: + """ + 添加下载任务 + """ + if not torrent.enclosure: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + brush_config = self.__get_brush_config(torrent.site_name) + + # 上传限速 + up_speed = int(brush_config.up_speed) if brush_config.up_speed else None + # 下载限速 + down_speed = int(brush_config.dl_speed) if brush_config.dl_speed else None + # 保存地址 + download_dir = brush_config.save_path or None + # 获取下载链接 + torrent_content = torrent.enclosure + # proxies + proxies = settings.PROXY if torrent.site_proxy else None + # cookie + cookies = torrent.site_cookie + if torrent_content.startswith("["): + torrent_content = self.__get_redict_url(url=torrent_content, + proxies=proxies, + ua=torrent.site_ua, + cookie=cookies) + # 目前馒头请求实际种子时,不能传入Cookie + cookies = None + if not torrent_content: + logger.error(f"获取下载链接失败:{torrent.title}") + return None + + if brush_config.site_skip_tips: + torrent_content = self.__reset_download_url(torrent_url=torrent_content, site_id=torrent.site) + logger.debug(f"站点 {torrent.site_name} 已启用自动跳过提示,种子下载地址更新为 {torrent_content}") + + downloader = self.downloader + if not downloader: + return None + + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + # 限速值转为bytes + up_speed = up_speed * 1024 if up_speed else None + down_speed = down_speed * 1024 if down_speed else None + # 生成随机Tag + tag = StringUtils.generate_random_str(10) + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + state = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + category=brush_config.qb_category, + tag=["已整理", brush_config.brush_tag, tag], + upload_limit=up_speed, + download_limit=down_speed) + if not state: + return None + else: + # 获取种子Hash + torrent_hash = downloader.get_torrent_id_by_tag(tags=tag) + if not torrent_hash: + logger.error(f"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README") + return None + return torrent_hash + return None + + elif self.downloader_helper.is_downloader("transmission", service=self.service_info): + # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器 + if not torrent_content.startswith("magnet"): + response = RequestUtils(cookies=cookies, + proxies=proxies, + ua=torrent.site_ua).get_res(url=torrent_content) + if response and response.ok: + torrent_content = response.content + else: + logger.error("尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载") + if torrent_content: + torrent = downloader.add_torrent(content=torrent_content, + download_dir=download_dir, + cookie=cookies, + labels=["已整理", brush_config.brush_tag]) + if not torrent: + return None + else: + if brush_config.up_speed or brush_config.dl_speed: + downloader.change_torrent(hash_string=torrent.hashString, + upload_limit=up_speed, + download_limit=down_speed) + return torrent.hashString + return None + + def __qb_torrents_reannounce(self, torrent_hashes: List[str]): + """强制重新汇报""" + downloader = self.downloader + if not downloader: + return + + if not downloader.qbc: + return + + if not torrent_hashes: + return + + try: + # 重新汇报 + downloader.qbc.torrents_reannounce(torrent_hashes=torrent_hashes) + except Exception as err: + logger.error(f"强制重新汇报失败:{str(err)}") + + def __get_hash(self, torrent: Any): + """ + 获取种子hash + """ + try: + return torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info) \ + else torrent.hashString + except Exception as e: + print(str(e)) + return "" + + def __get_all_hashes(self, torrents): + """ + 获取torrents列表中所有种子的Hash值 + + :param torrents: 包含种子信息的列表 + :return: 包含所有Hash值的列表 + """ + try: + all_hashes = [] + for torrent in torrents: + # 根据下载器类型获取Hash值 + hash_value = torrent.get("hash") if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) \ + else torrent.hashString + if hash_value: + all_hashes.append(hash_value) + return all_hashes + except Exception as e: + print(str(e)) + return [] + + def __get_label(self, torrent: Any): + """ + 获取种子标签 + """ + try: + return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ + if self.downloader_helper.is_downloader("qbittorrent", + service=self.service_info) else torrent.labels or [] + except Exception as e: + print(str(e)) + return [] + + def __get_torrent_info(self, torrent: Any) -> dict: + """ + 获取种子信息 + """ + date_now = int(time.time()) + # QB + if self.downloader_helper.is_downloader("qbittorrent", service=self.service_info): + """ + { + "added_on": 1693359031, + "amount_left": 0, + "auto_tmm": false, + "availability": -1, + "category": "tJU", + "completed": 67759229411, + "completion_on": 1693609350, + "content_path": "/mnt/sdb/qb/downloads/Steel.Division.2.Men.of.Steel-RUNE", + "dl_limit": -1, + "dlspeed": 0, + "download_path": "", + "downloaded": 67767365851, + "downloaded_session": 0, + "eta": 8640000, + "f_l_piece_prio": false, + "force_start": false, + "hash": "116bc6f3efa6f3b21a06ce8f1cc71875", + "infohash_v1": "116bc6f306c40e072bde8f1cc71875", + "infohash_v2": "", + "last_activity": 1693609350, + "magnet_uri": "magnet:?xt=", + "max_ratio": -1, + "max_seeding_time": -1, + "name": "Steel.Division.2.Men.of.Steel-RUNE", + "num_complete": 1, + "num_incomplete": 0, + "num_leechs": 0, + "num_seeds": 0, + "priority": 0, + "progress": 1, + "ratio": 0, + "ratio_limit": -2, + "save_path": "/mnt/sdb/qb/downloads", + "seeding_time": 615035, + "seeding_time_limit": -2, + "seen_complete": 1693609350, + "seq_dl": false, + "size": 67759229411, + "state": "stalledUP", + "super_seeding": false, + "tags": "", + "time_active": 865354, + "total_size": 67759229411, + "tracker": "https://tracker", + "trackers_count": 2, + "up_limit": -1, + "uploaded": 0, + "uploaded_session": 0, + "upspeed": 0 + } + """ + # ID + torrent_id = torrent.get("hash") + # 标题 + torrent_title = torrent.get("name") + # 下载时间 + if (not torrent.get("added_on") + or torrent.get("added_on") < 0): + dltime = 0 + else: + dltime = date_now - torrent.get("added_on") + # 做种时间 + if (not torrent.get("completion_on") + or torrent.get("completion_on") < 0): + seeding_time = 0 + else: + seeding_time = date_now - torrent.get("completion_on") + # 分享率 + ratio = torrent.get("ratio") or 0 + # 上传量 + uploaded = torrent.get("uploaded") or 0 + # 平均上传速度 Byte/s + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 已未活动 秒 + if (not torrent.get("last_activity") + or torrent.get("last_activity") < 0): + iatime = 0 + else: + iatime = date_now - torrent.get("last_activity") + # 下载量 + downloaded = torrent.get("downloaded") + # 种子大小 + total_size = torrent.get("total_size") + # 添加时间 + add_on = (torrent.get("added_on") or 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") + # tracker + tracker = torrent.get("tracker") + # TR + else: + # ID + torrent_id = torrent.hashString + # 标题 + torrent_title = torrent.name + done_date = getattr(torrent, "date_done", None) or getattr(torrent, "done_date", None) + added_date = getattr(torrent, "date_added", None) or getattr(torrent, "added_date", None) + active_date = getattr(torrent, "date_active", None) or getattr(torrent, "activity_date", None) + # 做种时间 + if (not done_date + or done_date.timestamp() < 1): + seeding_time = 0 + else: + seeding_time = date_now - int(done_date.timestamp()) + # 下载耗时 + if (not added_date + or added_date.timestamp() < 1): + dltime = 0 + else: + dltime = date_now - int(added_date.timestamp()) + # 下载量 + downloaded = int(torrent.total_size * torrent.progress / 100) + # 分享率 + ratio = torrent.ratio or 0 + # 上传量 + uploaded = int(downloaded * torrent.ratio) + # 平均上传速度 + if dltime: + avg_upspeed = int(uploaded / dltime) + else: + avg_upspeed = uploaded + # 未活动时间 + if (not active_date + or active_date.timestamp() < 1): + iatime = 0 + else: + iatime = date_now - int(active_date.timestamp()) + # 种子大小 + total_size = torrent.total_size + # 添加时间 + add_on = (added_date.timestamp() if added_date else 0) + add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on)) + # 种子标签 + tags = torrent.get("tags") or getattr(torrent, "labels", None) or [] + # tracker + trackers = getattr(torrent, "trackers", None) or [] + tracker = torrent.get("tracker") or (getattr(trackers[0], "announce", "") if trackers else "") + + return { + "hash": torrent_id, + "title": torrent_title, + "seeding_time": seeding_time, + "ratio": ratio, + "uploaded": uploaded, + "downloaded": downloaded, + "avg_upspeed": avg_upspeed, + "iatime": iatime, + "dltime": dltime, + "total_size": total_size, + "add_time": add_time, + "add_on": add_on, + "tags": tags, + "tracker": tracker + } + + def __log_and_notify_error(self, message): + """ + 记录错误日志并发送系统通知 + """ + logger.error(message) + self.systemmessage.put(message, title="站点刷流(低频版)") + + def __send_delete_message(self, site_name: str, torrent_title: str, torrent_desc: str, reason: str, + title: str = "【刷流任务种子删除】"): + """ + 发送删除种子的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + msg_text = "" + if site_name: + msg_text = f"站点:{site_name}" + if torrent_title: + msg_text = f"{msg_text}\n标题:{torrent_title}" + if torrent_desc: + msg_text = f"{msg_text}\n内容:{torrent_desc}" + if reason: + msg_text = f"{msg_text}\n原因:{reason}" + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + @staticmethod + def __build_add_message_text(torrent): + """ + 构建消息文本,兼容TorrentInfo对象和torrent_task字典 + """ + + # 定义一个辅助函数来统一获取数据的方式 + def get_data(_key, default=None): + if isinstance(torrent, dict): + return torrent.get(_key, default) + else: + return getattr(torrent, _key, default) + + # 构造消息文本,确保使用中文标签 + msg_parts = [] + label_mapping = { + "site_name": "站点", + "title": "标题", + "description": "内容", + "size": "大小", + "pubdate": "发布时间", + "seeders": "做种数", + "volume_factor": "促销", + "hit_and_run": "Hit&Run" + } + for key in label_mapping: + value = get_data(key) + if key == "size" and value and str(value).replace(".", "", 1).isdigit(): + value = StringUtils.str_filesize(value) + if value: + msg_parts.append(f"{label_mapping[key]}:{'是' if key == 'hit_and_run' and value else value}") + + return "\n".join(msg_parts) + + def __send_add_message(self, torrent, title: str = "【刷流任务种子下载】"): + """ + 发送添加下载的消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + # 使用辅助方法构建消息文本 + msg_text = self.__build_add_message_text(torrent) + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text) + + def __send_message(self, title: str, text: str): + """ + 发送消息 + """ + brush_config = self.__get_brush_config() + if not brush_config.notify: + return + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + + def __log_and_send_torrent_task_update_message(self, title: str, status: str, reason: str, + torrent_tasks: List[dict]): + """ + 记录和发送刷流任务更新消息 + """ + if torrent_tasks: + sites_names = ', '.join({task.get("site_name", "N/A") for task in torrent_tasks}) + first_title = torrent_tasks[0].get('title', 'N/A') + count = len(torrent_tasks) + msg = f"站点:{sites_names}\n内容:{first_title} 等 {count} 个种子已经{status}\n原因:{reason}" + logger.info(f"{title},{msg}") + self.__send_message(title=title, text=msg) + + def __get_torrents_size(self) -> int: + """ + 获取任务中的种子总大小 + """ + # 读取种子记录 + task_info = self.get_data("torrents") or {} + if not task_info: + return 0 + total_size = sum([task.get("size") or 0 for task in task_info.values()]) + return total_size + + def __get_average_bandwidth(self, sample_count: int = 5, interval: float = 3.0) \ + -> Tuple[Optional[float], Optional[float]]: + """ + 多次采样上传和下载带宽,取平均值 + """ + upload_speeds = [] + download_speeds = [] + start_time = time.time() + for _ in range(sample_count): + downloader_info = self.__get_downloader_info() + if downloader_info: + upload_speeds.append(downloader_info.upload_speed or 0) + download_speeds.append(downloader_info.download_speed or 0) + # 采样间隔 + time.sleep(interval) + end_time = time.time() + total_duration = end_time - start_time + if not upload_speeds or not download_speeds: + return None, None + avg_upload_speed = sum(upload_speeds) / len(upload_speeds) if upload_speeds else 0 + avg_download_speed = sum(download_speeds) / len(download_speeds) if download_speeds else 0 + logger.debug(f"平均上传带宽 {StringUtils.str_filesize(avg_upload_speed)}, " + f"平均下载带宽 {StringUtils.str_filesize(avg_download_speed)}, " + f"采样次数={sample_count}, 时长={total_duration:.2f} 秒") + return avg_upload_speed, avg_download_speed + + def __get_downloader_info(self) -> schemas.DownloaderInfo: + """ + 获取下载器实时信息(所有下载器) + """ + ret_info = schemas.DownloaderInfo() + + downloader = self.downloader + if not downloader: + return ret_info + + transfer_infos = self.chain.run_module("downloader_info") + if transfer_infos: + for transfer_info in transfer_infos: + ret_info.download_speed += transfer_info.download_speed + ret_info.upload_speed += transfer_info.upload_speed + ret_info.download_size += transfer_info.download_size + ret_info.upload_size += transfer_info.upload_size + + return ret_info + + def __get_downloading_count(self) -> int: + """ + 获取正在下载的任务数量 + """ + try: + brush_config = self.__get_brush_config() + downloader = self.downloader + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warning("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") + return 0 + + @staticmethod + def __get_pubminutes(pubdate: str) -> float: + """ + 将字符串转换为时间,并计算与当前时间差)(分钟) + """ + try: + if not pubdate: + return 0 + pubdate = pubdate.replace("T", " ").replace("Z", "") + pubdate = datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S") + now = datetime.now() + return (now - pubdate).total_seconds() // 60 + except Exception as e: + logger.error(f"发布时间 {pubdate} 获取分钟失败,错误详情: {e}") + return 0 + + @staticmethod + def __adjust_site_pubminutes(pub_minutes: float, torrent: TorrentInfo) -> float: + """ + 处理部分站点的时区逻辑 + """ + try: + if not torrent: + return pub_minutes + + if torrent.site_name == "我堡": + # 获取当前时区的UTC偏移量(以秒为单位) + utc_offset_seconds = time.timezone + + # 将UTC偏移量转换为分钟 + utc_offset_minutes = utc_offset_seconds / 60 + + # 增加UTC偏移量到pub_minutes + adjusted_pub_minutes = pub_minutes + utc_offset_minutes + + return adjusted_pub_minutes + + return pub_minutes + except Exception as e: + logger.error(str(e)) + return 0 + + def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]: + """ + 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R" + """ + # 如果排除标签字符串为空,则返回原始列表 + if not exclude_tag: + return torrents + + # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重 + exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip()) + + filter_torrents = [] + for torrent in torrents: + # 使用 __get_label 方法获取每个 torrent 的标签列表 + labels = self.__get_label(torrent) + # 检查是否有任何一个排除标签存在于标签列表中 + if not any(exclude in labels for exclude in exclude_tags): + filter_torrents.append(torrent) + return filter_torrents + + def __get_subscribe_titles(self) -> Set[str]: + """ + 获取当前订阅的所有标题,返回一个不包含None和空白字符的集合 + """ + brush_config = self.__get_brush_config() + if not brush_config.except_subscribe: + logger.info("没有开启排除订阅,取消订阅标题匹配") + return set() + + logger.info("已开启排除订阅,正在准备订阅标题匹配 ...") + + if not self._subscribe_infos: + self._subscribe_infos = {} + + subscribes = self.subscribe_oper.list() + if subscribes: + # 遍历订阅 + for subscribe in subscribes: + media_type = subscribe.type if isinstance(subscribe.type, MediaType) else MediaType(subscribe.type) + if media_type not in (MediaType.MOVIE, MediaType.TV): + continue + + # 判断当前订阅是否已经在缓存中,如果已经处理过,那么这里直接跳过 + subscribe_key = f"{subscribe.id}_{subscribe.name}" + if subscribe_key in self._subscribe_infos: + continue + + subscribe_titles = [subscribe.name] + try: + # 生成元数据 + meta = MetaInfo(subscribe.name) + meta.year = subscribe.year + meta.begin_season = subscribe.season or None + meta.type = media_type + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=meta, mtype=meta.type, + media_source=subscribe.media_source, + media_id=subscribe.media_id, + cache=True) + if mediainfo: + logger.info(f"订阅 {subscribe.name} 已识别到媒体信息") + logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}") + subscribe_titles.extend(mediainfo.names) + subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()] + self._subscribe_infos[subscribe_key] = subscribe_titles + else: + logger.info(f"订阅 {subscribe.name} 没有识别到媒体信息,跳过订阅标题匹配") + except Exception as e: + logger.error(f"识别订阅 {subscribe.name} 媒体信息失败,错误详情: {e}") + + # 移除不再存在的订阅 + current_keys = {f"{subscribe.id}_{subscribe.name}" for subscribe in subscribes} + for key in set(self._subscribe_infos) - current_keys: + del self._subscribe_infos[key] + + logger.info("订阅标题匹配完成") + logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}") + unique_titles = {title for titles in self._subscribe_infos.values() for title in titles} + return unique_titles + + @staticmethod + def __filter_torrents_contains_subscribe(torrents: Any, subscribe_titles: Set[str]): + # 初始化两个列表,一个用于收集未被排除的种子,一个用于记录被排除的种子 + included_torrents = [] + excluded_torrents = [] + + # 单次遍历处理 + for torrent in torrents: + # 确保title和description至少是空字符串 + title = torrent.title or '' + description = torrent.description or '' + + if any(subscribe_title in title or subscribe_title in description for subscribe_title in subscribe_titles): + # 如果种子的标题或描述包含订阅标题中的任一项,则记录为被排除 + excluded_torrents.append(torrent) + logger.info(f"命中订阅内容,排除种子:{title}|{description}") + else: + # 否则,收集为未被排除的种子 + included_torrents.append(torrent) + + if not excluded_torrents: + logger.info(f"没有命中订阅内容,不需要排除种子") + + # 返回未被排除的种子列表 + return included_torrents + + @staticmethod + def __bytes_to_gb(size_in_bytes: float) -> float: + """ + 将字节单位的大小转换为千兆字节(GB)。 + + :param size_in_bytes: 文件大小,单位为字节。 + :return: 文件大小,单位为千兆字节(GB)。 + """ + if not size_in_bytes: + return 0.0 + return size_in_bytes / (1024 ** 3) + + @staticmethod + def __is_number_or_range(value): + """ + 检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2') + """ + return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value)) + + @staticmethod + def __is_number(value): + """ + 检查给定的值是否可以被转换为数字(整数或浮点数) + """ + try: + float(value) + return True + except ValueError: + return False + + @staticmethod + def __calculate_seeding_torrents_size(torrent_tasks: Dict[str, dict]) -> float: + """ + 计算保种种子体积 + """ + return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False)) + + def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None: + """ + 自动归档已经删除的种子数据 + """ + if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0: + logger.info("自动归档记录天数小于等于0,取消自动归档") + return + + # 用于存储已删除的数据 + archived_tasks: Dict[str, dict] = self.get_data("archived") or {} + + current_time = time.time() + archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数 + + # 准备一个列表,记录所有需要从原始数据中删除的键 + keys_to_delete = set() + + # 遍历所有 torrent 条目 + for key, value in torrent_tasks.items(): + deleted_time = value.get("deleted_time") + # 场景 1: 检查任务是否已被标记为删除且超出保留天数 + if (value.get("deleted") and isinstance(deleted_time, (int, float)) and + current_time - deleted_time > archive_threshold_seconds): + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 场景 2: 检查没有明确删除时间的历史数据 + if value.get("deleted") and deleted_time is None: + keys_to_delete.add(key) + archived_tasks[key] = value + continue + + # 从原始字典中移除已删除的条目 + for key in keys_to_delete: + del torrent_tasks[key] + + self.save_data("archived", archived_tasks) + + def __clear_tasks(self): + """ + 清除统计数据 + 彻底重置所有刷流数据,如当前还存在正在做种的刷流任务,待定时检查任务执行后,会自动纳入刷流管理 + """ + self.save_data("torrents", {}) + self.save_data("archived", {}) + self.save_data("unmanaged", {}) + self.save_data("statistic", {}) + + def __get_statistic_info(self) -> Dict[str, int]: + """ + 获取统计数据 + """ + statistic_info = self.get_data("statistic") or { + "count": 0, + "deleted": 0, + "uploaded": 0, + "downloaded": 0, + "unarchived": 0, + "active": 0, + "active_uploaded": 0, + "active_downloaded": 0 + } + return statistic_info + + @staticmethod + def __is_valid_time_range(time_range: str) -> bool: + """检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效""" + if not time_range: + return False + + # 使用正则表达式匹配格式 + pattern = re.compile(r'^\d{2}:\d{2}-\d{2}:\d{2}$') + if not pattern.match(time_range): + return False + + try: + start_str, end_str = time_range.split('-') + datetime.strptime(start_str, '%H:%M').time() + datetime.strptime(end_str, '%H:%M').time() + except Exception as e: + print(str(e)) + return False + + return True + + def __is_current_time_in_range(self) -> bool: + """判断当前时间是否在开启时间区间内""" + + brush_config = self.__get_brush_config() + active_time_range = brush_config.active_time_range + + if not self.__is_valid_time_range(active_time_range): + # 如果时间范围格式不正确或不存在,说明当前没有开启时间段,返回True + return True + + start_str, end_str = active_time_range.split('-') + start_time = datetime.strptime(start_str, '%H:%M').time() + end_time = datetime.strptime(end_str, '%H:%M').time() + now = datetime.now().time() + + if start_time <= end_time: + # 情况1: 时间段不跨越午夜 + return start_time <= now <= end_time + else: + # 情况2: 时间段跨越午夜 + return now >= start_time or now <= end_time + + def __get_site_by_torrent(self, torrent: Any) -> Tuple[int, str]: + """ + 根据tracker获取站点信息 + """ + trackers = [] + try: + tracker_url = torrent.get("tracker") + if tracker_url: + trackers.append(tracker_url) + + magnet_link = torrent.get("magnet_uri") + if magnet_link: + query_params: dict = parse_qs(urlparse(magnet_link).query) + encoded_tracker_urls = query_params.get('tr', []) + # 解码tracker URLs然后扩展到trackers列表中 + decoded_tracker_urls = [unquote(url) for url in encoded_tracker_urls] + trackers.extend(decoded_tracker_urls) + except Exception as e: + logger.error(e) + + domain = "未知" + if not trackers: + return 0, domain + + # 特定tracker到域名的映射 + tracker_mappings = { + "chdbits.xyz": "ptchdbits.co", + "agsvpt.trackers.work": "agsvpt.com", + "tracker.cinefiles.info": "audiences.me", + } + + for tracker in trackers: + if not tracker: + continue + # 检查tracker是否包含特定的关键字,并进行相应的映射 + for key, mapped_domain in tracker_mappings.items(): + if key in tracker: + domain = mapped_domain + break + else: + # 使用StringUtils工具类获取tracker的域名 + domain = StringUtils.get_url_domain(tracker) + + site_info = self.sites_helper.get_indexer(domain) + if site_info: + return site_info.get("id"), site_info.get("name") + + # 当找不到对应的站点信息时,返回一个默认值 + return 0, domain + + def __sync_official(self, config: dict): + """ + 双向同步官方插件数据 + """ + if not config: + return + + # 双向数据同步官方插件数据,以本地插件的数据为准 + if config.get("sync_official"): + # 获取本地数据 + from_torrents = self.get_data("torrents") or {} + from_archived = self.get_data("archived") or {} + from_unmanaged = self.get_data("unmanaged") or {} + + # 获取官方插件数据 + to_torrents = self.get_data("torrents", "BrushFlow") or {} + to_archived = self.get_data("archived", "BrushFlow") or {} + to_unmanaged = self.get_data("unmanaged", "BrushFlow") or {} + + # 合并插件数据 + merged_torrents = {**to_torrents, **from_torrents} + merged_archived = {**to_archived, **from_archived} + merged_unmanaged = {**to_unmanaged, **from_unmanaged} + + # 双向保存插件数据 + self.save_data("torrents", merged_torrents) + self.save_data("archived", merged_archived) + self.save_data("unmanaged", merged_unmanaged) + self.save_data("torrents", merged_torrents, "BrushFlow") + self.save_data("archived", merged_archived, "BrushFlow") + self.save_data("unmanaged", merged_unmanaged, "BrushFlow") + + def __check_and_resolve_plugin_conflict(self) -> bool: + """ + 判断是否存在插件冲突 + """ + brush_config = self.__get_brush_config() + if not brush_config: + return True + + official_config = self.get_config("BrushFlow") + if not official_config: + return True + + official_enabled = official_config.get("enabled") + if official_enabled and brush_config.enabled: + logger.warning("官方插件与当前插件只能同时启用一个,请重新配置") + return False + + return True diff --git a/plugins.v3/plexedition/__init__.py b/plugins.v3/plexedition/__init__.py new file mode 100644 index 00000000..a1f95a3b --- /dev/null +++ b/plugins.v3/plexedition/__init__.py @@ -0,0 +1,782 @@ +import concurrent.futures +import threading +import time +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import Event, eventmanager +from app.core.meta import MetaAnime, MetaBase, MetaVideo +from app.core.metainfo import is_anime +from app.db.transferhistory_oper import TransferHistoryOper +from app.helper.mediaserver import MediaServerHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import ServiceInfo +from app.schemas.types import EventType, MediaSource, MediaType + +lock = threading.Lock() + + +class PlexEdition(_PluginBase): + # 插件名称 + plugin_name = "PlexEdition" + # 插件描述 + plugin_desc = "根据入库记录修改Edition为电影版本/资源类型/特效信息。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexedition.png" + # 插件版本 + plugin_version = "1.3" + # 插件作者 + plugin_author = "InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "plexedition" + # 加载顺序 + plugin_order = 94 + # 可使用的用户级别 + auth_level = 1 + # Plex + _plex = None + + # region 私有属性 + mediaserver_helper = None + history_oper = None + # 是否开启 + _enabled = False + # 立即运行一次 + _onlyonce = False + # 任务执行间隔 + _cron = None + # 发送通知 + _notify = False + # 需要处理的媒体库 + _libraries = None + # 锁定元数据 + _lock = None + # 入库后运行一次 + _execute_transfer = None + # 入库后延迟执行时间 + _delay = None + # 运行线程数 + _thread_count = None + # 定时器 + _scheduler = None + # 退出事件 + _event = threading.Event() + + # endregion + + def init_plugin(self, config: dict = None): + self.history_oper = TransferHistoryOper() + self.mediaserver_helper = MediaServerHelper() + if not config: + return False + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._libraries = config.get("libraries") + self._lock = config.get("lock") + self._execute_transfer = config.get("execute_transfer") + try: + self._thread_count = int(config.get("thread_count", 5)) + except ValueError: + self._thread_count = 5 + try: + self._delay = int(config.get("delay", 200)) + except ValueError: + self._delay = 200 + + # 如果开启了入库后运行一次,延迟时间又不填,默认为200s + if self._execute_transfer and not self._delay: + self._delay = 200 + + # 停止现有任务 + self.stop_service() + + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + if self._onlyonce: + logger.info(f"PlexEdition服务,立即运行一次") + self._scheduler.add_job( + func=self.refresh_edition, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="PlexEdition", + ) + # 关闭一次性开关 + self._onlyonce = False + + config_mapping = { + "enabled": self._enabled, + "onlyonce": False, + "cron": self._cron, + "notify": self._notify, + "libraries": self._libraries, + "lock": self._lock, + "thread_count": self._thread_count, + "execute_transfer": self._execute_transfer, + "delay": self._delay + } + self.update_config(config=config_mapping) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def service_infos(self, name_filters: Optional[List[str]] = None) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + services = self.mediaserver_helper.get_services(name_filters=name_filters, type_filter="plex") + if not services: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的媒体服务器,请检查配置") + return None + + return active_services + + def service_info(self, name: str) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + service = self.mediaserver_helper.get_service(name=name, type_filter="plex") + if not service: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + logger.warning(f"媒体服务器 {name} 未连接,请检查配置") + return None + + return service + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + 'hint': '开启后插件将处于激活状态', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + 'hint': '是否在特定事件发生时发送通知', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + 'hint': '插件将立即运行一次', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'lock', + 'label': '锁定元数据', + 'hint': '部分Plex版本只有锁定时才会生效', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'execute_transfer', + 'label': '入库后运行一次', + 'hint': '在媒体入库后运行一次操作', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VCronField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式', + 'hint': '使用cron表达式指定执行周期,如 0 8 * * *', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delay', + 'label': '延迟时间(秒)', + 'placeholder': '入库后延迟执行时间', + 'hint': '入库后延迟执行的时间(秒)', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'thread_count', + 'label': '运行线程数', + 'hint': '执行任务时使用的线程数量', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'libraries', + 'label': '媒体库', + 'items': self.__get_service_library_options(), + 'hint': '选择要处理的媒体库', + 'persistent-hint': True + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '灵感来自于项目 ' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/x1ao4/plex-edition-manager', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': 'plex-edition-manager' + } + ] + }, + { + 'component': 'span', + 'text': ' ,特此感谢 ' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/x1ao4', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': 'x1ao4' + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:如开启锁定元数据,则修改后需要在Plex中手动解锁才允许修改,' + '请先在测试媒体库验证无问题后再继续使用' + } + } + ] + } + ] + } + ], + } + ], { + "enabled": False, + "notify": True, + "cron": "30 0 * * *", + "lock": False, + "thread_count": 5, + "execute_transfer": False, + "delay": 200 + } + + def get_page(self) -> List[dict]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + services = [] + + if self._enabled and self._cron: + logger.info(f"PlexEdition定时服务启动,时间间隔 {self._cron} ") + services.append({ + "id": "PlexEdition", + "name": "PlexEdition", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.refresh_edition, + "kwargs": {} + }) + + if not services: + logger.info("PlexEdition定时服务未开启") + + return services + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + logger.info(str(e)) + + @eventmanager.register(EventType.TransferComplete) + def after_transfer(self, event: Event): + """ + 发送通知消息 + """ + if not self._enabled: + return + + if not self._execute_transfer: + return + + event_info: dict = event.event_data + if not event_info: + return + + mediainfo: MediaInfo = event_info.get("mediainfo") + meta: MetaBase = event_info.get("meta") + if not mediainfo or not meta: + return + + if mediainfo.type != MediaType.MOVIE: + return + + # 确定季度和集数信息,如果存在则添加前缀空格 + season_episode = f" {meta.season_episode}" if meta.season_episode else "" + + # 根据是否有延迟设置不同的日志消息 + delay_message = f"{self._delay} 秒后运行一次Edition服务" if self._delay else "准备运行一次Edition服务" + logger.info(f"{mediainfo.title_year}{season_episode} 已入库,{delay_message}") + + if not self._scheduler: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + self._scheduler.remove_all_jobs() + + self._scheduler.add_job( + func=self.refresh_edition, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=self._delay), + name="PlexEdition", + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def refresh_edition(self): + with lock: + logger.info(f"正在准备执行Edition服务") + service_libraries = self.__get_service_libraries() + if not service_libraries: + logger.error(f"Plex 配置不正确,请检查") + return + logger.info(f"正在准备Edition的媒体库 {service_libraries}") + self.__loop_all(service_libraries=service_libraries, thread_count=self._thread_count) + + def __get_service_library_options(self): + """ + 获取媒体库选项 + """ + library_options = [] + service_infos = self.service_infos() + if not service_infos: + return library_options + + # 获取所有媒体库 + for service in service_infos.values(): + plex = service.instance + if not plex or not plex.get_plex(): + continue + plex_server = plex.get_plex() + libraries = sorted(plex_server.library.sections(), key=lambda x: x.key) + # 遍历媒体库,创建字典并添加到列表中 + for library in libraries: + # 排除照片库 + if library.TYPE == "photo": + continue + library_dict = { + "title": f"{service.name} - {library.key}. {library.title} ({library.TYPE})", + "value": f"{service.name}.{library.key}" + } + library_options.append(library_dict) + return library_options + + def __get_service_libraries(self) -> Optional[Dict[str, Dict[int, Any]]]: + """ + 获取 Plex 媒体库信息 + """ + if not self._libraries: + return None + + service_libraries = defaultdict(set) + + # 1. 处理本地 _libraries,提取出 service_name 和 library_key + for library in self._libraries: + if not library: + continue + if "." in library: + service_name, library_key = library.split(".", 1) + service_libraries[service_name].add(library_key) + + # 2. 获取 service_infos 对象 + service_infos = self.service_infos(name_filters=list(service_libraries.keys())) + if not service_infos: + return None + + # 创建存放交集的字典,value 也是字典,key 为 int(library.key),value 为 library 对象 + intersected_libraries = {} + + # 3. 遍历 service_infos,验证 Plex 实例并获取媒体库 + for service_name, library_keys in service_libraries.items(): + service_info = service_infos.get(service_name) + if not service_info or not service_info.instance: + continue + + plex = service_info.instance + plex_server = plex.get_plex() + if not plex_server: + continue + + libraries = plex_server.library.sections() + + # 4. 获取 Plex 实例中的有效媒体库,进行比对 + remote_libraries = { + int(library.key): library # 键为 int(library.key),值为 library 对象 + for library in libraries if library.TYPE == "movie" + } + + # 计算本地库和远程库的交集,保留匹配的库 + matched_libraries = { + key: library + for key, library in remote_libraries.items() + if str(key) in library_keys + } + + # 如果存在交集,添加到最终结果 + if matched_libraries: + intersected_libraries[service_name] = matched_libraries + + # 5. 返回交集 + return intersected_libraries if intersected_libraries else None + + def __process_items(self, item): + """ + 处理单个媒体项 + """ + if item.type != "movie": + logger.info(f"{item.title} is not movie, not support edit edition") + return + + if item.editionTitle or not item.locations: + return + + locked_fields = [field.name for field in item.fields if field.locked] + if "editionTitle" in locked_fields: + logger.debug(f"{item.title}: titleSort is locked, skip") + else: + file_name = item.locations[0] + tmdb_id = self.__get_tmdb_id(item) + histories = [] + if tmdb_id: + histories = self.history_oper.get_by( + media_source=MediaSource.TMDB, + media_id=tmdb_id, + mtype="电影", + dest=file_name, + ) + + if not histories: + histories = self.history_oper.get_by_title(title=file_name) + + if histories: + history = histories[0] + file_name = history.src if history.src else file_name + + is_anime_flag = is_anime(file_name) + meta = MetaAnime(file_name, file_name, True) \ + if is_anime_flag else MetaVideo(file_name, file_name, True) + + if not meta.edition: + logger.warning(f"{item.title}({item.ratingKey}) can't get edition, can't edit edition") + return + + old_edition = item.editionTitle + item.edit(**{ + "editionTitle.locked": 1 if self._lock else 0, + "editionTitle.value": meta.edition + }) + + logger.info(f"{item.title}({item.ratingKey}) edition : {old_edition} -> {meta.edition}") + + @staticmethod + def __list_items(library): + """ + 获取指定媒体库中的所有媒体项 + """ + if not library: + return None + items = library.search(container_size=1000) + logger.info(f"{library.title} 类型共计{len(items)}个媒体") + return items + + def __loop_all(self, service_libraries: Dict[str, Dict[int, Any]], thread_count: int = None): + """ + 选择媒体库并遍历其中的每一个媒体。 + """ + overall_start_time = time.time() + thread_count = thread_count or 5 # 默认线程数为5 + logger.info(f"正在运行Edition服务,线程数:{thread_count},锁定元数据:{self._lock}") + + for service_name, libraries in service_libraries.items(): + service = self.service_info(name=service_name) + if not service or not service.instance: + logger.info(f"获取媒体服务器 {service_name} 实例失败,跳过处理") + continue + + service_start_time = time.time() + logger.info(f"开始处理媒体服务器 {service_name}") + + for library_id, library_details in libraries.items(): + if items := self.__list_items(library_details): + self.__threads(datalist=items, func=self.__process_items, + thread_count=thread_count) + + service_elapsed_time = time.time() - service_start_time + logger.info(f"媒体服务器 {service_name} 处理完成,耗时 {service_elapsed_time:.2f} 秒") + + overall_elapsed_time = time.time() - overall_start_time + logger.info(f"所有媒体服务器处理完毕,总耗时 {overall_elapsed_time:.2f} 秒") + + @staticmethod + def __threads(datalist, func, thread_count): + """ + 多线程处理模块,每个线程处理部分数据 + :param datalist: 待处理数据列表 + :param func: 处理函数 + :param thread_count: 运行线程数 + """ + + def chunks(lst, n): + """列表切片工具,将列表分成几个块。""" + for i in range(0, len(lst), n): + yield lst[i:i + n] + + chunk_size = (len(datalist) + thread_count - 1) // thread_count + list_chunks = list(chunks(datalist, chunk_size)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=thread_count) as executor: + tasks = [executor.submit(func, item) for chunk in list_chunks for item in chunk] + for task in concurrent.futures.as_completed(tasks): + task.result() + + @staticmethod + def __get_tmdb_id(item): + """获取tmdb_id""" + if not item: + return None + if item.guids: + for guid in item.guids: + if guid.id.startswith("tmdb://"): + tmdb_id = guid.id.split("//")[1] + return tmdb_id + return None diff --git a/plugins.v3/plexmatch/__init__.py b/plugins.v3/plexmatch/__init__.py new file mode 100644 index 00000000..dce09a97 --- /dev/null +++ b/plugins.v3/plexmatch/__init__.py @@ -0,0 +1,440 @@ +import threading +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional, Type + +import pytz +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.core.meta import MetaBase +from app.db import db_query +from app.db.models import TransferHistory +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import TransferInfo +from app.schemas.types import EventType, MediaSource, MediaType +from apscheduler.schedulers.background import BackgroundScheduler +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +lock = threading.Lock() + + +class PlexMatch(_PluginBase): + # 插件名称 + plugin_name = "PlexMatch" + # 插件描述 + plugin_desc = "实现入库时添加 .plexmatch 文件,提高识别准确率。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexmatch.png" + # 插件版本 + plugin_version = "1.4" + # 插件作者 + plugin_author = "InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "plexmatch_" + # 加载顺序 + plugin_order = 95 + # 可使用的用户级别 + auth_level = 1 + + # region 私有属性 + + # 是否开启 + _enabled = False + # 是否覆盖 + _overwrite = False + # 根据历史记录一次性补全 + _complete_all = False + + # 定时器 + _scheduler = None + # 退出事件 + _event = threading.Event() + + # endregion + + def init_plugin(self, config: dict = None): + + if not config: + return + + self._enabled = config.get("enabled") + self._overwrite = config.get("overwrite") + self._complete_all = config.get("complete_all") + + # 停止现有任务 + self.stop_service() + + # 启动服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + if self._complete_all: + logger.info(f"{self.plugin_name},一次性补全服务,立即运行一次") + self._scheduler.add_job( + func=self.__complete_by_history, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name=f"{self.plugin_name}", + ) + # 关闭一次性开关 + self._complete_all = False + config["complete_all"] = False + self.update_config(config=config) + + # 启动服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + pass + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + logger.info(str(e)) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + 'hint': '开启后插件将处于激活状态', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'overwrite', + 'label': '覆盖 .plexmatch 文件', + 'hint': '是否覆盖已有文件', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'complete_all', + 'label': '补全 .plexmatch 文件', + 'hint': '根据历史记录一次性补全,执行后自动关闭', + 'persistent-hint': True + }, + } + ], + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:.plexmatch 相关内容请查阅 ' + }, + 'content': [ + { + 'component': 'a', + 'props': { + 'href': 'https://support.plex.tv/articles/plexmatch', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'Plex官方教程' + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:启用覆盖功能时,若指定路径下已存在 .plexmatch 文件,该文件将被替换,请慎重开启' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:仅适配了MoviePilot默认的重命名目录结构,电影和电视剧均会生成 .plexmatch 文件,但目前仅电视剧会生效' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '警告:根据历史记录一次性补全,可能会触发Plex重新扫描已入库媒体文件的片头片尾,请慎重使用' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "overwrite": False, + "complete_all": False + } + + def get_page(self) -> List[dict]: + pass + + @eventmanager.register(EventType.TransferComplete) + def execute_transfer(self, event: Event): + """ + 入库后执行一次服务 + """ + if not self._enabled: + return + + event_info: dict = event.event_data + if not event_info: + return + + mediainfo: MediaInfo = event_info.get("mediainfo") + meta: MetaBase = event_info.get("meta") + transfer_info: TransferInfo = event_info.get("transferinfo") + if not mediainfo or not meta or not transfer_info: + return + if mediainfo.type not in (MediaType.MOVIE, MediaType.TV): + return + + # 获取媒体信息,确定季度和集数信息,如果存在则添加前缀空格 + season_episode = f" {meta.season_episode}" if meta.season_episode else "" + media_desc = f"{mediainfo.title_year}{season_episode}" + + logger.info(f"{media_desc} 已入库,正在准备运行一次 PlexMatch 服务") + + tmdb_id = self.__get_tmdb_id( + media_source=mediainfo.media_source, + media_id=mediainfo.media_id, + ) + if not tmdb_id: + logger.info(f"{media_desc} 没有有效的 TMDB 媒体身份,跳过 PlexMatch 服务") + return + + self.__add_plexmatch_file(title=mediainfo.title, + tmdb_id=tmdb_id, + file_path=str(transfer_info.target_item.path) if transfer_info.target_item else None, + mtype=mediainfo.type) + + def __complete_by_history(self): + """ + 补全历史记录 + """ + histories = self.__list_transfer_histories(db=None) + if not histories: + logger.info("没有获取到相关的历史记录,取消补全") + return + + for history in histories: + if self.__check_external_interrupt(service=f"{self.plugin_name}"): + return + media_type = MediaType(history.type) + if media_type not in (MediaType.MOVIE, MediaType.TV): + continue + tmdb_id = self.__get_tmdb_id( + media_source=history.media_source, + media_id=history.media_id, + ) + if not tmdb_id: + continue + self.__add_plexmatch_file(title=history.title, + tmdb_id=tmdb_id, + file_path=history.dest, + mtype=media_type) + + @staticmethod + def __get_tmdb_id(media_source: Optional[MediaSource | str], media_id: Optional[str]) -> Optional[str]: + """仅从有效的 TMDB 媒体身份中提取 Plex 可识别的 ID。""" + normalized_id = str(media_id).strip() if media_id is not None else "" + if media_source != MediaSource.TMDB or not normalized_id or normalized_id == "0": + return None + return normalized_id + + def __add_plexmatch_file(self, title: str, tmdb_id: str, file_path: str, + mtype: MediaType = MediaType.TV) -> bool: + """添加.plexmatch文件""" + keyword = f"{title}({tmdb_id})-> {file_path}" + logger.info(f"{keyword} 正在准备添加 .plexmatch 文件") + try: + if mtype not in (MediaType.MOVIE, MediaType.TV): + logger.info(f"{title} 的媒体类型 {mtype.value} 不支持 PlexMatch,跳过处理") + return False + if not tmdb_id: + logger.warning(f"{title} 的 TMDB ID {tmdb_id} 无效,跳过处理") + return False + + path = Path(file_path) if file_path else None + if not path or not path.exists(): + logger.warning(f"目标路径 {path} 不存在,跳过处理") + return False + + if mtype == MediaType.TV: + parent_path = path.parent.parent if path.is_file() else path.parent + else: + parent_path = path.parent if path.is_file() else path + + plexmatch_file = parent_path / ".plexmatch" + logger.info(f".plexmatch 文件路径为 {plexmatch_file}") + if plexmatch_file.exists() and not self._overwrite: + logger.info(f".plexmatch 文件已存在且未开启覆盖,跳过处理") + return False + + hints = f"tmdbid: {tmdb_id} #{title} TMDB编号" + with plexmatch_file.open('w', encoding='utf-8') as file: + file.write(hints) + + logger.info(f"{keyword} 已添加 .plexmatch 文件至 {plexmatch_file}") + return True + except Exception as e: + logger.error(f"处理 {keyword} 时发生错误: {e}") + return False + + def __check_external_interrupt(self, service: str) -> bool: + """ + 检查是否有外部中断请求,并记录相应的日志信息 + """ + if self._event.is_set(): + logger.warning(f"外部中断请求,{service}服务停止") + return True + return False + + @staticmethod + @db_query + def __list_transfer_histories(db: Optional[Session]) -> list[Type[TransferHistory]]: + """获取具有有效 TMDB 媒体身份且整理成功的历史记录。""" + result = db.query(TransferHistory).filter(and_( + TransferHistory.type.in_([MediaType.MOVIE.value, MediaType.TV.value]), + TransferHistory.media_source == MediaSource.TMDB.value, + TransferHistory.media_id.is_not(None), + TransferHistory.media_id != "", + TransferHistory.media_id != "0", + TransferHistory.status) + ).all() + return result diff --git a/plugins.v3/plexpersonmeta/README.md b/plugins.v3/plexpersonmeta/README.md new file mode 100644 index 00000000..7bca7aef --- /dev/null +++ b/plugins.v3/plexpersonmeta/README.md @@ -0,0 +1,28 @@ +# Plex演职人员刮削 + +实现刮削演职人员中文名称及角色 + +## 版本更新日志 + +- v2.4 + - MoviePilot V3 版本Plex演职人员刮削插件 + +- **技术难点**:Plex 的 API 实现较为复杂,特别是在处理关联演职人员的 `tagKey`,我在尝试为 `actor.tag.tagKey` 赋值时遇到了问题。如果您对此有所了解,请不吝赐教,欢迎通过在项目的 GitHub 页面新增一个 issue 与我联系,我将非常感谢您的反馈和帮助。 + +- **操作警告**:在刮削演职人员信息后,可能会出现一些问题,例如丢失在线元数据,或者在 Plex 中无法通过点击演职人员的名字来查看其详细信息。请在操作前备份相关数据,以防不测。 + +在进行任何操作前,请确保您已经做好了完整的数据备份,并理解所有相关的技术细节和潜在风险。如果您有更多关于 Plex API 的技术问题或需求,欢迎与我联系或查阅更多资料。 + +#### 保留在线元数据功能注意事项 + +- **2024.7.7 由于未知原因,部分媒体库演员数据被清理,因此该方案搁置,相关脚本下线,请勿开启该功能,如已使用该功能,建议尽快恢复数据库备份** + +#### 感谢 + +- 本插件基于 [官方插件](https://github.com/jxxghp/MoviePilot-Plugins) 编写,并参考了 [PrettyServer](https://github.com/Bespertrijun/PrettyServer) 项目,实现了插件的相关功能。 +- 特此感谢 [jxxghp](https://github.com/jxxghp)、[Bespertrijun](https://github.com/Bespertrijun) 等贡献者的卓越代码贡献。 +- 如有未能提及的作者,请告知我以便进行补充。 + +![](../../images/2024-07-13-03-02-10.png) +![](../../images/2024-06-25-02-57-20.png) +![](../../images/2024-06-25-02-57-53.png) diff --git a/plugins.v3/plexpersonmeta/__init__.py b/plugins.v3/plexpersonmeta/__init__.py new file mode 100644 index 00000000..e7225936 --- /dev/null +++ b/plugins.v3/plexpersonmeta/__init__.py @@ -0,0 +1,1001 @@ +import threading +import time +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import Event, eventmanager +from app.core.meta import MetaBase +from app.helper.mediaserver import MediaServerHelper +from app.log import logger +from app.plugins import _PluginBase +from app.plugins.plexpersonmeta.scrape import ScrapeHelper +from app.schemas import ServiceInfo +from app.schemas.types import EventType, NotificationType + +lock = threading.Lock() + + +class PlexPersonMeta(_PluginBase): + # 插件名称 + plugin_name = "Plex演职人员刮削" + # 插件描述 + plugin_desc = "实现刮削演职人员中文名称及角色。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/plexpersonmeta.png" + # 插件版本 + plugin_version = "2.4" + # 插件作者 + plugin_author = "InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "plexpersonmeta_" + # 加载顺序 + plugin_order = 91 + # 可使用的用户级别 + auth_level = 1 + + # region 私有属性 + mediaserver_helper = None + # 是否开启 + _enabled = False + # 立即运行一次 + _onlyonce = False + # 任务执行间隔 + _cron = None + # 发送通知 + _notify = False + # 需要处理的媒体库 + _libraries = None + # 入库后运行一次 + _execute_transfer = None + # 入库后延迟执行时间 + _delay = None + # 最近一次入库时间 + _transfer_time = None + # 清理缓存 + _clear_cache = None + # 定时器 + _scheduler = None + # 退出事件 + _event = threading.Event() + + # endregion + + def init_plugin(self, config: dict = None): + self.mediaserver_helper = MediaServerHelper() + if not config: + return + self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._cron = config.get("cron") + self._notify = config.get("notify") + self._libraries = config.get("libraries", []) + self._clear_cache = config.get("clear_cache") + self._execute_transfer = config.get("execute_transfer") + try: + self._delay = int(config.get("delay", 200)) + except ValueError: + self._delay = 200 + + # 如果开启了入库后运行一次,延迟时间又不填,默认为200s + if self._execute_transfer and not self._delay: + self._delay = 200 + + # 停止现有任务 + self.stop_service() + + # 启动服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + if self._clear_cache: + logger.info(f"{self.plugin_name} 清理缓存一次") + self._scheduler.add_job( + func=ScrapeHelper.clear_cache, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name=f"{self.plugin_name}", + ) + # 关闭清理缓存 + self._clear_cache = False + config["clear_cache"] = False + self.update_config(config=config) + + if self._onlyonce: + logger.info(f"{self.plugin_name}服务,立即运行一次") + self._scheduler.add_job( + func=self.scrape_library, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=6), + name=f"{self.plugin_name}", + ) + # 关闭一次性开关 + self._onlyonce = False + config["onlyonce"] = False + self.update_config(config=config) + + # 启动服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def service_infos(self, name_filters: Optional[List[str]] = None) -> Optional[Dict[str, ServiceInfo]]: + """ + 服务信息 + """ + services = self.mediaserver_helper.get_services(name_filters=name_filters, type_filter="plex") + if not services: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + active_services = {} + for service_name, service_info in services.items(): + if service_info.instance.is_inactive(): + logger.warning(f"媒体服务器 {service_name} 未连接,请检查配置") + else: + active_services[service_name] = service_info + + if not active_services: + logger.warning("没有已连接的媒体服务器,请检查配置") + return None + + return active_services + + def service_info(self, name: str) -> Optional[ServiceInfo]: + """ + 服务信息 + """ + service = self.mediaserver_helper.get_service(name=name, type_filter="plex") + if not service: + logger.warning("获取媒体服务器实例失败,请检查配置") + return None + + if service.instance.is_inactive(): + logger.warning(f"媒体服务器 {name} 未连接,请检查配置") + return None + + return service + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + logger.info(f"{self.plugin_name}定时服务启动,时间间隔 {self._cron} ") + return [{ + "id": "PlexPersonMeta", + "name": f"{self.plugin_name}服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.scrape_library, + "kwargs": {} + }] + + def stop_service(self): + """ + 退出插件 + """ + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + logger.info(str(e)) + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + 'hint': '开启后插件将处于激活状态', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + 'hint': '是否在特定事件发生时发送通知', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clear_cache', + 'label': '清理缓存', + 'hint': '清理元数据识别缓存', + 'persistent-hint': True + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + 'hint': '插件将立即运行一次', + 'persistent-hint': True + }, + } + ], + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'lock', + 'label': '锁定元数据', + 'hint': '开启后元数据将锁定,须手工解锁后才允许修改', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'execute_transfer', + 'label': '入库后运行一次', + 'hint': '在媒体入库后运行一次操作', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'douban_scrape', + 'label': '豆瓣辅助识别', + 'hint': '提高识别率的同时将会降低性能', + 'persistent-hint': True + } + } + ] + } + ], + }, + { + 'component': 'VRow', + 'content': [ + # { + # 'component': 'VCol', + # 'props': { + # 'cols': 12, + # 'md': 4 + # }, + # 'content': [ + # { + # 'component': 'VSwitch', + # 'props': { + # 'model': 'remove_no_zh', + # 'label': '删除非中文演员', + # 'hint': '开启后将删除所有非中文演员', + # 'persistent-hint': True + # } + # } + # ] + # }, + # { + # 'component': 'VCol', + # 'props': { + # 'cols': 12, + # 'md': 4 + # }, + # 'content': [ + # { + # 'component': 'VSwitch', + # 'props': { + # 'model': 'reserve_tag_key', + # 'label': '保留在线元数据(实验性功能)', + # 'hint': '尝试保留在线元数据,需结合脚本使用', + # 'persistent-hint': True + # } + # } + # ] + # } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VCronField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '5位cron表达式', + 'hint': '使用cron表达式指定执行周期,如 0 8 * * *', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'delay', + 'label': '延迟时间(秒)', + 'placeholder': '入库后延迟运行时间', + 'hint': '入库后延迟运行的时间(秒)', + 'persistent-hint': True + }, + } + ], + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'scrape_type', + 'label': '刮削条件', + 'items': [ + {'title': '全部', 'value': 'all'}, + {'title': '演员非中文', 'value': 'name'}, + {'title': '角色非中文', 'value': 'role'}, + ], + 'hint': '选择刮削条件', + 'persistent-hint': True + } + } + ] + } + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'libraries', + 'label': '媒体库', + 'items': self.__get_service_library_options(), + 'hint': '选择要处理的媒体库', + 'persistent-hint': True + }, + } + ], + }, + ], + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '基于 ' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/jxxghp/MoviePilot-Plugins', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': '官方插件' + } + ] + }, + { + 'component': 'span', + 'text': ' 编写,并参考了 ' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/Bespertrijun/PrettyServer', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': 'PrettyServer' + } + ] + }, + { + 'component': 'span', + 'text': ' 项目,特此感谢 ' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/jxxghp', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': 'jxxghp' + } + ] + }, + { + 'component': 'span', + 'text': '、' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/Bespertrijun', + 'target': '_blank', + 'style': 'text-decoration: underline;' + }, + 'content': [ + { + 'component': 'u', + 'text': 'Bespertrijun' + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': 'Plex 的 API 实现较为复杂,我在尝试为 actor.tag.tagKey 赋值时遇到了问题,' + '如果您对此有所了解,请不吝赐教,可以通过新增一个 issue 与我联系,特此感谢' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'error', + 'variant': 'tonal', + 'text': '警告:由于tagKey的问题,当执行刮削后,可能会出现丢失在线元数据,无法在Plex中点击人物查看详情等问题' + } + } + ] + } + ] + }, + # { + # 'component': 'VRow', + # 'content': [ + # { + # 'component': 'VCol', + # 'props': { + # 'cols': 12, + # }, + # 'content': [ + # { + # 'component': 'VAlert', + # 'props': { + # 'type': 'error', + # 'variant': 'tonal', + # 'text': '免责声明:如开启「保留在线元数据」选项,该功能尚处于实验性阶段,开启后将大幅降低刮削效率,同时需结合数据库脚本使用,' + # '可能会引发元数据丢失、播放问题甚至Plex数据库文件损坏等风险,请慎重使用,详细信息请查阅 ' + # }, + # 'content': [ + # { + # 'component': 'a', + # 'props': { + # 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/plexpersonmeta/README.md', + # 'target': '_blank' + # }, + # 'content': [ + # { + # 'component': 'u', + # 'text': 'README' + # } + # ] + # } + # ] + # } + # ] + # } + # ] + # }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:如刮削没有达到预期的效果,请尝试在Plex中修改配置,设置->在线媒体资源->发现更多->停用发现来源' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '注意:如开启锁定元数据,则刮削后需要在Plex中手动解锁才允许修改,' + '请先在测试媒体库验证无问题后再继续使用' + } + } + ] + } + ] + } + ], + } + ], { + "enabled": False, + "notify": True, + "cron": "0 1 * * *", + "lock": False, + "execute_transfer": False, + "delay": 200, + "scrape_type": "all", + "remove_no_zh": False, + # "reserve_tag_key": False, + "douban_scrape": True + } + + def get_page(self) -> List[dict]: + pass + + def __get_service_library_options(self): + """ + 获取媒体库选项 + """ + library_options = [] + service_infos = self.service_infos() + if not service_infos: + return library_options + + # 获取所有媒体库 + for service in service_infos.values(): + plex = service.instance + if not plex or not plex.get_plex(): + continue + plex_server = plex.get_plex() + libraries = sorted(plex_server.library.sections(), key=lambda x: x.key) + # 遍历媒体库,创建字典并添加到列表中 + for library in libraries: + # 仅支持电影、剧集媒体库 + if library.TYPE != "show" and library.TYPE != "movie": + continue + library_dict = { + "title": f"{service.name} - {library.key}. {library.title} ({library.TYPE})", + "value": f"{service.name}.{library.key}" + } + library_options.append(library_dict) + return library_options + + def __get_service_libraries(self) -> Optional[Dict[str, Dict[int, Any]]]: + """ + 获取 Plex 媒体库信息 + """ + if not self._libraries: + return None + + service_libraries = defaultdict(set) + + # 1. 处理本地 _libraries,提取出 service_name 和 library_key + for library in self._libraries: + if not library: + continue + if "." in library: + service_name, library_key = library.split(".", 1) + service_libraries[service_name].add(library_key) + + # 2. 获取 service_infos 对象 + service_infos = self.service_infos(name_filters=list(service_libraries.keys())) + if not service_infos: + return None + + # 创建存放交集的字典,value 也是字典,key 为 int(library.key),value 为 library 对象 + intersected_libraries = {} + + # 3. 遍历 service_infos,验证 Plex 实例并获取媒体库 + for service_name, library_keys in service_libraries.items(): + service_info = service_infos.get(service_name) + if not service_info or not service_info.instance: + continue + + plex = service_info.instance + plex_server = plex.get_plex() + if not plex_server: + continue + + libraries = plex_server.library.sections() + + # 4. 获取 Plex 实例中的有效媒体库,进行比对 + remote_libraries = { + int(library.key): library # 键为 int(library.key),值为 library 对象 + for library in libraries if library.TYPE != "photo" + } + + # 计算本地库和远程库的交集,保留匹配的库 + matched_libraries = { + key: library + for key, library in remote_libraries.items() + if str(key) in library_keys + } + + # 如果存在交集,添加到最终结果 + if matched_libraries: + intersected_libraries[service_name] = matched_libraries + + # 5. 返回交集 + return intersected_libraries if intersected_libraries else None + + @eventmanager.register(EventType.TransferComplete) + def scrape_rt(self, event: Event): + """ + 根据事件实时刮削演员信息 + """ + if not self._enabled: + return + + if not self._execute_transfer: + return + + event_info: dict = event.event_data + if not event_info: + return + + mediainfo: MediaInfo = event_info.get("mediainfo") + meta: MetaBase = event_info.get("meta") + if not mediainfo or not meta: + return + + # 获取媒体信息,确定季度和集数信息,如果存在则添加前缀空格 + season_episode = f" {meta.season_episode}" if meta.season_episode else "" + media_desc = f"{mediainfo.title_year}{season_episode}" + + # 如果最近一次入库时间为None,这里才进行赋值,否则可能是存在尚未执行的任务待执行 + if not self._transfer_time: + self._transfer_time = datetime.now(tz=pytz.timezone(settings.TZ)) + + # 根据是否有延迟设置不同的日志消息 + delay_message = f"{self._delay} 秒后运行一次{self.plugin_name}服务" if self._delay else f"准备运行一次{self.plugin_name}服务" + logger.info(f"{media_desc} 已入库,{delay_message}") + + if not self._scheduler: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + self._scheduler.remove_all_jobs() + + self._scheduler.add_job( + func=self.__scrape_by_transfer, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=self._delay), + name=f"{self.plugin_name}", + ) + + # 启动任务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __scrape_by_transfer(self): + """入库后运行一次""" + if not self._transfer_time: + logger.info(f"没有获取到最近一次的入库时间,取消执行{self.plugin_name}服务") + return + + logger.info(f"正在运行一次{self.plugin_name}服务,入库时间 {self._transfer_time.strftime('%Y-%m-%d %H:%M:%S')}") + + adjusted_time = self._transfer_time - timedelta(minutes=5) + logger.info(f"为保证入库数据完整性,前偏移5分钟后的时间:{adjusted_time.strftime('%Y-%m-%d %H:%M:%S')}") + + self.scrape_library_by_added_time(added_time=int(adjusted_time.timestamp())) + self._transfer_time = None + + def scrape_library(self): + """ + 刮削媒体库中所有媒体的演员信息 + """ + if not self.__check_plex_media_server(): + return + + with lock: + overall_start_time = time.time() + plugin_config = self.get_config() + service_libraries = self.__get_service_libraries() + for service_name, libraries in service_libraries.items(): + service = self.service_info(name=service_name) + if not service or not service.instance: + logger.info(f"获取媒体服务器 {service.name} 实例失败,跳过处理") + continue + service_start_time = time.time() + scrape_helper = ScrapeHelper(config=plugin_config, event=self._event, chain=self.chain, + service=service, libraries=libraries) + logger.info(f"开始处理媒体服务器 {service.name} 的媒体库") + + for library_id, library in libraries.items(): + logger.info(f"开始刮削媒体库 {library.title} 的演员信息 ...") + try: + rating_items = scrape_helper.list_rating_items(library=library) + if not rating_items: + logger.info(f"媒体库 {library.title} 没有找到任何媒体信息,跳过刮削") + continue + + scrape_helper.scrape_rating_items(rating_items=rating_items) + logger.info(f"媒体库 {library.title} 的演员信息刮削完成") + except Exception as e: + logger.error(f"媒体库 {library.title} 刮削过程中出现异常,{str(e)}") + + service_elapsed_time = time.time() - service_start_time + logger.info(f"媒体服务器 {service.name} 处理完成,耗时 {service_elapsed_time:.2f} 秒") + + overall_elapsed_time = time.time() - overall_start_time + message_text = f"演员信息刮削完成,用时 {overall_elapsed_time:.2f} 秒" + self.__send_message(title="【媒体库演员信息刮削】", text=message_text) + logger.info(message_text) + + def scrape_library_by_added_time(self, added_time: int): + """根据入库时间刮削媒体库中的演员信息""" + if not self.__check_plex_media_server(): + return + + with lock: + overall_start_time = time.time() + plugin_config = self.get_config() + service_libraries = self.__get_service_libraries() + for service_name, libraries in service_libraries.items(): + service = self.service_info(name=service_name) + if not service or not service.instance: + logger.info(f"获取媒体服务器 {service.name} 实例失败,跳过处理") + continue + service_start_time = time.time() + scrape_helper = ScrapeHelper(config=plugin_config, event=self._event, chain=self.chain, + service=service, libraries=libraries) + logger.info(f"开始处理媒体服务器 {service.name} 的媒体库") + + for library_id, library in libraries.items(): + rating_items = {} + episode_items = {} + recent_added_items = scrape_helper.list_rating_items_by_added(added_time=added_time) + + for rating_item in recent_added_items: + section_id = rating_item.get("librarySectionID") + if section_id != library_id: + continue + + rating_key = rating_item.get("ratingKey") + if not rating_key: + continue + + rating_type = rating_item.get("type") + # 先获取show和movie的key,后续直接进行刮削 + if rating_type in ["show", "movie"]: + rating_items[rating_key] = rating_item + # 如果是季,这里直接当成show进行处理 + elif rating_type == "season": + parent_key = scrape_helper.extract_key_from_url(rating_item.get("parentKey")) + if parent_key and parent_key not in rating_items: + try: + rating_items[parent_key] = scrape_helper.fetch_item(rating_key=parent_key) + except Exception as e: + logger.error(f"媒体项 {rating_item.get('parentTitle')} 获取详细信息失败,{e}") + # 如果是集的,先判断对应的父级key是否已经在rating_keys中增加,如果是,则忽略,如果不是,则追加到集的key中,后续独立进行刮削 + elif rating_type == "episode": + parent_key = scrape_helper.extract_key_from_url(rating_item.get("grandparentKey")) + if parent_key and parent_key not in rating_items: + episode_items.setdefault(parent_key, []).append(rating_item) + + logger.info(f"开始刮削媒体库 {library.title} 最近入库的演员信息 ...") + if not rating_items and not episode_items: + logger.info(f"媒体库 {library.title} 最近入库没有找到任何符合条件的媒体信息,跳过刮削") + else: + scrape_helper.scrape_rating_items(rating_items=list(rating_items.values())) + scrape_helper.scrape_episode_items(episode_items=episode_items) + + service_elapsed_time = time.time() - service_start_time + logger.info(f"媒体服务器 {service.name} 处理完成,耗时 {service_elapsed_time:.2f} 秒") + + overall_elapsed_time = time.time() - overall_start_time + formatted_added_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(added_time)) + message_text = f"最近一次入库时间:{formatted_added_time},演员信息刮削完成,用时 {overall_elapsed_time:.2f} 秒" + self.__send_message(title="【媒体库演员信息刮削】", text=message_text) + logger.info(message_text) + + def __send_message(self, title: str, text: str): + """ + 发送消息 + """ + if not self._notify: + return + + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + + def __check_plex_media_server(self) -> bool: + """检查Plex媒体服务器配置""" + service_libraries = self.__get_service_libraries() + if not service_libraries: + logger.error(f"Plex 配置不正确,请检查") + return False + return True diff --git a/plugins.v3/plexpersonmeta/helper.py b/plugins.v3/plexpersonmeta/helper.py new file mode 100644 index 00000000..15eeaf29 --- /dev/null +++ b/plugins.v3/plexpersonmeta/helper.py @@ -0,0 +1,75 @@ +""" +helper.py + +这个模块定义了用于存储媒体项目信息的 `RatingInfo` 数据类以及缓存、限流等装饰器 +""" +import functools +import hashlib +from dataclasses import dataclass +from typing import Optional + +from app.core.cache import Cache +from app.log import logger + +# 创建缓存实例 +cache_backend = Cache(maxsize=100000, ttl=60 * 60 * 24 * 3) + + +@dataclass +class RatingInfo: + """ + 媒体项目信息的数据类 + """ + key: Optional[str] = None # 媒体项目的唯一标识 + type: Optional[str] = None # 媒体项目的类型(例如:电影、电视剧) + title: Optional[str] = None # 媒体项目的标题 + search_title: Optional[str] = None # 用于搜索的标题 + tmdbid: Optional[int] = None # TMDB 的唯一标识,可选 + + +def cache_with_logging(region, source): + """ + 装饰器,用于在函数执行时处理缓存逻辑和日志记录。 + :param region: 缓存区,用于存储和检索缓存数据 + :param source: 数据来源,用于日志记录(例如:PERSON 或 MEDIA) + :return: 装饰器函数 + """ + + def decorator(func): + + @functools.wraps(func) + def wrapped_func(*args, **kwargs): + # 生成缓存键 + func_name = func.__name__ + args_str = str(args) + str(sorted(kwargs.items())) + key = hashlib.md5((func_name + args_str).encode()).hexdigest() + + exists_cache = cache_backend.exists(key, region=region) + if exists_cache: + value = cache_backend.get(key, region=region) + if value is not None: + if value == "None": + logger.info(f"从缓存中获取到 {source} 信息为 None,可能是之前触发限流或网络异常") + return None + if source == "PERSON": + logger.info(f"从缓存中获取到 {source} 人物信息") + else: + logger.info(f"从缓存中获取到 {source} 媒体信息: {kwargs.get('title', 'Unknown Title')}") + return value + return None + + # 执行被装饰的函数 + result = func(*args, **kwargs) + + if result is None: + # 如果结果为 None,说明触发限流或网络等异常,缓存5分钟,以免高频次调用 + cache_backend.set(key, "None", region=region) + else: + # 结果不为 None,使用默认 TTL 缓存 + cache_backend.set(key, result, region=region) + + return result + + return wrapped_func + + return decorator diff --git a/plugins.v3/plexpersonmeta/requirements.txt b/plugins.v3/plexpersonmeta/requirements.txt new file mode 100644 index 00000000..60d72680 --- /dev/null +++ b/plugins.v3/plexpersonmeta/requirements.txt @@ -0,0 +1 @@ +pypinyin~=0.51.0 \ No newline at end of file diff --git a/plugins.v3/plexpersonmeta/scrape.py b/plugins.v3/plexpersonmeta/scrape.py new file mode 100644 index 00000000..0b0c4d2c --- /dev/null +++ b/plugins.v3/plexpersonmeta/scrape.py @@ -0,0 +1,851 @@ +import copy +import re +import threading +import time +from typing import Any, Dict, List, Optional + +import plexapi +import plexapi.utils +import pypinyin +from plexapi.library import LibrarySection + +from app.chain.mediaserver import MediaServerChain +from app.chain.tmdb import TmdbChain +from app.core.context import MediaInfo +from app.log import logger +from app.plugins import PluginChian +from app.plugins.plexpersonmeta.helper import RatingInfo, cache_backend, cache_with_logging +from app.schemas import MediaPerson, ServiceInfo +from app.schemas.types import MediaSource, MediaType +from app.utils.zhconv import convert as zhconv_convert +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class ScrapeHelper: + timeout: int = 10 + + def __init__(self, config: dict, event: threading.Event, chain: PluginChian, + service: ServiceInfo, libraries: dict[int, Any]): + self.tmdb_chain = TmdbChain() + self.mediaserver_chain = MediaServerChain() + self.chain = chain + self.event = event + self.service = service + self.plex = service.instance if service else None + self.libraries = libraries + + if not config: + return + self._lock = config.get("lock") + self._execute_transfer = config.get("execute_transfer") + self._scrape_type = config.get("scrape_type", "all") + self._remove_no_zh = config.get("remove_no_zh", False) + self._douban_scrape = config.get("douban_scrape", True) + # self._reserve_tag_key = config.get("reserve_tag_key", False) + try: + self._delay = int(config.get("delay", 200)) + except ValueError: + self._delay = 200 + + def scrape_rating_items(self, rating_items: list): + """刮削媒体库中的媒体项""" + for rating_item in rating_items: + if self.check_external_interrupt(): + return + info = self.get_rating_info(item=rating_item) + if not info or info.type not in ["movie", "show"]: + continue + item = {} + try: + item = self.fetch_item(rating_key=info.key) + if not item: + continue + logger.info(f"开始刮削 {info.title} 的演员信息 ...") + self.scrape_item(item=item) + logger.info(f"{info.title} 的演员信息刮削完成") + except Exception as e: + logger.error(f"媒体项 {info.title} 刮削过程中出现异常,{str(e)}") + + if info.type != "show": + logger.info(f"<{info.title}> 类型为 {info.type},非show类型,跳过剧集刮削") + continue + logger.info(f"<{info.title}> 类型为 show,准备进行剧集刮削") + self.scrape_episodes(item=item) + + def scrape_episode_items(self, episode_items: dict): + """刮削剧集的媒体信息""" + for parent_key, episodes in episode_items.items(): + if self.check_external_interrupt(): + return + item = self.fetch_item(rating_key=parent_key) + if not item: + continue + self.scrape_episodes(item=item, episodes=episodes) + + def scrape_episodes(self, item: dict, episodes: Optional[dict] = None): + """刮削剧集""" + info = self.get_rating_info(item=item) + if not info or info.type != "show": + return + + try: + # 如果 episodes 为空,这里获取所有的 episodes 进行刮削 + episodes_provided_all = episodes is None + if episodes_provided_all: + episodes = self.list_episodes(rating_key=info.key) + + if not episodes: + logger.info(f"<{info.title}> 没有找到任何剧集信息,取消剧集刮削") + else: + if episodes_provided_all: + logger.info( + f"<{info.title}> 共计 {item.get('childCount', 0)} 季 {len(episodes)} 集,准备进行剧集刮削") + else: + logger.info(f"<{info.title}> 共计 {len(episodes)} 集,准备进行剧集刮削") + + for episode in episodes: + if self.check_external_interrupt(): + return + episode_info = self.get_rating_info(item=episode, parent_item=item) + if not episode_info or episode_info.type != "episode": + continue + try: + episode_item = self.fetch_item(rating_key=episode_info.key) + if not episode_item: + continue + logger.info(f"开始刮削 {episode_info.title} 的演员信息 ...") + self.scrape_item(item=episode_item, info=episode_info) + logger.info(f"{episode_info.title} 的演员信息刮削完成") + except Exception as e: + logger.error(f"媒体项 {episode_info.title} 刮削过程中出现异常,{str(e)}") + except Exception as e: + logger.error(f"媒体项 {info.title} 刮削剧集过程中出现异常,{str(e)}") + + def scrape_item(self, item: dict, info: Optional[RatingInfo] = None): + """ + 刮削媒体服务器中的条目 + """ + if not item: + return + + if not info: + info = self.get_rating_info(item=item) + + if not info or info.type not in {"movie", "show", "episode"}: + return + + if not info or not info.tmdbid: + logger.warning(f"{info.title} 未找到tmdbid,无法识别媒体信息") + return + + logger.info(f"{info.title} 正在获取 TMDB 媒体信息") + mediainfo = self.get_tmdb_media(tmdbid=info.tmdbid, + title=info.search_title, + mtype=MediaType.MOVIE if info.type == "movie" else MediaType.TV) + if not mediainfo: + logger.warning(f"{info.title} TMDB 未识别到媒体信息") + return + + try: + if self.need_trans_actor(item): + self.update_peoples(item=item, mediainfo=mediainfo, info=info) + else: + logger.info(f"{info.title} 的人物信息已是中文,无需更新") + except Exception as e: + logger.error(f"{info.title} 更新人物信息时出错:{str(e)}") + + def need_trans_actor(self, item: dict) -> bool: + """ + 是否需要处理人物信息 + """ + actors = item.get("Role", []) + if not actors: + return False + + field_to_check = None + if self._scrape_type == "name": + field_to_check = "tag" + elif self._scrape_type == "role": + field_to_check = "role" + + if field_to_check: + for actor in actors: + # 检查特定字段,且字段不能为空 + field_value = actor.get(field_to_check) + if field_value and not StringUtils.is_chinese(field_value): + return True + else: + for actor in actors: + # 刮削为 all 时,检查 tag 和 role 两个字段,且字段不能均为空 + tag_value = actor.get("tag") + role_value = actor.get("role") + if (tag_value and not StringUtils.is_chinese(tag_value)) or \ + (role_value and not StringUtils.is_chinese(role_value)): + return True + + return False + + def update_peoples(self, item: dict, mediainfo: MediaInfo, info: Optional[RatingInfo] = None): + """处理媒体项中的人物信息""" + """ + item 的数据结构: + { + "Director": [{ + "id": 119824, + "filter": "director=119824", + "tag": "Christopher Nolan", + "tagKey": "5d776825880197001ec9038e", + "thumb": "https://metadata-static.plex.tv/people/5d776825880197001ec9038e.jpg" + }], + "Writer": [{ + "id": 119825, + "filter": "writer=119825", + "tag": "Christopher Nolan", + "tagKey": "5d776825880197001ec9038e", + "thumb": "https://metadata-static.plex.tv/people/5d776825880197001ec9038e.jpg" + }], + "Role": [{ + "id": 94414, + "filter": "actor=94414", + "tag": "Cillian Murphy", + "tagKey": "5d776825880197001ec90394", + "role": "J. Robert Oppenheimer", + "thumb": "https://metadata-static.plex.tv/e/people/ef539a37a16672a1a8d20f272b338c6b.jpg" + }, { + "id": 119826, + "filter": "actor=119826", + "tag": "Emily Blunt", + "tagKey": "5d7768265af944001f1f6689", + "role": "Kitty Oppenheimer", + "thumb": "https://metadata-static.plex.tv/7/people/7a290c167719a107b03c15922013d211.jpg" + }] + } + """ + if not mediainfo: + return + + title = info.title if info and info.title else item.get("title") + actors = item.get("Role", []) + trans_actors = [] + + # 将 mediainfo.actors 转换为字典,以 original_name、name、alias 和拼音为键 + actor_dict = {} + for actor in mediainfo.actors: + name = actor.get("name") + original_name = actor.get("original_name") + if name: + actor_dict[name] = actor + if StringUtils.is_chinese(name): + actor_dict[self.to_pinyin(name)] = actor + if original_name: + actor_dict[original_name] = actor + person_tmdbid = actor.get("id") + if person_tmdbid: + logger.info(f"{name} 正在获取 TMDB 人物信息") + person_detail = self.get_tmdb_person_detail(person_tmdbid=person_tmdbid) + if person_detail: + cn_name = self.get_chinese_name(person=person_detail) + if cn_name: + actor["name"] = cn_name + if person_detail.also_known_as: + actor["also_known_as"] = person_detail.also_known_as + for alias in person_detail.also_known_as: + actor_dict[alias] = actor + + # 使用TMDB信息更新人物 + for actor in actors: + if self.check_external_interrupt(): + return + tag_value = actor.get("tag") + role_value = actor.get("role") + if not tag_value: + continue + + # 批量赋值 original_name 属性,以便后续能够拿到原始值,避免翻译不一致时,豆瓣无法正确获取值 + original_actor = actor_dict.get(tag_value) + if original_actor: + actor["original_name"] = original_actor.get("original_name") + + if StringUtils.is_chinese(tag_value) and StringUtils.is_chinese(role_value): + logger.debug(f"{tag_value} 已是中文数据,无需更新") + trans_actors.append(actor) + continue + try: + trans_actor = self.update_people_by_tmdb(people=actor, people_dict=actor_dict) + if trans_actor: + trans_actors.append(trans_actor) + else: + trans_actors.append(actor) + except Exception as e: + logger.error(f"{title} TMDB 更新人物信息失败:{str(e)}") + + # 使用豆瓣信息更新人物 + if self._douban_scrape: + # 如果全部人物信息都已经是中文数据,无需使用豆瓣信息更新 + if all(StringUtils.is_chinese(actor.get("tag", "")) and StringUtils.is_chinese(actor.get("role", "")) for + actor in trans_actors): + logger.info(f"{title} 的人物信息已是中文,无需使用豆瓣信息更新") + else: + # 存在人物信息还不是中文数据,使用豆瓣信息进行更新 + logger.info(f"{title} 正在获取豆瓣媒体信息") + douban_actors = self.get_douban_actors(imdbid=mediainfo.imdb_id, + title=mediainfo.title, + mtype=mediainfo.type, + year=mediainfo.year, + season=mediainfo.season, + season_years=tuple(sorted(mediainfo.season_years.items()))) + if douban_actors: + # 将 douban_actors 转换为字典,以 latin_name 和 name 和拼音为键 + douban_actor_dict = {} + for actor in douban_actors: + name = actor.get("name") + latin_name = actor.get("latin_name") + if name: + douban_actor_dict[name] = actor + if StringUtils.is_chinese(name): + douban_actor_dict[self.to_pinyin(name)] = actor + if latin_name: + douban_actor_dict[latin_name] = actor + douban_actor_dict[self.standardize_name_order(latin_name)] = actor + + for actor in trans_actors: + if self.check_external_interrupt(): + return + try: + tag_value = actor.get("tag") + role_value = actor.get("role") + if StringUtils.is_chinese(tag_value) and StringUtils.is_chinese(role_value): + logger.debug(f"{tag_value} 已是中文数据,无需使用豆瓣信息更新") + continue + + updated_actor = self.update_people_by_douban(people=actor, + people_dict=douban_actor_dict) + if updated_actor: + actor.update(updated_actor) + except Exception as e: + logger.error(f"{title} 豆瓣更新人物信息失败:{str(e)}") + + if trans_actors: + try: + self.put_actors(item=item, actors=trans_actors) + logger.info(f"{title} 的中文人物信息更新完成") + except Exception as e: + logger.error(f"{title} 的中文人物信息更新失败:{str(e)}") + + def put_actors(self, item: dict, actors: list): + """更新演员信息""" + if not item or not actors: + return + + rating_key = item.get("ratingKey") + if not rating_key: + return + + # 创建actors_param字典 + actors_param = {} + for i, actor in enumerate(actors): + actor_index = f"actor[{i}]" + actor_tag_key = actor.get("tagKey", "") + + actors_param.update({ + f"{actor_index}.tag.tag": actor.get("tag", ""), + f"{actor_index}.tagging.text": actor.get("role", ""), + f"{actor_index}.tag.thumb": actor.get("thumb", ""), + f"{actor_index}.tag.tagKey": actor_tag_key + }) + + # if self._reserve_tag_key: + # actors_param[f"{actor_index}.tag.art"] = actor_tag_key + + params = { + "actor.locked": 1 if self._lock else 0 + } + params.update(actors_param) + + endpoint = f"library/metadata/{rating_key}" + self.plex.put_data( + endpoint=endpoint, + params=params, + timeout=self.timeout + ) + + def update_people_by_tmdb(self, people: dict, people_dict: dict) -> Optional[dict]: + """更新人物信息,返回替换后的人物信息""" + """ + people 的数据结构: + { + "id": 94414, + "filter": "actor=94414", + "tag": "Cillian Murphy", + "tagKey": "5d776825880197001ec90394", + "role": "J. Robert Oppenheimer", + "thumb": "https://metadata-static.plex.tv/e/people/ef539a37a16672a1a8d20f272b338c6b.jpg" + } + + people_dict 的数据结构: + [{ + "adult": False, + "gender": 2, + "id": 2037, + "known_for_department": "Acting", + "name": "基利安·墨菲", + "original_name": "Cillian Murphy", + "popularity": 48.424, + "profile_path": "/dm6V24NjjvjMiCtbMkc8Y2WPm2e.jpg", + "cast_id": 3, + "character": "J. Robert Oppenheimer", + "credit_id": "613a940d9653f60043e380df", + "order": 0 + }, { + "adult": False, + "gender": 1, + "id": 5081, + "known_for_department": "Acting", + "name": "艾米莉·布朗特", + "original_name": "Emily Blunt", + "popularity": 94.51, + "profile_path": "/5nCSG5TL1bP1geD8aaBfaLnLLCD.jpg", + "cast_id": 161, + "character": "Kitty Oppenheimer", + "credit_id": "6328c918524978007e9f1a7f", + "order": 1 + }] + """ + if not people_dict: + return None + + # 返回的人物信息 + ret_people = copy.deepcopy(people) + + # 查找对应的 TMDB 人物信息 + person_name = people.get("tag") + person_name_lower = self.remove_spaces_and_lower(person_name) + person_pinyin = self.to_pinyin(person_name) + + # 构建一个包含所有潜在键的列表后,再进行逐一获取 + potential_keys = [person_name, person_name_lower, person_pinyin] + person_detail = next((people_dict[key] for key in potential_keys if key in people_dict), None) + + # 从 TMDB 演员中匹配中文名称、角色和简介 + if not person_detail: + logger.debug(f"人物 {person_name} 未找到中文数据") + return None + + # 名称 + if StringUtils.is_chinese(person_name): + logger.debug(f"{person_name} 已是中文名称,无需更新") + else: + cn_name = self.get_chinese_field_value(people=person_detail, field="name") + if cn_name: + logger.debug(f"{person_name} 从 TMDB 获取到中文名称:{cn_name}") + ret_people["tag"] = cn_name + else: + logger.debug(f"{person_name} 从 TMDB 未能获取到中文名称") + + # 角色 + character = people.get("role") + if StringUtils.is_chinese(character): + logger.debug(f"{person_name} 已是中文角色,无需更新") + else: + cn_character = self.get_chinese_field_value(people=person_detail, field="character") + if cn_character: + logger.debug(f"{person_name} 从 TMDB 获取到中文角色:{cn_character}") + ret_people["role"] = cn_character + else: + logger.debug(f"{person_name} 从 TMDB 未能获取到中文角色") + + return ret_people + + def update_people_by_douban(self, people: dict, people_dict: dict) -> Optional[dict]: + """从豆瓣信息中更新人物信息""" + """ + people 的数据结构: + { + "id": 94414, + "filter": "actor=94414", + "tag": "Cillian Murphy", + "tagKey": "5d776825880197001ec90394", + "role": "J. Robert Oppenheimer", + "thumb": "https://metadata-static.plex.tv/e/people/ef539a37a16672a1a8d20f272b338c6b.jpg" + "original_name": "Cillian Murphy" + } + + people_dict 的数据结构 + { + "name": "丹尼尔·克雷格", + "roles": [ + "演员", + "制片人", + "配音" + ], + "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员", + "url": "https://movie.douban.com/celebrity/1025175/", + "user": null, + "character": "饰 詹姆斯·邦德 James Bond 007", + "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907", + "avatar": { + "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", + "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp" + }, + "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/", + "type": "celebrity", + "id": "1025175", + "latin_name": "Daniel Craig" + } + """ + if not people_dict: + return people + + # 返回的人物信息 + ret_people = copy.deepcopy(people) + + # 查找对应的豆瓣人物信息 + person_name = people.get("tag") + original_name = people.get("original_name") + also_known_as = people.get("also_known_as", []) + person_name_lower = self.remove_spaces_and_lower(person_name) + person_pinyin = self.to_pinyin(person_name) + + # 构建一个包含所有潜在键的列表后,再进行逐一获取 + potential_keys = [person_name, original_name] + also_known_as + [person_name_lower, person_pinyin] + person_detail = next((people_dict[key] for key in potential_keys if key in people_dict), None) + + # 从豆瓣演员中匹配中文名称、角色和简介 + if not person_detail: + logger.debug(f"人物 {person_name} 未找到中文数据") + return None + + # 名称 + if StringUtils.is_chinese(person_name): + logger.debug(f"{person_name} 已是中文名称,无需更新") + else: + cn_name = self.get_chinese_field_value(people=person_detail, field="name") + if cn_name: + logger.debug(f"{person_name} 从豆瓣中获取到中文名称:{cn_name}") + ret_people["tag"] = cn_name + else: + logger.debug(f"{person_name} 从豆瓣未能获取到中文名称") + + # 角色 + character = people.get("role") + if StringUtils.is_chinese(character): + logger.debug(f"{person_name} 已是中文角色,无需更新") + else: + cn_character = self.get_chinese_field_value(people=person_detail, field="character") + if cn_character: + # "饰 詹姆斯·邦德 James Bond 007" + cn_character = re.sub(r"饰\s+", "", cn_character) + cn_character = re.sub("演员", "", cn_character) + if cn_character: + logger.debug(f"{person_name} 从豆瓣中获取到中文角色:{cn_character}") + ret_people["role"] = cn_character + else: + logger.debug(f"{person_name} 从豆瓣未能获取到中文角色") + else: + logger.debug(f"{person_name} 从豆瓣未能获取到中文角色") + + return ret_people + + @cache_with_logging("plex_tmdb_person", "PERSON") + def get_tmdb_person_detail(self, + person_tmdbid: int) -> Optional[MediaPerson]: + """获取TMDB媒体信息""" + try: + person_detail = self.tmdb_chain.person_detail(int(person_tmdbid)) + return person_detail + except Exception as e: + logger.error(f"{person_tmdbid} TMDB 识别人员信息时出错:{str(e)}") + return None + + @cache_with_logging("plex_tmdb_media", "TMDB") + def get_tmdb_media(self, + tmdbid: int, + title: str, + mtype: MediaType = MediaType.TV) -> Optional[MediaInfo]: + """获取TMDB媒体信息""" + if mtype not in (MediaType.MOVIE, MediaType.TV): + logger.info(f"{title} 的媒体类型 {mtype.value} 不支持人物刮削,跳过处理") + return None + try: + mediainfo = self.chain.recognize_media( + mtype=mtype, + media_source=MediaSource.TMDB, + media_id=str(tmdbid), + ) + return mediainfo + except Exception as e: + logger.error(f"{title} TMDB 识别媒体信息时出错:{str(e)}") + return None + + @cache_with_logging("plex_douban_media", "TMDB") + def get_douban_actors(self, + title: str, + imdbid: Optional[str] = None, + mtype: Optional[MediaType] = None, + year: Optional[str] = None, + season: Optional[int] = None, + season_years: Any = None) -> List[dict]: + """获取豆瓣演员信息""" + douban_actors = [] + + if season_years and len(season_years) > 1: + for season, year in season_years: + actors = self.fetch_douban_actors(fetch_title=title, fetch_mtype=mtype, fetch_year=year, + fetch_season=season) + if actors: + douban_actors.extend(actors) + else: + actors = self.fetch_douban_actors(fetch_title=title, fetch_imdbid=imdbid, fetch_mtype=mtype, + fetch_year=year, + fetch_season=season) + if actors: + douban_actors.extend(actors) + + return douban_actors if douban_actors else None + + def fetch_douban_actors(self, fetch_title: str, + fetch_imdbid: Optional[str] = None, + fetch_mtype: Optional[MediaType] = None, + fetch_year: Optional[str] = None, + fetch_season: Optional[int] = None) -> Optional[List[dict]]: + """ + 获取演员信息 + :param fetch_title: 影片标题 + :param fetch_imdbid: IMDB ID,可选 + :param fetch_mtype: 媒体类型,可选 + :param fetch_year: 年份,可选 + :param fetch_season: 季,可选 + :return: 包含演员信息的字典列表,或 None + """ + try: + sleep_time = 5 + int(time.time()) % 7 + logger.debug(f"随机休眠 {sleep_time}秒 ...") + time.sleep(sleep_time) + doubaninfo = self.chain.match_doubaninfo(name=fetch_title, + imdbid=fetch_imdbid, + mtype=fetch_mtype, + year=fetch_year, + season=fetch_season, + raise_exception=True) + if doubaninfo: + item = self.chain.douban_info(doubaninfo.get("id"), raise_exception=True) or {} + if item: + return (item.get("actors") or []) + (item.get("directors") or []) + else: + logger.debug(f"未找到豆瓣详情:{fetch_title}({fetch_year})") + return None + else: + logger.debug(f"未找到豆瓣信息:{fetch_title}({fetch_year})") + return None + except Exception as e: + logger.error(f"{fetch_title} 豆瓣识别媒体信息时出错:{str(e)}") + return None + + @staticmethod + def get_chinese_name(person: MediaPerson) -> Optional[str]: + """ + 获取TMDB别名中的中文名 + """ + try: + # 如果人物名称已经是中文,则直接返回,不再繁简转换 + if StringUtils.is_chinese(person.name): + return person.name + also_known_as = person.also_known_as or [] + if also_known_as: + for name in also_known_as: + if name and StringUtils.is_chinese(name): + return zhconv_convert(name, "zh-hans") + except Exception as err: + logger.error(f"获取人物中文名失败:{err}") + return None + + @staticmethod + def get_chinese_field_value(people: dict, field: str) -> Optional[str]: + """ + 获取TMDB的中文名称 + """ + """ + people 的数据结构 + { + "adult": False, + "gender": 2, + "id": 2037, + "known_for_department": "Acting", + "name": "基利安·墨菲", + "original_name": "Cillian Murphy", + "popularity": 48.424, + "profile_path": "/dm6V24NjjvjMiCtbMkc8Y2WPm2e.jpg", + "cast_id": 3, + "character": "J. Robert Oppenheimer", + "credit_id": "613a940d9653f60043e380df", + "order": 0 + } + """ + try: + field_value = people.get(field, "") + if field_value and StringUtils.is_chinese(field_value): + return field_value + except Exception as e: + logger.error(f"获取人物{field}失败:{e}") + return None + + @staticmethod + def get_season_episode(item: Dict) -> str: + """获取剧集的季和集信息""" + season_number = item.get("parentIndex", "0") + episode_number = item.get("index", "0") + return f"s{str(season_number).zfill(2)}e{str(episode_number).zfill(2)}" + + @staticmethod + def get_rating_info(item: dict, parent_item: Optional[dict] = None) -> Optional[RatingInfo]: + """获取媒体项目信息""" + if not item: + return None + + key = item.get("ratingKey") + if not key: + return None + + rating_type = item.get("type") + title = item.get("title", key) + search_title = title + + # 获取 TMDB ID + tmdbid = (ScrapeHelper.get_tmdb_id(item=parent_item) if parent_item + else ScrapeHelper.get_tmdb_id(item=item)) + + # 如果是剧集,调整标题格式 + if rating_type == "episode": + parent_title = parent_item.get("title") if parent_item else item.get("grandparentTitle", title) + title = f"{parent_title} - {ScrapeHelper.get_season_episode(item=item)} - {title}" + search_title = parent_title + + return RatingInfo(key=key, + type=rating_type, + title=title, + search_title=search_title, + tmdbid=tmdbid) + + def list_rating_items(self, library: LibrarySection): + """获取所有媒体项目""" + if not library: + return [] + + endpoint = f"/library/sections/{library.key}/all?type={plexapi.utils.searchType(libtype=library.TYPE)}" + + response = self.plex.get_data(endpoint=endpoint, timeout=self.timeout) + datas = (response + .json() + .get("MediaContainer", {}) + .get("Metadata", [])) + + if len(datas): + logger.info(f"<{library.title} {library.TYPE}> " + f"类型共计 {len(datas)} 个") + + return datas + + def list_rating_items_by_added(self, added_time: int): + """获取最近入库媒体""" + endpoint = f"/library/all?addedAt>={added_time}" + response = self.plex.get_data(endpoint=endpoint, timeout=self.timeout) + datas = (response + .json() + .get("MediaContainer", {}) + .get("Metadata", [])) + return datas + + def list_episodes(self, rating_key, ): + """获取show的所有剧集""" + endpoint = f"/library/metadata/{rating_key}/allLeaves" + + response = self.plex.get_data(endpoint=endpoint, timeout=self.timeout) + datas = (response + .json() + .get("MediaContainer", {}) + .get("Metadata", [])) + + return datas + + def fetch_item(self, rating_key): + """ + 获取条目信息 + """ + endpoint = f"/library/metadata/{rating_key}" + response = self.plex.get_data(endpoint=endpoint, timeout=self.timeout) + datas = (response + .json() + .get("MediaContainer", {}) + .get("Metadata", [])) + return datas[0] if datas else None + + def fetch_all_items(self, rating_keys): + """ + 批量获取条目。 + :param rating_keys: 需要获取的条目的评级键列表。 + :return: 获取的所有条目列表。 + """ + endpoint = f"/library/metadata/{','.join(rating_keys)}" + response = self.plex.get_data(endpoint=endpoint, timeout=self.timeout) + items = (response + .json() + .get("MediaContainer", {}) + .get("Metadata", [])) + return items + + @staticmethod + def get_tmdb_id(item) -> Optional[int]: + """获取 tmdb_id""" + if not item: + return None + guids = item.get("Guid", []) + if not guids: + return None + for guid in guids: + guid_id = guid.get("id", "") + if guid_id.startswith("tmdb://"): + parts = guid_id.split("tmdb://") + if len(parts) == 2 and parts[1].isdigit(): + return int(parts[1]) + return None + + def check_external_interrupt(self, service: Optional[str] = None) -> bool: + """ + 检查是否有外部中断请求,并记录相应的日志信息 + """ + if self.event.is_set(): + logger.warning(f"外部中断请求,{service if service else 'Plex演职人员刮削'} 服务停止") + return True + return False + + @staticmethod + def to_pinyin(string) -> str: + """将中文字符串转换为拼音,没有空格分隔""" + return pypinyin.slug(string, separator="", style=pypinyin.Style.NORMAL, strict=False).lower() + + @staticmethod + def standardize_name_order(name) -> str: + """将英文名标准化为统一的顺序(姓在前,名在后)""" + parts = name.split() + if len(parts) == 2: + return f"{parts[1]} {parts[0]}" + return name + + @staticmethod + def remove_spaces_and_lower(string) -> str: + """去除字符串中的空格并转换为小写""" + return string.replace(" ", "").lower() + + @staticmethod + def extract_key_from_url(url: str) -> Optional[str]: + """从URL中提取key""" + match = re.search(r'/library/metadata/(\d+)', url) + return match.group(1) if match else None + + @staticmethod + def clear_cache(): + """清理插件用于识别结果的缓存分区。""" + for region in ("plex_tmdb_media", "plex_tmdb_person", "plex_douban_media"): + cache_backend.clear(region=region) diff --git a/plugins.v3/subscribeassistantenhanced/README.md b/plugins.v3/subscribeassistantenhanced/README.md new file mode 100644 index 00000000..05365dec --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/README.md @@ -0,0 +1,507 @@ +# 订阅助手(增强版) + +> **BETA 版本提示**:本插件仍处于测试阶段,可能调整订阅状态、洗版记录、下载任务和媒体文件。请先在可回滚环境中验证,再用于长期运行。 + +多场景管理订阅,实现订阅全生命周期管理。插件覆盖订阅待定、暂停、洗版、删种、远程切换订阅状态、完结守卫、识别增强和自动纠错等能力。 + +> 兼容 MoviePilot v3.0.0 及以上主程序版本 +> 适用于使用 TMDB 作为订阅数据源的环境,通知能力依赖 MoviePilot 已配置的通知渠道 + +## 默认状态速览 + +- 建议先保持默认:完结守卫使用平衡模式,剧集待定、下载待定和站点完结信号默认开启;站点集数探测需按需开启。 +- 默认开启但会删下载器文件:下载超时自动删除。不能接受自动删种和删除下载器源文件时,请先关闭本项;保留时需确认下载器可读、排除标签正确。 +- 默认关闭,按需启用:自动暂停、无下载处理策略、自动洗版、自动纠错、站点集数探测、识别增强和订阅文件清理。 +- 需要额外确认风险后再开启:清理整理记录范围、清理整理记录场景和无下载处理中的完成 / 删除动作;这些能力可能删除订阅、下载器种子、整理记录或媒体文件。 + +## 版本更新日志 + +- v0.7:MoviePilot V3 版本订阅助手(增强版)插件 +- v0.6.12:配置页滚动条跟随主程序样式,并恢复移动端修改项计数。 +- v0.6.11:修正全集洗版可能阻断新增集自动纠错的问题。 +- v0.6.10:完善配置页前端质量检查与构建门禁,提升构建稳定性。 +- v0.6.9:完善分集转全集的覆盖校验、优先级迁移与回滚,并优化新增剧集订阅重建和转换生命周期。 +- v0.6.8:优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。 +- v0.6.7:分集洗版转为全集洗版时保留已下载剧集、当前优先级和分集优先级,避免转换后订阅进度重置。 +- v0.6.6:修正洗版订阅继承手动总集数的问题,补充分集转全集完成快照,并仅在已有订阅覆盖最新 TMDB 集数时完成自动纠错。 +- v0.6.5:优化插件品牌图标与配置页视觉效果,提升界面一致性与操作辨识度。 +- v0.6.4:修正新增订阅时集数刷新事件可能中断插件处理的问题。 +- v0.6.3:优化站点扩集证据租约,在搜索消费前稳定保持目标集数,并在已消费或超时后抑制同值证据重复生效。 +- v0.6.2:修正站点集数探测在单集与更高集数资源并存时误判冲突的问题,确保订阅目标可按已发布集数扩展。 +- v0.6.1:优化移动端配置项排版与底部操作栏适配,提升小屏和独立运行模式下的视觉体验。 +- v0.6.0:重构插件配置页,优化桌面端与移动端的配置体验。 +- v0.5.13:优化订阅生命周期状态流转,集中处理暂停、待定、下载恢复和待定释放,减少多来源状态冲突和重复通知。 +- v0.5.12:新增订阅命中剧集待定时会延迟触发一次单订阅搜索,避免首次搜索被待定状态跳过。 +- v0.5.11:修正剧集开播日期兜底,按目标季/剧集组内首个有效分集日期判断,并在暂停原因变化时静默刷新,避免重复通知。 +- v0.5.10:修正下载完成判定,避免 qB 未完成任务或目标大小缺失时提前释放下载待定,并保留拆包下载目标大小判断。 +- v0.5.9:新增订阅补全页签,集中承载站点集数探测、暂停订阅补搜和无进展诊断,支持订阅长期无进展时按模式发送诊断提醒。 +- v0.5.8:新增站点集数探测与站点完结信号,支持按需使用主程序缓存资源扩展剧集目标并辅助完结守卫。 +- v0.5.7:修正剧集待定集数判断,优先使用季总集数信息,避免分集详情临时失败时误报 0 集。 +- v0.5.6:重构剧集完结证据与完成前观察策略,L 与已播完/冷却期信号同时满足时可直接完成,并避免完成前观察释放后反复进入待定。 +- v0.5.5:优化 L 信号风险判定细节,减少长季订阅误入完成前观察。 +- v0.5.4:修正剧集洗版清理范围,资源明确覆盖订阅目标后按同季清理旧整理记录,无法确认覆盖时跳过清理。 +- v0.5.3:优化识别增强二次识别,结合候选标题与副标题进行复核,并在审计日志记录实际识别标题和副标题。 +- v0.5.2:兼容新版 Transmission SDK 字段,并增强 qB Tracker 响应读取容错。 +- v0.5.1:修正低置信完结信号导致剧集待定反复释放的问题。 +- v0.5.0:新增暂停订阅低频补搜和下载命中恢复,支持外部暂停接管与恢复后同原因保护。 +- v0.4.9:修正待定与暂停状态流转,避免全集洗版跳过用户名/上映前暂停,并防止用户名、无下载等标记暂停被自动恢复误接管。 +- v0.4.8:电影普通订阅完成后自动洗版会继承本次完成资源优先级,已达顶档时跳过洗版并推送提示。 +- v0.4.7:缩短总集数变更速率默认窗口,修正旧波动缓存影响完结守卫释放的问题。 +- v0.4.6:适配主程序订阅进度刷新,统一洗版回填、完成和删种回滚口径。 +- v0.4.5:修正分集洗版误按洗版完成的问题,优化完成快照增集重建逻辑。升级后请重新配置洗版时限和订阅清理场景。 +- v0.4.4:新增识别增强候选准入,支持审计模式、策略配置、二次识别复核和风险通知。 +- v0.4.3:订阅清理分集洗版场景使用洗版订阅统一配置值,洗版清理通知统一显示为洗版。 +- v0.4.2:修复总集数变更速率记录兼容异常数据时可能导致订阅刷新失败的问题。 +- v0.4.1:梳理完成守卫与待定状态生命周期;待定参考变更速率默认关闭,可作为关闭完结守卫时的简化保护组合;普通订阅的完成守卫待定统一进入完成前观察释放;剧集待定和完成前观察通知文案分开,下载待定不单独通知,并保留总集数变化明细。 +- v0.4.0:剧集无任何可用开播排期时继续按上映前暂停保留,避免被集数不足待定接管;通知和日志原因统一显示为开播日期未知。 +- v0.3.9:通知和日志补充电影上映日期、剧集开播 / 下一集日期及相对当前天数;无下载处理会展示开播 / 上映日期和无下载截止日。 +- v0.3.8:待定状态重复命中同一来源时,只刷新待定原因和更新时间,不再重复发送进入待定通知。 +- v0.3.7:剧集上映前暂停在季日期和剧级首播日期缺失时,会按当前剧集组第 1 集播出日期判断开播窗口;若仍无有效日期则不暂停。分集洗版回填会合并媒体库已有集和订阅下载记录,保留低于当前起始集但仍在总集数范围内的已下载集优先级。 +- v0.3.6:完结守卫 L 信号完全复用主程序订阅目标缺集口径,事件缺少 `meta` 时按主程序 MetaInfo 构造入口补齐输入;特别季 S0 在缺集查询、媒体识别和播出边界中按合法季号处理,需要 MoviePilot v2.13.13 及以上主程序版本。 +- v0.3.5:完结守卫使用 SeasonScope 分集列表判断后续播出日期和播出日期未知的后续集,聚合 `next_episode_to_air` 不再参与完成裁决;可信 finale 遇到后续集时进入观察,Ended/Canceled 仍按剧级高置信完成。 +- v0.3.4:待定状态仅保留订阅生命周期保护,不再覆盖订阅总集数或锁定搜索范围。 +- v0.3.3:完结守卫复用主程序订阅目标满足口径,分集洗版在目标集任意版本已下载后可转为全集洗版;总集数波动只在接近完结时进入待定,可信末集 finale 可解除波动观察,低可信 L 信号继续进入完成前观察。 +- v0.3.2:订阅清理独立于洗版编排,支持普通订阅、分集洗版和洗版按集清理旧整理记录、源文件和入库前目标文件,单集清理通知会展示对应清理路径;订阅清理配置统一放入「订阅清理」页签。 +- v0.3.1:适配 MoviePilot 插件数据重置前事件;主程序清空插件数据前会恢复增强版持有的待定和自动暂停订阅,并在启用通知时发送恢复汇总。 +- v0.3.0:订阅状态由增强版统一收口处理,上映/播出暂停可覆盖待定,插件任务重置会恢复增强版持有的待定和生命周期暂停;主程序清空插件数据后待定订阅可自动恢复,缺少归属记录的暂停订阅继续按手工暂停保留。 +- v0.2.9:下载任务检查会拆分未取到实时任务信息、无法确认任务是否仍存在和连续缺失未达阈值等跳过原因;洗版媒体识别失败日志补充订阅上下文和检查建议;洗版文件清理日志补齐最终摘要;总集数变化相关待定和守卫日志改为用户可读描述。 +- v0.2.8:下载超时删种通知补齐低进度删除次数、种子详情、订阅图片和随机补搜时间,连续达到上限后保留种子进入人工保护;洗版文件清理通知补齐图片,自动创建失败和存量回填补齐结果通知,全集资源未覆盖目标范围时保留旧文件并告警。 +- v0.2.7:播出间隔暂停会结合媒体库实缺数量判断是否已追到当前已播最新集,手动整理导致订阅下载记录不完整时也能正确等待下一集;已有播出暂停在本轮数据无法明确恢复时会继续保留。 +- v0.2.6:重置插件数据前会恢复由增强版持有的待定订阅;通用巡检会自动修复有插件归属但已无有效来源的待定状态,不影响仍有活跃来源或无归属记录的订阅。 +- v0.2.5:洗版已有下载任务等待整理入库时不再选择其他资源;分集洗版继续按待定集串行,避免同一范围并发下载多个版本。 +- v0.2.4:洗版日志和通知明确区分洗版和分集洗版;过期的洗版清理事务由通用巡检统一回收,整理拦截阶段不再提前删除事务。 +- v0.2.3:电影订阅不再套用剧集集数待定规则;特别季 S0 会正常读取 TMDB 分集,避免在待定和订阅中反复切换。 +- v0.2.2:已存在集回填仅作用于分集洗版,洗版会继续等待新的整季资源;回填不会覆盖已有优先级进度,并会同步当前优先级。 +- v0.2.1:TMDB 聚合下一集为空、滞后或无效时,会从当前季或剧集组分集表中按首个未下载集补充排期;按集订阅在仍有后续播出日期时不会提前完成订阅。 +- v0.2.0:下载任务监听支持完成任务、手动删种、Tracker 关键字和低进度超时处理。完成任务会自动释放下载待定;删种后恢复缺集状态、清理任务记录并按配置补搜;新增洗版订阅会先回填已在库集,立即运行一次会遵守自动纠错开关。 +- v0.1.9:完结守卫支持关闭、严格、平衡和宽松模式,默认使用平衡策略,并新增订阅目标满足(L)信号;当天播出的下一集不再延迟完结,订阅 ID 复用不会继承旧任务状态。经完成事件完成的订阅会保存纠错快照,默认保留 180 天且按用户配置自动清理;自动恢复按季和剧集组精确匹配,洗版文件清理事务会自动失效。 +- v0.1.8:目标范围末集的 finale 播出日期晚于当前日期时不再提前判定完结,仍在播出的剧集会继续按下一集播出间隔暂停。 +- v0.1.7:洗版清理按 Sxx 季号匹配旧整理记录;删除关联旧种后先等待 5 秒并跨下载器确认 hash,查询失败最多等待 1 分钟、旧种仍存在最多等待 3 分钟,达到上限后继续下载。 +- v0.1.6:低置信完结先进入完成前观察,观察期内增集或信号变化会重新判定;观察到期后通过完成快照接管后续增集纠错。分集洗版目标集入库后可在整理完成事件中立即切换为全集洗版,并确认订阅目标满足。 +- v0.1.5:元数据巡检兼容 TMDB 集信息字典形态;新增历史季订阅不再因最后已播日期直接暂停;剧集订阅完成后自动创建洗版订阅,确保洗版清理按整季范围执行。 +- v0.1.4:统一多来源待定(P)管理。下载发起后保持待定,整理完成后只解除下载造成的待定,其他待定原因继续保留;分集洗版仅在存在多条关联分集下载历史时自动创建洗版订阅。 +- v0.1.3:优化订阅集数刷新和定时任务注册日志,新增订阅创建阶段可显示媒体名称、季号、TMDB ID 与触发场景。 +- v0.1.2:补齐订阅状态、洗版订阅创建、分集转全集、删种善后与自动纠错重建的通知及事件联动。 +- v0.1.0:多场景管理订阅,实现订阅全生命周期管理。 + +## 功能概览 + +- 状态字母:`R` 表示启用订阅,`P` 表示待定订阅,`S` 表示暂停订阅。 +- **完结守卫**:在主程序准备完成剧集订阅前,按固定信号顺序和所选模式复核当前目标集范围。普通剧集订阅和分集洗版由完结守卫裁决,电影和全集洗版交还主程序完成链路。 +- **订阅待定(P)**:P 是 MoviePilot 的待定状态。增强版按来源分别管理剧集待定(`pending_judge`)、下载待定(`download_pending`)和完成前观察(`guard_veto`)。开播日期加待定天数窗口、低集数,以及存在本季分集列表但均无播出日期且未被上映前暂停覆盖的场景属于剧集待定;下载未整理入库属于下载待定;普通剧集订阅和分集洗版的完成守卫观察分支属于完成前观察。全集洗版不进入剧集待定或完成前观察。自然释放时,所有待定来源都解除后才恢复启用(R);若命中上映、播出、无下载或用户名暂停,暂停会覆盖 P 并转入暂停恢复规则。 +- **订阅暂停**:支持按新增用户、上映时间和播出间隔自动暂停。分集洗版按普通按集订阅处理上映前和播出间隔暂停;全集洗版保留用户名自动暂停和上映前暂停,不参与播出间隔暂停。无下载策略可单独把长期无下载订阅标记暂停。 +- **订阅补全**:围绕订阅长期未补齐的场景做保守辅助。暂停订阅可按指定原因低频补搜,并在命中新下载任务时恢复订阅;站点集数探测可利用缓存资源辅助发现目标范围不足。 +- **下载管理**:下载待定和下载异常处理共用下载任务记录。开启「下载超时自动删除」后,会处理手动删种、Tracker 响应和下载进度停滞,并按配置补搜。 +- **订阅清理**:下载前按配置清理旧整理记录、源文件和入库前目标文件;普通订阅和分集洗版按集处理,剧集洗版确认覆盖后按同季处理。 +- **订阅洗版**:按配置自动创建洗版订阅,支持分集转全集、电影 / 剧集独立洗版时限、并行下载隔离和存量下载事实回填。 +- **识别增强**:在订阅候选入闸时复核目标身份、媒体形态和目标集数范围,降低同名真人 / 动漫、电影 / 剧集、错误 ID 或错误集数范围导致的误下载风险;审计日志会记录每个候选的判定结果。 +- **自动纠错**:订阅经 `SubscribeComplete` 完成时会保存总集数和订阅配置;开启自动纠错后周期性复查 TMDB,发现新增集数时按季和剧集组精确重建订阅并通知。无下载策略中的完成动作是清理性完成,不写入自动纠错快照。 + +## 不在范围内 +- 仅支持 **TMDB 数据源的电影和电视剧订阅**,不处理音乐,也不覆盖豆瓣 / Bangumi 等其它来源;订阅状态和生命周期语义可参考 [主程序订阅生命周期文档](https://github.com/jxxghp/MoviePilot/blob/v3/docs/subscribe-lifecycle.md) 以及 [#3330](https://github.com/jxxghp/MoviePilot/pull/3330)、[#477](https://github.com/jxxghp/MoviePilot-Frontend/pull/477)、[#6015](https://github.com/jxxghp/MoviePilot/pull/6015)。 +- 不替代 MoviePilot 主程序订阅识别、搜索召回和下载链路;识别增强只在订阅候选选择阶段做准入保护。 +- 不在下载发起后做识别增强硬拦截,已进入下载链路的资源仍由下载管理、订阅清理和主程序流程处理。 + +## 定时服务 + +| 服务名 | 触发方式 | 周期 / CRON | 说明 | +| --- | --- | --- | --- | +| 立即运行一次 | date(一次性) | 保存后延迟约 3 秒 | 串行执行元数据检查、下载任务检查、洗版订阅检查和通用巡检;启用「自动纠错」时同时执行自动纠错;仅勾选「立即运行一次」时注册 | +| 元数据检查 | interval | 由「元数据检查周期(小时)」决定,默认 3 小时 | 复核活动订阅元数据,处理上映前 / 播出间隔暂停的双向恢复,以及剧集待定进入 / 退出 | +| 下载任务检查 | interval | 由「下载检查周期(分钟)」决定,默认 10 分钟 | 定时检查下载任务状态;启用「自动待定下载中订阅」或「下载超时自动删除」时注册 | +| 洗版订阅检查 | cron | 由「洗版检查周期」决定,默认 `0 15 * * *` | 兜底推进分集转全集,并处理电影 / 剧集独立洗版时限;仅「洗版类型」不是关闭且「洗版检查周期」非空时注册 | +| 自动纠错 | interval | 由「自动纠错间隔(小时)」决定,默认 12 小时 | 复查完成快照中的总集数,发现 TMDB 增集后自动重建订阅;仅启用「自动纠错」时注册 | +| 通用巡检 | interval | 由「通用巡检周期(分钟)」决定,默认 30 分钟 | 依次执行待定释放、待定状态一致性检查、无下载处理、暂停订阅低频补搜、站点证据采样、删除记录清理、完成快照清理和订阅清理事务清理;各子任务互不阻断 | + +## 命令 / API + +| 类型 | 标识 | 说明 | +| --- | --- | --- | +| 命令 | `/subscribe_toggle` | 切换订阅启用 / 禁用状态;参数为订阅 ID 或完整订阅名,命中多个同名订阅时会返回 ID 列表并提示带 ID 重试 | +| API | `GET /api/v1/plugin/SubscribeAssistantEnhanced/summary` | 返回各功能启用状态、待定数量和记录中的下载任务数量;配置页通过登录态读取该粗粒度概况,不返回配置明细、路径、站点凭据或日志内容 | + +## 配置说明 + +> 新版配置页使用 MoviePilot Vue 联邦组件渲染,外观跟随主程序主题、圆角、阴影和透明主题设置;配置字段和默认值仍以本表为准。 + +看不懂的配置项可跳到下方的「深入说明」对照查看;不在「深入说明」里的项一般按默认值即可。 + +### 全局开关与运行 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 启用插件 | `enabled` | bool | `false` | 插件总开关 | 关闭后不注册定时任务 | +| 发送通知 | `notify` | bool | `true` | 关键事件是否推送通知 | 依赖 MoviePilot 通知渠道 | +| 重置数据 | `reset_task` | bool | `false` | 恢复增强版持有的待定和部分暂停后清空插件任务数据 | 一次性动作,执行后自动复位 | +| 立即运行一次 | `onlyonce` | bool | `false` | 保存后跑一轮全量巡检 | 一次性动作,执行后自动复位 | + +### 公共周期 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 下载检查周期(分钟) | `download_check_interval_minutes` | enum(分钟) | `10` | 定时检查下载任务状态 | 可选 5/10/15/30/60/120 分钟 | +| 元数据检查周期(小时) | `meta_check_interval_hours` | enum(小时) | `3` | 元数据巡检周期 | 可选 1/3/6/12/24 小时 | +| 洗版检查周期 | `best_version_cron` | CRON | `0 15 * * *` | 洗版巡检 CRON 表达式 | 默认每日 15:00 | +| [通用巡检周期(分钟)](#cfg-auto_check_interval_minutes) | `auto_check_interval_minutes` | enum(分钟) | `30` | 站点证据采样、待定释放、无下载处理和本地清理的周期 | 可选 10/20/30/60/120/240 分钟 | + +### 订阅清理 + +本页签同时包含下载管理和订阅清理。下载超时自动删除默认开启,命中超时、Tracker 或手动删种条件时会删除下载器种子及下载器源文件,并记录近期删除资源、按配置补搜;它不等同于订阅文件清理,默认不会删除整理记录或媒体库文件。清理整理记录默认关闭,只有「清理整理记录范围」不是 `no` 且选择了清理场景时才会删除旧整理记录、源文件或媒体库文件。 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 下载超时自动删除 | `download_monitor_enabled` | bool | `true` | 订阅下载超时将自动删除种子 | 关闭后不处理超时、Tracker 和手动删种;下载待定仍可释放 | +| 监听手动删除种子 | `manual_delete_listen` | bool | `true` | 监听下载器侧手动删种 | 连续确认种子不存在后才按手动删除处理 | +| 监听Tracker响应关键字 | `tracker_response_listen` | bool | `true` | Tracker 返回内容包含关键字时删种 | 关键字来自「Tracker响应关键字」 | +| 删除后触发搜索补全 | `auto_search_when_delete` | bool | `true` | 删种后触发订阅补搜 | 关闭后只记录删除并解除下载待定 | +| 跳过近期删除资源 | `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` | 带有这些标签的种子不自动删除 | 多个标签用逗号分隔 | +| Tracker响应关键字 | `default_tracker_response` | str(多行) | 内置默认列表 | 每行一个 Tracker 响应关键字,支持正则表达式 | 默认包含 `torrent not registered with this tracker`、`torrent banned` | +| [清理整理记录范围](#cfg-subscription_cleanup_history_type) | `subscription_cleanup_history_type` | enum | `no` | 限定订阅清理媒体类型 | 破坏性操作;可选 `no` / `all` / `movie` / `tv` | +| [清理整理记录场景](#cfg-subscription_cleanup_history_scenes) | `subscription_cleanup_history_scenes` | multi enum | `[]` | 选择下载前触发订阅清理的场景 | 可选普通订阅 / 洗版订阅 / 分集洗版 | + +### 订阅待定 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| [自动待定下载中订阅](#cfg-pending_download_enabled) | `pending_download_enabled` | bool | `true` | 存在进行中下载时自动标记待定,避免提前完成订阅 | 也用于洗版下载串行控制 | +| [自动待定剧集订阅](#cfg-pending_enhanced_enabled) | `pending_enhanced_enabled` | bool | `true` | 启用剧集待定的新进入判定 | 作用于按集剧集订阅,包含分集洗版;全集洗版不进入剧集待定;既有待定和完成前观察释放不依赖此开关 | +| 待定参考变更速率 | `pending_use_volatility` | bool | `false` | 让剧集待定消费 F 信号,接近完结且目标总集数近期变化时提前待定 | 依赖「变更速率信号」记录;默认关闭,通常只在关闭完结守卫的简化组合中开启 | +| 剧集待定天数 | `auto_tv_pending_days` | int(天) | `0` | 当前日期早于开播日期 + N 天时保持待定 | 配置为 0 表示不处理 | +| 剧集待定集数 | `auto_tv_pending_episodes` | int(集) | `1` | 集数不超过阈值时待定 | 配置为 0 表示不处理 | + +### 订阅暂停 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| [自动暂停订阅](#cfg-pause_enhanced_enabled) | `pause_enhanced_enabled` | bool | `false` | 启用用户名、上映前、播出间隔、外部暂停接管和暂停订阅下载命中恢复 | 不控制无下载动作本身;暂停补搜和下载命中恢复依赖此开关 | +| 自动暂停新增订阅的用户(逗号分隔) | `auto_pause_users` | str | 空 | 新增订阅用户在名单内时直接暂停 | 多个用户用英文逗号分隔 | +| 即将播出暂停天数 | `airing_pause_days` | int(天) | `30` | 下一集距离当前日期超过该天数时暂停 | 条件解除后可自动恢复 | +| 剧集上映暂停天数 | `tv_air_pause_days` | int(天) | `14` | 开播前 N 天外暂停剧集 | 配置为 0 表示不处理;规则开启且无可用开播日期时暂停等待 | +| 电影上映暂停天数 | `movie_air_pause_days` | int(天) | `7` | 上映前 N 天外暂停电影 | 配置为 0 表示不处理;规则开启且上映日期未知时暂停等待 | +| 剧集无下载处理天数 | `tv_no_download_days` | int(天) | `180` | 开播后超期无下载时处理 | 配置为 0 表示不处理 | +| 电影无下载处理天数 | `movie_no_download_days` | int(天) | `365` | 上映后超期无下载时处理 | 配置为 0 表示不处理 | +| [无下载处理策略](#cfg-no_download_actions) | `no_download_actions` | multi enum | `[]` | 无下载时执行的动作集合 | 可选暂停 / 完成 / 删除,按媒体类型区分 | + +### 订阅补全 + +订阅补全页签承载长期缺失订阅的辅助能力。站点集数探测只消费主程序缓存资源,辅助发现目标范围不足;暂停订阅低频补搜会在订阅保持暂停时触发单订阅搜索。 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| [站点集数探测](#cfg-site_total_probe_enabled) | `site_total_probe_enabled` | bool | `false` | 用站点缓存资源辅助发现目标集数不足 | 仅普通剧集和分集洗版;不请求站点 | +| [暂停订阅补搜场景](#cfg-paused_probe_reasons) | `paused_probe_reasons` | multi enum | `["no_download"]` | 选择允许低频补搜的暂停原因 | 默认补搜无下载暂停;下载命中恢复不依赖此多选 | +| [暂停满N天后补搜](#cfg-paused_probe_min_pause_days) | `paused_probe_min_pause_days` | int(天) | `14` | 暂停达到天数后开始补搜 | 配置为 0 表示不处理 | +| [补搜间隔(小时)](#cfg-paused_probe_interval_hours) | `paused_probe_interval_hours` | enum(小时) | `72` | 同一订阅两次补搜的最小间隔 | 可选 24 / 48 / 72 / 96 / 120 / 144 | + +### 订阅洗版 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| [洗版类型](#cfg-best_version_type) | `best_version_type` | enum | `no` | 控制自动洗版开关与媒体范围 | 可选 `no` / `all` / `movie` / `tv` / `tv_episode` | +| 电影洗版时限(天) | `best_version_movie_remaining_days` | int(天) | `0` | 电影洗版订阅超过时限后停止洗版 | 有下载记录时按最近活动时间计算;0 表示不限 | +| 剧集洗版时限(天) | `best_version_tv_remaining_days` | int(天) | `0` | 剧集全集洗版超过时限后停止洗版 | 有下载记录时按最近活动时间计算;0 表示不限 | +| [分集转全集](#cfg-best_version_episode_to_full) | `best_version_episode_to_full` | bool | `false` | 订阅目标满足后切为全集洗版 | 仅对分集洗版有意义 | +| [回填已存在集](#cfg-best_version_backfill_enabled) | `best_version_backfill_enabled` | bool | `false` | 把在库集写入订阅事实 | 仅新增或转分集洗版时处理 | +| [立即扫描存量并回填](#cfg-best_version_backfill_enabled) | `backfill_best_version_now` | bool | `false` | 对现有分集洗版订阅执行一次回填 | 一次性动作,执行后自动复位 | + +### 完结信号 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| [自动纠错](#cfg-verify_enabled) | `verify_enabled` | bool | `false` | 完成后复查 TMDB 增集 | 发现增集可重建订阅 | +| 站点完结信号 | `site_completion_evidence_enabled` | bool | `true` | 使用站点资源标题佐证完结信号 | 需要当前目标满足后才参与完成守卫 | +| 变更速率信号 | `volatility_enabled` | bool | `true` | 记录并启用目标总集数变动信号(F) | 完结守卫默认消费;剧集待定还需开启「待定参考变更速率」才消费 | +| 播出节奏信号 | `cadence_enabled` | bool | `true` | 用播出间隔辅助判断 | 绝对季等高风险目标范围会采用更保守的判断 | +| 按节奏加速释放 | `timeout_cadence_acceleration` | bool | `true` | 播出节奏到期时加速释放 | 与完成前观察释放联动 | +| [完结守卫模式](#cfg-completion_guard_mode) | `completion_guard_mode` | enum | `balanced` | 选择完成检查的复核强度 | 可选 `off` / `strict` / `balanced` / `loose` | +| 变更速率窗口(天) | `volatility_window_days` | int(天) | `3` | 统计集数变动的窗口 | 窗口内变动视为不稳定 | +| 节奏窗口系数 | `cadence_multiplier` | float | `2.5` | 播出间隔放大倍数 | 数值越大越宽松 | +| 节奏窗口下限(天) | `cadence_min_window_days` | int(天) | `7` | 节奏窗口最小天数 | 防止短间隔剧集过早释放 | +| 节奏参与最少集数 | `cadence_min_episodes` | int(集) | `3` | 参与节奏统计的最低集数 | 少于阈值不推算节奏 | +| 季冷却期(天) | `season_cooldown_days` | int(天) | `14` | 最后一集播出后的观察期 | 用于低置信季级信号 | +| 自动纠错间隔(小时) | `verify_interval_hours` | int(小时) | `12` | 完成后复查周期 | 仅自动纠错开启时生效 | +| 快照保留(天) | `verify_retention_days` | int(天) | `180` | 完成快照保留时间 | 到期自动清理,关闭自动纠错时仍执行 | +| 完成前观察天数 | `timeout_release_days` | int(天) | `7` | 完成前观察(`guard_veto`)的最长保留天数 | 只释放完成守卫写入的待定来源;低置信观察也使用此窗口,播出节奏已到期时可减半 | + +### 识别增强 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 识别增强模式 | `recognition_guard_mode` | enum | `off` | 控制订阅候选准入复核强度 | 可选 `off` / `audit` / `loose` / `balanced` / `strict` | +| 识别增强通知 | `recognition_guard_notify` | enum | `off` | 控制识别增强风险消息推送 | 可选 `off` / `summary` / `detail` / `all`;不影响审计日志 | +| 识别增强通知限频(秒) | `recognition_guard_notify_interval` | int | `3600` | 同订阅、同动作、同原因的通知限频 | 最小 60 秒 | +| 识别增强二次识别 | `recognition_guard_tmdb_recheck_mode` | enum | `balanced_strict` | 控制是否对候选做二次识别复核 | 可选 `off` / `all` / `strict` / `balanced_strict` | +| 识别增强缓存大小 | `recognition_guard_cache_maxsize` | int | `100000` | 二次识别结果缓存数量 | 最小 100 条 | +| 自定义识别规则 | `recognition_guard_custom_config` | YAML | 内置说明模板 | 覆盖动作、空候选保护和关键词分组 | 支持 actions / empty_pool / keywords | + +## 深入说明 + + +#### 完结守卫模式(`completion_guard_mode`) + +- **解决什么问题**:主程序认为订阅满足完成条件时,增强版会先检查当前季或剧集组的真实播出范围,避免总集数临时变动、绝对季断档、`mid_season` 标记或下载尚未入库时过早完成。 +- **边界**:完结守卫不会主动完成订阅。主程序发起完成检查时,完结守卫只判断当前订阅目标是否可以完成;站点完结信号用于佐证当前目标已经结束,也能在点映或站点提前出现整季资源时帮助提前完结。默认只开启「站点完结信号」时,站点更高集数只记录诊断,不扩展目标、不否决完成;只有开启「站点集数探测」、证据严格匹配且未命中高置信 E 信号时,站点证据显示当前目标偏小才会否决本次完成并等待集数目标扩展。 +- **模式选择**: + - `strict`(严格):高置信 E、独立中置信 I 立即完成;L+I 的当前目标完成证据和低置信 I/L 进入完成前观察。 + - `balanced`(平衡,默认):高、中置信立即完成;目标范围不少于 3 集且不属于高风险范围时,低置信 I/L 立即完成;1 至 2 集或高风险范围继续观察。 + - `loose`(宽松):普通范围的低置信 I 可立即完成;低置信 L 仍要求目标范围不少于 3 集且非高风险,季中、总集数不稳定、后续集检查和高风险范围仍会否决。 + - `off`(关闭):不注册完成守卫,由主程序决定订阅完成。 +- **模式与信号裁决**: + + | 信号 / 场景 | 关闭 `off` | 严格 `strict` | 平衡 `balanced` | 宽松 `loose` | + | --- | --- | --- | --- | --- | + | 下载任务仍在进行 | 由主程序决定 | 开启下载待定时拦截完成,等待整理入库 | 开启下载待定时拦截完成,等待整理入库 | 开启下载待定时拦截完成,等待整理入库 | + | M:`mid_season` 季中阶段 | 由主程序决定 | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | + | F down:目标总集数近期向下变化 | 由主程序决定 | 拦截完成,进入待定(P),不被 E 或 L+I 覆盖 | 拦截完成,进入待定(P),不被 E 或 L+I 覆盖 | 拦截完成,进入待定(P),不被 E 或 L+I 覆盖 | + | F:总集数近期仍在变化,但不是向下变化 | 由主程序决定 | 通常拦截;高置信 E 且未发现后续集时可放行 | 通常拦截;高置信 E 且未发现后续集,或 L 与已播完 / 冷却期共同形成当前目标完成证据时可放行 | 通常拦截;高置信 E 且未发现后续集,或 L 与已播完 / 冷却期共同形成当前目标完成证据时可放行 | + | 后续集检查:SeasonScope 存在播出日期晚于当前日期的分集,或最后已播集之后存在播出日期未知的分集(剧集已结束 / 已取消除外) | 由主程序决定 | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | + | E:剧集已结束 / 已取消 / 目标末集为 finale | 由主程序决定 | 放行完成 | 放行完成 | 放行完成 | + | I:中置信间接完结(如后续季已出现) | 由主程序决定 | 放行完成 | 放行完成 | 放行完成 | + | L+I:当前目标完成证据(L 与 `I:all_aired` / `I:cooldown` 同时命中) | 由主程序决定 | 进入完成前观察 | 放行完成 | 放行完成 | + | L+S:站点佐证当前目标完成 | 由主程序决定 | 进入完成前观察 | 放行完成 | 放行完成 | + | S:站点证据显示当前目标偏小(开启站点集数探测、证据严格匹配且未命中高置信 E) | 由主程序决定 | 拦截完成,等待集数目标扩展 | 拦截完成,等待集数目标扩展 | 拦截完成,等待集数目标扩展 | + | I:低置信间接完结,目标范围不少于 3 集且非高风险 | 由主程序决定 | 进入完成前观察 | 放行完成 | 放行完成 | + | I:低置信间接完结,1 至 2 集且非高风险范围 | 由主程序决定 | 进入完成前观察 | 进入完成前观察 | 放行完成 | + | I:高风险范围的低置信间接完结 | 由主程序决定 | 不生成完成信号,按无完结信号处理 | 不生成完成信号,按无完结信号处理 | 不生成完成信号,按无完结信号处理 | + | L:主程序口径下订阅目标已满足,不少于 3 集且非高风险 | 由主程序决定 | 进入完成前观察 | 放行完成 | 放行完成 | + | L:主程序口径下订阅目标已满足,1 至 2 集或高风险范围 | 由主程序决定 | 进入完成前观察 | 进入完成前观察 | 进入完成前观察 | + | 无完结信号且媒体库仍缺目标集 | 由主程序决定 | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | 拦截完成,进入待定(P) | + + “进入完成前观察”适用于普通剧集订阅和分集洗版,会写入 `guard_veto` 待定来源并记录观察窗口;窗口到期后先解除该待定来源,低置信信号仍匹配时会生成一次性放行标记,下一次完成检查才放行。全集洗版和电影洗版不由完结守卫裁决;启用完结守卫且开启下载待定时,完成检查会额外拦截仍在进行的下载,避免资源已进入下载链路但尚未整理入库。经 `SubscribeComplete` 完成的订阅会保存快照,模式只影响完成前是否放行。 +- **推荐组合**: + - 完成前守卫:`completion_guard_mode=balanced/strict/loose`、`volatility_enabled=true`、`pending_use_volatility=false`。F 信号在主程序准备完成订阅时由完结守卫处理,适合默认使用。 + - 简化保护:`completion_guard_mode=off`、`volatility_enabled=true`、`pending_use_volatility=true`、`verify_enabled=true`。元数据巡检会在接近完结且总集数波动时提前待定,自动纠错负责完成后增集补救;它不等价于完结守卫,若待定尚未写入,主程序仍可能先完成。 +- **典型联动**:与「变更速率信号」「播出节奏信号」「季冷却期(天)」「完成前观察天数」联动。普通剧集订阅和分集洗版进入完成前观察时会写入 `guard_veto` 待定来源并启动计时;全集洗版和电影洗版交还主程序完成链路。该释放链路不依赖「自动待定剧集订阅」。 +- **信号含义**: + - `SeasonScope` 表示当前订阅季或剧集组对应的统一目标集范围。信号引擎始终基于该范围判断,不直接用主季信息猜测绝对季范围。 + + | 标识 | 名称 | 含义 | + | --- | --- | --- | + | M | 季中阶段否决 | 最后已播集标记为 `mid_season` 时否决完成。 | + | F | 总集数稳定性 | 目标总集数在变更速率窗口内仍有变化时否决完成;total 向下变化属于硬否决,不被 E 或 L+I 覆盖;其他不稳定场景下,高置信 E 且未发现后续集时可解除该观察,L 与已播完 / 冷却期共同形成当前目标完成证据时,平衡 / 宽松模式可受控放行。 | + | E | 明确完结 | 剧集状态为已结束或已取消,或者目标范围末集带有可信 `finale` 标记。 | + | I | 季级间接信号 | 通过后续季、最后播出集、全量播出和季冷却期等信息判断;高风险范围更保守。 | + | L | 订阅目标满足 | 主程序合并口径下当前订阅目标已无剩余搜索 / 下载范围;平衡 / 宽松模式下单独 L 要求目标范围不少于 3 集且非高风险,L 与 `I:all_aired` / `I:cooldown` 可合成当前目标完成证据。 | + | S | 站点资源信号 | 从主程序 RSS / spider 缓存候选中提取同季资源标题和集数证据;开启站点集数探测、证据严格匹配且未命中高置信 E 时,更高集数会否决完成并等待目标扩展;等于当前目标的可靠 total 或完结标题只能与 L 合成当前目标完成证据。 | + | G | 播出节奏辅助 | 不直接确认完结,只用于完成前观察的释放节奏和超时加速。 | + | 后续集检查 | 硬保护 | SeasonScope 存在晚于当前日期的 `air_date`,或最后已播集之后存在播出日期未知的分集时,会阻止 finale、I 和高风险 G 放行;L 只在这些后续集超出当前订阅目标范围时阻断。剧集已结束 / 已取消时,稳定态按 E 信号完成,F 观察期内需未发现后续集才可覆盖 F。 | + | none | 无完结信号 | 完成前检查没有找到可确认完结的信号,会进入完成前观察。 | + + - **季中阶段否决(M)**:最后已播集标记为 `mid_season` 时,说明当前目标范围仍处于阶段中场,直接否决完结。 + - **总集数稳定性(F)**:检查总集数在变更速率窗口内是否稳定。近期仍在变化时否决完结;若最近一次变化是 total 向下变化,会作为硬否决进入观察,不能由 E 或 L+I 覆盖。其他不稳定场景下,剧集状态为已结束 / 已取消,或目标范围末集为可信 `finale`,且未发现后续集时,可确认完成;L 与 `I:all_aired` / `I:cooldown` 共同形成当前目标完成证据时,平衡 / 宽松模式可受控放行。单独 L 不覆盖 F,只有没有 F 等更高优先级否决时才按低置信 L 规则裁决。 + - **明确完结信号(E)**:剧集状态为已结束或已取消,或者目标范围末集带有 `finale` 标记时,确认完结。 + - **季级间接信号(I)**:通过后续季、最后播出集、全量播出和季冷却期等信息判断。绝对季等高风险目标范围进入更保守的分支,不会仅凭低置信冷却期放行。 + - **订阅目标满足信号(L)**:按主程序缺集裁剪口径确认当前订阅目标已无剩余搜索 / 下载范围时生成,不要求 TMDB 标记 `finale`。它会合并媒体库缺集结果与订阅下载历史;分集洗版按目标集是否已下载过任意版本判断,洗版仍按整季资源继续洗版。 + - **站点资源信号(S)**:通用巡检只读取主程序已有 RSS / spider 缓存并保存 24 小时站点证据,不请求站点,也不刷新主程序缓存。默认只开启「站点完结信号」时,站点更高 total 只记录 `S:site_conflict` 诊断;开启「站点集数探测」、证据严格匹配且未命中高置信 E 信号时,站点更高 total 会阻止本次完成并等待订阅目标扩展。站点可靠 total 等于当前目标或标题明确完结时,必须与 L 目标满足信号共同命中,才作为中置信当前目标完成证据。 + - **播出节奏辅助(G)**:根据已播集日期估算下一次更新窗口,用于辅助完成前观察释放和超时加速,不单独确认完结。 + - 完结守卫不使用 TMDB 聚合字段 `next_episode_to_air` 裁决完成;播出日期晚于当前日期的分集,以及播出日期未知但位于最后已播集之后的分集,均以当前 `SeasonScope` 分集列表为准。 + - 元数据信号链简写为 `M → F/E → I(±high_risk) → G`,完成守卫随后结合 L 和模式裁决。 + - 变更速率只观察目标总集数是否在「变更速率窗口(天)」内发生变化。窗口越长越保守;它不检查每集播出日期,也不能替代开播窗口和低集数保护。 + - 播出节奏根据已播集日期的典型间隔估算下一次更新窗口,等待时间取 `典型间隔 × 节奏窗口系数` 与「节奏窗口下限(天)」中的较大值。播出节奏本身不会直接判定完结,只用于辅助完成前观察释放。 + - 「节奏参与最少集数」要求至少有相应数量的已播集记录才计算节奏;样本不足或播出日期无效时,不使用节奏推算。 + - 「季冷却期(天)」只参与普通目标范围的低置信季级间接信号(I)判断。绝对季、中间出现 `mid_season` 或 `finale` 等高风险范围不会仅凭冷却期放行。 + - 「自动纠错」「自动纠错间隔(小时)」「快照保留(天)」处理完成后的增集。经 `SubscribeComplete` 完成的订阅会保存包含总集数的快照;自动纠错开关只控制是否定期复查和重建。无下载策略中的完成动作不会写入该快照。 + - 「完成前观察天数」「按节奏加速释放」只处理完成守卫写入的 `guard_veto` 待定来源。信号仍不稳定时,观察计时会重新开始;严格模式和未获平衡模式直接放行的低置信完结使用相同窗口完成观察。全集洗版和电影洗版不会写入该来源,分集洗版与普通剧集订阅一致。 + - 完成前观察期间不会因为任意 TMDB 字段变化直接放行:总集数增长只释放本轮 `guard_veto` 并等待重新判定;高置信 E、独立中置信 I,或平衡 / 宽松模式可放行的 L+I 会清理观察并允许完成;低置信同族切换继续观察,I:all_aired 与 I:cooldown 切换沿用计时,L/I 来源切换会重新计时;观察到期后,单低置信 I/L 记录一次性放行标记,严格模式下 L+I 观察到期只释放待定并等待重新判定。 + - `cadence_expired` 只表示播出节奏窗口已到期,不代表已完结;开启「按节奏加速释放」时,它只会把当前待定释放期限缩短为原来的一半。 +- **例子**:一部 12 集连载剧播到第 10 集时,TMDB 短暂把总集数写成 10,主程序可能认为已完成。开启完结守卫后,完成检查命中总集数稳定性(F)并写入 `guard_veto` 待定来源。后续 TMDB 回到 12 集后,完成前观察释放该来源,订阅恢复搜索或在下一次完成检查按新数据重新判断。 +- **常见误用**:关闭完结守卫后,不再在完成检查事件中即时否决完成;已提前写入的待定(P)和完成后自动纠错仍可运行,但若 P 尚未写入,主程序仍可能先完成。 + + +#### 识别增强(`recognition_guard_mode` / `recognition_guard_custom_config`) + +- **解决什么问题**:资源选择阶段会同时出现同名真人 / 动漫、电影版 / 剧集版、错误 TMDB ID、错误季集范围或缺少年份的候选。识别增强在候选进入下载前做准入复核,尽量把高风险候选挡在下载链路之外,并为每个候选写出可审计摘要。 +- **模式选择**: + - `off`(关闭):不做识别增强过滤。 + - `audit`(审计):只记录 would action,不移除候选,适合上线前观察。 + - `loose`(宽松):偏向放行,只把明确 hard veto 风险挡住。 + - `balanced`(平衡):内置推荐策略模板,缺少年份观察,目标范围明显过大先软拦截,明确身份冲突拦截;识别增强字段默认仍为 `off`,需要手动选择该模式才会生效。 + - `strict`(严格):缺少年份、用户普通黑名单和二次识别身份冲突均按拦截处理;整轮候选被清空时不会自动恢复软拦截。 +- **动作含义**: + - `inherit` 表示继承当前模式模板。 + - `observe` 只记录审计和可选通知,不移除候选。 + - `soft_block` 先移除候选;若整轮候选被清空且空候选保护允许,可降级为观察并恢复候选。 + - `block` 直接移除候选,空候选保护也不会恢复。 +- **可覆盖原因**:`actions` 支持 `missing_year`、`target_range_oversized`、`user_block` 和 `secondary_identity_conflict`,动作值可选 `inherit` / `observe` / `soft_block` / `block`。显式 ID 错配、目标范围完全不覆盖、电影 / 剧集明确互串、动画 / 真人明确互串和 `hard_block` 关键词属于 hard veto,不受 allow 或空候选保护抵消。 +- **空候选保护**:`empty_pool.policy` 可选 `recover_soft_block` / `never_recover`。`recover_soft_block` 只恢复可恢复的 `soft_block`,不会恢复 `block` 或 hard veto;`empty_pool.non_recoverable_codes` 可把指定原因码排除在恢复范围外。 +- **关键词分组**:`keywords.allow` 只抵消非 hard veto 风险;`keywords.block` 的动作由当前模式或 `actions.user_block` 决定;`keywords.hard_block` 总是 hard veto。`live_action`、`animation`、`movie`、`tv` 等内置证据分组取消注释后表示替换该组,未配置的分组继续使用内置默认。 +- **二次识别**:`recognition_guard_tmdb_recheck_mode` 控制二次识别触发范围,可选 `off` / `all` / `strict` / `balanced_strict`。二次识别无结果或失败按 fail-open 处理,只避免二次识别失败本身造成误拦截,不覆盖用户策略拦截、hard veto 或明确范围 veto。二次识别结果会按 `recognition_guard_cache_maxsize` 缓存,减少重复识别。 +- **通知与审计**:`recognition_guard_notify` 可选 `off` / `summary` / `detail` / `all`。`summary` 只推送聚合计数,`detail` 推送拦截和软拦截明细,`all` 还包含观察项。通知限频由 `recognition_guard_notify_interval` 控制;审计日志不受通知开关和限频影响,并会脱敏链接、Cookie、token、passkey、密码和本地路径。 +- **常见误用**:识别增强不替代主程序搜索召回和媒体识别,也不会在下载发起后再硬拦截资源。初次启用建议先用 `audit` 或 `balanced` 观察审计日志,再把明确不希望下载的规则写入 `keywords.block` 或 `keywords.hard_block`。 + + +#### 自动待定下载中订阅(`pending_download_enabled`) + +- **解决什么问题**:订阅已经选中资源但还没整理入库时,主程序可能先触发完成检查。开启后,增强版会在资源下载事件(`ResourceDownload`)写入无 hash 下载待定,下载器任务建立事件(`DownloadAdded`)再补齐真实 hash。完结守卫启用时,完成检查看到仍有下载任务会否决完成;资源选择事件(`ResourceSelection`)也会避免同一集并行下载多个洗版版本。 +- **什么时候开或关闭**:普通追剧和洗版建议保持开启。若下载器任务与订阅完成完全由外部流程管理,不希望插件用下载中状态影响完成判断,可以关闭。关闭后不再写下载待定,也不再用洗版下载待定做资源串行过滤。 +- **典型联动**:下载待定会把订阅置为待定(P)。「下载任务检查」在开启本项或「下载超时自动删除」时注册;只开启「自动待定下载中订阅」时也会注册「下载任务检查」,但只释放下载待定和本地任务,开启「下载超时自动删除」后才处理下载超时、Tracker 命中和手动删种。若剧集信息待确认或完成守卫否决仍然有效,订阅继续保持待定(P)。自然释放时,所有待定原因解除后才恢复启用(R);若后续命中暂停策略,则转为暂停状态处理。 +- **内部状态**:下载待定、剧集信息待确认和完成守卫否决分别记录为 `download_pending`、`pending_judge` 和 `guard_veto`,用于避免不同原因互相误释放。 +- **常见误用**:下载待定不是长期暂停。未建立下载器任务的待定只覆盖资源下载到下载器任务建立之间的短窗口,超过宽限仍未建立任务时会自动释放;下载器返回任务已完成或确认任务已不存在时,也会清理本地任务并释放下载待定。 + + +#### 自动待定剧集订阅(`pending_enhanced_enabled`) + +- **解决什么问题**:剧集处在开播日期加待定天数窗口内、集数很少,或存在本季分集列表但均无播出日期且未被上映前暂停覆盖时,订阅容易按不完整范围提前完成。剧集待定会让订阅暂时进入待定(P),阻止主程序提前完成;搜索范围和总集数刷新继续跟随主程序。总集数近期变化默认由完结守卫处理,只有开启「待定参考变更速率」时才会被剧集待定提前消费。 +- **什么时候开或关闭**:追连载剧集、新番、绝对季时建议保持开启。只订阅已完结剧或不希望插件按剧集待定规则写入新 P 状态时再关闭。关闭后不会新进入 `pending_judge`,但不会禁用下载待定、完成前观察释放或孤儿 P 状态修复。 +- **典型联动**:「剧集待定天数」「剧集待定集数」「待定参考变更速率」决定进入待定的具体条件,「通用巡检周期(分钟)」决定释放检查频率。变更速率只能识别总集数波动,且只在接近完结时触发剧集待定,不能替代开播窗口和低集数保护。 +- **字段分工**:「剧集待定天数」处理开播日期加 N 天的窗口,「剧集待定集数」处理只播出 1 集等低集数阶段,「待定参考变更速率」处理接近完结时的 F 信号提前待定。完全无可用开播排期时,上映前暂停会优先接管;TMDB 未给总集数时,插件不再注入虚拟总集数,订阅目标范围仍由主程序刷新。 +- **常见误用**:待定不会加速完结,也不会缩小搜索范围。它只阻止主程序在待定原因解除前自动完成订阅;关闭本开关也不等于关闭所有 P 状态来源。 + + +#### 自动暂停订阅(`pause_enhanced_enabled`) + +- **解决什么问题**:对暂时不该继续搜索的订阅写入暂停状态,减少无意义搜索。上映前和播出间隔暂停会在条件解除后自动恢复,用户名暂停属于标记暂停,不会被元数据巡检自动恢复。 +- **什么时候开或关闭**:需要自动管理连载空窗、上映前订阅和指定用户新增订阅的环境建议开启。习惯手动管理订阅状态时可以关闭。 +- **典型联动**:「即将播出暂停天数」「剧集上映暂停天数」「电影上映暂停天数」控制可自动恢复的暂停。上映前规则开启后,电影上映日期未知或剧集无可用开播日期时也会暂停等待;分集洗版与普通按集订阅一致,全集洗版保留用户名自动暂停和上映前暂停,不参与播出间隔暂停和剧集待定。无下载动作本身不受本开关控制,但外部暂停接管、暂停订阅低频补搜、下载命中恢复和恢复后的同原因保护都依赖本开关。 +- **常见误用**:下载超时删种不会暂停订阅。近期删除过的同一资源会被跳过,内部通过删除指纹匹配,后续是否补搜由「删除后触发搜索补全」决定。 + +> 以下能力属于「订阅补全」页签:站点集数探测辅助发现目标范围不足,暂停订阅低频补搜负责保守触发单订阅搜索。 + + +#### 站点集数探测(`site_total_probe_enabled`) + +- **解决什么问题**:主程序缓存的 RSS / spider 资源标题可能先出现更高集数,增强版可用这些证据发现当前订阅目标范围可能偏小。 +- **当前边界**:只消费主程序已有缓存,不主动请求站点,不直接改订阅规则或下载任务;命中后仍由订阅生命周期和完结守卫处理。 + + + + +#### 暂停订阅低频补搜(`paused_probe_reasons` / `paused_probe_min_pause_days` / `paused_probe_interval_hours`) + +- **解决什么问题**:暂停订阅不会被主程序常规定时搜索处理。开启自动暂停后,增强版可在通用巡检中为指定暂停原因安排低频单订阅搜索,等价于代用户对该暂停订阅补搜一次。 +- **场景选择**:可选无下载、上映/开播、播出间隔、用户名、外部暂停和全部。`all` 表示当前和未来所有暂停原因,也包含外部暂停;未选择 `all` 时只匹配已勾选的具体原因。 +- **时间门槛**:「暂停满N天后补搜」控制首次允许补搜的最小暂停天数,配置为 0 时不处理;「补搜间隔(小时)」控制同一订阅两次补搜安排的最小间隔,可选 24 / 48 / 72 / 96 / 120 / 144 小时。 +- **执行方式**:每轮通用巡检最多安排 10 个候选,每个候选随机延迟 1 至 5 分钟执行,搜索时订阅保持暂停状态。已有进行中下载的暂停订阅会跳过,洗版订阅不会被额外排除。 +- **下载命中恢复**:主动补搜没有通知;无命中和搜索异常也不通知。只要开启自动暂停,暂停订阅出现新的下载任务时会恢复为订阅中并通知。非外部暂停恢复后,48 小时内不会因同一暂停原因再次自动打回;外部暂停恢复后,用户再次手动暂停仍会立即生效。 +- **外部暂停**:用户手工暂停、其他插件暂停、主程序 UI 暂停或旧的无归属 `S` 状态会被记录为 `external`。外部暂停优先级最高,不会被上映、播出、无下载或用户名暂停覆盖;首次登记不发送暂停通知。 +- **常见误用**:只勾选「暂停订阅补搜场景」但未开启「自动暂停订阅」不会生效。未选择任何场景时不主动补搜,但自动暂停开启时仍会接管外部暂停,并可在下载命中时恢复暂停订阅。 + + +#### 无下载处理策略(`no_download_actions`) + +- **解决什么问题**:订阅在上映或开播后长时间没有任何下载记录时,可以自动暂停、完成或删除,避免无效订阅长期占用搜索。该策略按媒体类型处理普通订阅、分集洗版、全集洗版和电影洗版,不受洗版形态影响。 +- **什么时候开或关闭**:确认订阅源稳定、缺下载通常代表资源不可用时再开启。刚导入数据或下载历史不完整时先保持默认空策略。 +- **动作选择**:同一媒体类型按表单保存顺序取第一个匹配动作。内部动作值包括 `pause_movie`、`pause_tv`、`complete_movie`、`complete_tv`、`delete_movie` 和 `delete_tv`。 +- **典型联动**:必须同时配置「电影无下载处理天数」或「剧集无下载处理天数」,天数为 0 时不处理。选择暂停动作会写入 `no_download` 标记暂停;如果自动暂停开启,后续命中新下载会恢复订阅,并在 48 小时内阻止同一无下载原因再次自动暂停。 +- **常见误用**:只选择策略但两个天数字段都保持 0,不会触发任何动作。选择完成动作会写入主程序完成历史并删除订阅,但不会写入自动纠错快照;选择删除动作会直接删除订阅,不写完成历史,也不会生成自动纠错快照。 + + +#### 下载超时进度阈值(`download_progress_threshold`) + +- **解决什么问题**:下载超时不是按总耗时直接删除,而是在「下载超时时间(分钟)」窗口内观察进度增量。低于阈值才算停滞,连续多次停滞后才会删种。 +- **什么时候调整**:普通订阅保持默认 10%。大体积 4K、蓝光原盘或慢种较多时可降到 1 至 2,刷流或希望快速淘汰坏种时可适当提高。 +- **典型联动**:「下载连续超时重试次数」控制删除前观察多少轮。删种后会记录近期删除资源、解除下载待定,并恢复本次下载影响的洗版优先级。内部使用删除指纹和下载前优先级基线保存这些信息。是否补搜由「删除后触发搜索补全」决定,订阅不会因此暂停。 +- **常见误用**:阈值过高会把慢种当作停滞,阈值过低配合很短窗口会让坏种长期占位。 + + +#### 通用巡检周期(分钟)(`auto_check_interval_minutes`) + +- **解决什么问题**:站点证据采样、待定释放、待定状态一致性检查、无下载处理和本地过期数据清理统一由一个服务按此周期执行。 +- **什么时候调整**:默认 30 分钟。10 / 20 分钟适合追更敏感、订阅量较少且希望更及时采样站点缓存的环境;订阅量较大时建议使用 30 分钟或更长。 +- **典型联动**:每轮依次执行子任务:待定释放、待定状态一致性检查、无下载处理、暂停订阅低频补搜、站点证据采样、删除记录清理、完成快照清理和订阅清理事务清理。删除记录清理受「下载超时自动删除」控制;完成快照清理不要求开启「自动纠错」。 +- **常见误用**:它不控制下载任务、元数据和洗版检查周期。 + + +#### 洗版类型(`best_version_type`) + +- **解决什么问题**:控制普通订阅完成后哪些媒体类型可以自动进入洗版流程,同时作为自动洗版总入口。日常使用时通过这个字段选择自动洗版范围。 +- **可选值** + - `no` 表示关闭,不自动创建新的洗版订阅,也不注册洗版订阅检查任务。 + - `all` 表示电影和剧集都可自动洗版。 + - `movie` 表示仅处理电影。 + - `tv` 表示仅处理剧集整季。 + - `tv_episode` 表示仅处理分集下载的剧集。只有关联到多条分集下载历史时才自动洗版,单次合集或全集包下载完成不会自动创建洗版订阅。 +- **已有洗版订阅**:关闭后不会执行洗版订阅检查。如需继续推进已有洗版订阅,需要选择对应媒体范围。 +- **典型联动**:「洗版类型」不是 `no` 时,普通订阅完成后会按范围自动创建洗版订阅。当前订阅本身已经是洗版订阅时不会重复创建。 +- **常见误用**:把「洗版类型」保持为关闭时,既不会新增洗版订阅,也不会注册洗版订阅检查任务。 + + +#### 回填已存在集(`best_version_backfill_enabled` / `backfill_best_version_now`) + +- **解决什么问题**:媒体库里已有的集和订阅下载记录里已下载的集会交给主程序回填入口写入订阅事实,主程序随后刷新缺集和洗版进度,后续分集洗版巡检不再重复下载这些已确认集。普通订阅转分集洗版、新增分集洗版订阅和手工扫描存量都可触发回填。 +- **什么时候开或关闭**:已有媒体库内容较多、准备启用分集洗版时建议开启。默认关闭,避免首次启用时批量写入存量订阅事实。 +- **典型联动**:「立即扫描存量并回填」只处理已经存在的分集洗版订阅,执行完成会自动复位。「分集转全集」可在回填后继续按刷新后的订阅进度判断是否已全量在库。 +- **常见误用**:洗版等待新的整季资源,不参与按集回填。不要把「立即扫描存量并回填」当成长期开关;它是一次性动作,重复勾选只会重复扫描同一批订阅。 + + +#### 分集转全集(`best_version_episode_to_full`) + +- **解决什么问题**:分集洗版在媒体库实际覆盖完整目标范围后,可以切换为全集洗版,后续用整季包统一做种和继续洗版。 +- **什么时候开或关闭**:长期追剧并希望最终获得整季高质量版本时开启。电影或纯整季洗版不需要开启。 +- **典型联动**:「洗版类型」启用时才会注册洗版巡检。整理完成事件与洗版巡检共用同一转换检查:目标范围有效、没有下载待定,且媒体库实际覆盖全部目标集。其他待定来源不会单独阻断转换。转为全集洗版后会按「剧集洗版时限(天)」参与超时终止,分集洗版本身不适用该时限。 +- **常见误用**:完整覆盖只表示可以切换模式,不代表转换后一定不会下载。关联分集下载历史为零或一条时保留当前全集准入基线,避免已完结剧直接下载一个完整包后重复下载;存在多条分集下载历史时,全集准入基线从 `0` 开始,以便后续下载整包统一做种。历史查询失败时本轮不会转换,会等待下次巡检。 + + +#### 清理整理记录范围(`subscription_cleanup_history_type`) + +> 破坏性操作,默认 `no` 关闭。选择 `all` / `movie` / `tv` 后,命中场景的订阅下载可能删除旧整理记录的源文件、媒体库目标文件,并发送文件删除事件移除历史下载的旧种子。 + +- **解决什么问题**:订阅下载前先清理既有版本整理记录和文件,避免多个版本并存。 +- **什么时候开或关闭**:确认媒体库路径、硬链接关系和既有版本清理策略都正确后,再把范围从 `no` 改为需要的媒体类型。对分集清理、移动模式不熟悉或媒体库没有备份时保持 `no`。 +- **典型联动**:还必须在「清理整理记录场景」中选择普通订阅、洗版订阅或分集洗版。普通订阅和分集洗版会按本次目标集过滤旧整理记录;剧集洗版订阅会先校验资源覆盖订阅目标范围,确认覆盖后按同季清理。 +- **常见误用**:不要把它当成普通的记录清理选项。该能力会删除文件,风险高于只修改订阅状态。 + + +#### 清理整理记录场景(`subscription_cleanup_history_scenes`) + +- **解决什么问题**:控制哪些订阅下载场景允许进入清理事务,避免只打开媒体范围后误覆盖所有下载。 +- **可选值** + - `normal` 表示普通订阅下载前清理本次目标集对应的旧整理记录。 + - `best_version` 表示洗版订阅下载前确认资源覆盖订阅目标范围,剧集确认后清理同季旧整理记录。 + - `best_version_episode` 表示分集洗版下载前清理本次目标集对应的旧整理记录。 +- **典型联动**:只有「清理整理记录范围」和场景同时命中时才会执行。清理顺序为保存快照、删除旧源文件、发送文件删除事件、删除整理记录、等待旧下载任务释放,整理入库前再删除旧媒体库目标文件。清理事务超过 36 小时未被整理拦截消费时,会由通用巡检清理失效记录。 +- **常见误用**:只选择场景但范围保持 `no` 不会执行清理;只设置范围但不选择场景也不会执行清理。 + + +#### 自动纠错(`verify_enabled`) + +- **解决什么问题**:订阅完成后 TMDB 可能继续新增集数。插件会在 `SubscribeComplete` 事件中保存包含完成时总集数和订阅配置的完成快照;开启自动纠错后按周期复查,发现当前集数大于完成时集数时,会从下一集开始重建订阅。 +- **什么时候开或关闭**:长篇动画、分割放送、绝对季和经常修改集数的剧集建议保持开启。只订阅电影或不希望自动重建订阅时可以关闭。 +- **典型联动**:「自动纠错间隔(小时)」控制复查频率,「快照保留(天)」控制复查窗口。自动纠错不依赖完结守卫,订阅完成事件(`SubscribeComplete`)是唯一的完成快照入口。恢复时按 TMDB ID、季号和剧集组精确匹配。快照过期清理由通用巡检执行,即使没有开启自动纠错也会按保留天数清理。 +- **常见误用**:完成快照过期后不会再复查。希望长期关注新增集数时,需要调大「快照保留(天)」。 + +## 使用步骤 + +1. 在插件市场安装「订阅助手(增强版)」。 +2. 确认 MoviePilot 订阅使用 TMDB 数据源;如需消息提醒,先配置通知渠道。 +3. 按需要开启「启用插件」,再选择「完结守卫模式」「自动暂停订阅」「自动纠错」等自动状态管理能力;完结守卫默认使用平衡模式,「下载超时自动删除」默认开启,启用前建议确认下载器可读且排除标签正确。 +4. 按需要配置洗版,在界面中把「洗版类型」从「关闭」(`no`)改为目标范围。 +5. 需要为已有洗版订阅补充存量记录时,先开启「回填已存在集」,再勾选一次「立即扫描存量并回填」。 +6. 勾选「立即运行一次」保存,观察日志中的元数据、下载任务检查、洗版巡检、自动纠错和通用巡检记录。 +7. 运行一段时间后,再考虑把「清理整理记录范围」从 `no` 改为目标范围,并在「清理整理记录场景」中选择需要清理的订阅下载场景。 + +## 注意事项 / 已知风险 + +- **数据与文件风险**:插件会修改订阅状态、删除下载器种子和下载器源文件、写订阅历史和删除订阅。「清理整理记录范围」不是 `no` 且配置了场景时还可能删除旧整理记录、源文件和媒体库文件。 +- **仅支持 TMDB 数据源**:非 TMDB 订阅的集数、播出状态和完成判断可能不可靠。 +- **识别增强边界**:识别增强只在资源选择阶段做候选准入保护,不替代主程序搜索召回、媒体识别或下载后的纠错;全局通知关闭或内部限频不影响审计日志。 +- **订阅文件清理默认关闭**:不确认硬链接、媒体库路径和既有版本清理策略前,不要把「清理整理记录范围」改出 `no`,也不要选择清理场景;这不影响默认开启的下载超时自动删除下载器种子和源文件。 +- **标记暂停不会按元数据自动恢复**:无下载和用户名暂停不会被元数据巡检恢复;开启自动暂停后,如果暂停订阅命中新下载任务,会恢复为订阅中并按原暂停原因应用短期保护。上映前和播出间隔暂停仍可按元数据条件双向恢复。 +- **一次性动作会自动复位**:「重置数据」「立即运行一次」「立即扫描存量并回填」执行后会自动取消勾选。 +- **运行概况入口**:插件不提供详情页和仪表盘部件,可通过日志或 `/summary` API 查看运行概况;`/summary` 只提供概览,具体待定来源以插件日志和任务记录为准。 + +## 故障排查 + +- 主日志:`MoviePilot/config/logs/moviepilot.log` +- 插件日志:`MoviePilot/config/logs/plugins/subscribeassistantenhanced.log` +- 建议过滤词:`订阅助手(增强版)`、`subscribeassistantenhanced`、`注册定时任务`、`信号引擎`、`完成守卫`、`待定(P)`、`元数据巡检`、`资源选择`、`资源下载`、`下载器任务已建立`、`整理完成`、`下载任务检查`、`下载监控`、`种子删除处理`、`近期删除资源清理`、`自动洗版`、`洗版巡检`、`自动纠错`、`完成后验证` 和 `完成快照清理` +- 常见问题: + - 没有看到定时任务 → 确认「启用插件」为开;再看日志里的“注册定时任务”摘要。 + - 普通订阅完成后没有自动洗版 → 确认「洗版类型」不是「关闭」(`no`),且媒体类型落在所选范围内;若选择剧集(分集下载),还需存在多条关联分集下载历史。 + - 分集洗版没有转全集 → 先看「洗版检查周期」是否有效,再确认订阅本身是分集洗版、媒体识别成功、目标集是否已满足。若刚整理完成,也检查 `TransferComplete` / “整理完成”日志是否已经触发即时转全集。 + - 待定长期不释放 → 先看日志中的待定来源。`pending_judge` 表示剧集待定,需要剧集待定条件解除;`download_pending` 表示下载待定,会在整理完成、下载器任务完成或失效、下载异常处理、无 hash 宽限到期后解除;`guard_veto` 表示完成前观察,由「完成前观察天数」释放。数据仍在变化时会重置完成前观察计时;低置信完结需要观察到期后由下一次完成检查消费同一信号才会完成。若订阅从 P 变为 S,请同时检查是否被上映、播出、无下载或用户名暂停覆盖。 + - 下载超时没有删种 → 确认「下载超时自动删除」开启、下载器状态能读取、种子没有「排除标签」、进度窗口内增量是否超过阈值。下载监控摘要中的“未取到实时任务信息”“无法确认任务是否仍存在”“连续缺失未达阈值”分别表示实时任务读取缺失、存在性确认不可判定和手动删种去抖保护未到阈值。 + - 手动删种没有触发处理 → 需要下载器可达且连续确认种子不存在;单次连接抖动不会立即当作手动删除。 + - Tracker 关键字不生效 → 确认「监听Tracker响应关键字」开启,并在「打开Tracker配置窗口」里逐行填写关键字或正则表达式。 + - 误删或误暂停 → 先关闭相关 Tab 的开关或调大阈值,再用「重置数据」清理插件侧任务记录;重置会先恢复增强版持有的 P 状态和上映 / 播出暂停订阅,再清空插件任务数据,用户名暂停、无下载暂停和外部暂停不会按这个口径自动恢复;涉及文件删除的场景需从备份或媒体库管理工具恢复。 + +## 致谢 / 参考 + +- 订阅待定、暂停、删种和洗版能力参考 MoviePilot [订阅生命周期与订阅模式](https://github.com/jxxghp/MoviePilot/blob/v3/docs/subscribe-lifecycle.md) 中的常见运维场景。 +- 完结状态相关行为参考 MoviePilot 订阅状态链路、TMDB 剧集数据以及 [#3330](https://github.com/jxxghp/MoviePilot/pull/3330)、[#477](https://github.com/jxxghp/MoviePilot-Frontend/pull/477)、[#6015](https://github.com/jxxghp/MoviePilot/pull/6015)。 diff --git a/plugins.v3/subscribeassistantenhanced/__init__.py b/plugins.v3/subscribeassistantenhanced/__init__.py new file mode 100644 index 00000000..e6c40e61 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/__init__.py @@ -0,0 +1,1822 @@ +"""订阅助手(增强版)——完整订阅生命周期管理入口。 + +插件入口负责配置解析、事件注册、定时任务和各业务域模块组装;具体业务规则由独立领域模块承载。 +ResourceSelection 链式事件在这里接入候选准入、洗版串行和删除指纹过滤,保持入口只做编排。 +""" +import datetime +import json +import random +import re +import threading +import time +from types import SimpleNamespace +from typing import Any, Dict, List, Tuple, Optional + +from apscheduler.triggers.cron import CronTrigger + +from app.plugins import _PluginBase +from app import schemas +from app.log import logger +from app.core.event import eventmanager +from app.core.metainfo import MetaInfo +from app.schemas.types import EventType, ChainEventType, MediaType +from app.chain.storage import StorageChain +from app.chain.subscribe import SubscribeChain +from app.chain.tmdb import TmdbChain +from app.chain.torrents import TorrentsChain +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.subscribe_oper import SubscribeOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.helper.downloader import DownloaderHelper + +from .engine.types import CompletionSignal, SeasonScope +from .engine.site import SiteEpisodesRefreshHandler, SiteEvidenceScanner, SiteEvidenceStore +from .engine.volatility import VolatilityTracker +from .engine.pipeline import CompletionEvidencePipeline +from .guard import CompletionGuard +from .lifecycle import SubscribeLifecycleCoordinator +from .pending.judge import PendingJudge +from .pending.refresh import PendingRefresh +from .pending.state import PendingStateCoordinator +from .pause.airing import AiringPauseChecker +from .pause.manager import PauseManager +from .pause.nodownload import NoDownloadPolicy +from .pause.probe import PausedProbeCoordinator +from .best_version.priority import PriorityManager +from .best_version.converter import BestVersionConverter +from .best_version.orchestrator import BestVersionOrchestrator +from .cleanup import SubscriptionCleanup +from .download.monitor import DownloadMonitor +from .download.cleanup import TorrentCleanup +from .recognition import RecognitionGuard, RecognitionRuntime, RecognitionSettings +from .recognition.audit import redact_sensitive_text +from .shared.deletes import DeletesStore +from .shared.subscribe import ( + build_subscribe_meta, + format_subscribe_label, + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, + subscribe_media_identity, +) +from .postcheck.verifier import CompletionVerifier +from .postcheck.rebuilder import CompletionSubscribeRebuilder +from .postcheck.timeout import PendingTimeoutManager +from .events import EventProxy +from .shared.media import parse_date +from .shared.task import TaskDataManager +from .shared.config import ( + DEFAULT_DELETE_EXCLUDE_TAGS, + DEFAULT_RECOGNITION_GUARD_CUSTOM_CONFIG, + DEFAULT_TRACKER_RESPONSE, + PluginConfig, +) +from .shared.log import detail, truncate_log_value +from .shared.subscribe import format_subscribe + + +class SubscribeAssistantEnhanced(_PluginBase): + """订阅助手增强版——插件入口。 + + 生命周期:init_plugin → 事件注册 → 定时任务 → stop_service。 + 配置界面由 Vue 联邦 Config 渲染,get_form 提供初始模型与默认值; + 运行概况由日志和只读 summary API 提供。 + 继承 _PluginBase 以获得真实数据层(get_data/save_data)、事件管理器与消息能力。 + """ + + # 插件名称 + plugin_name = "订阅助手(增强版)" + # 插件描述 + plugin_desc = "多场景管理订阅,实现订阅全生命周期管理。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png" + # 插件版本 + plugin_version = "0.7" + _site_cache_candidate_helper_warned = False + # 插件作者 + plugin_author = "InfinityPacer" + # 作者主页 + author_url = "https://github.com/InfinityPacer" + # 插件配置项ID前缀 + plugin_config_prefix = "subscribeassistantenhanced_" + # 加载顺序 + plugin_order = 5 + # 可使用的用户级别 + auth_level = 1 + + @property + def name(self) -> str: + """错误处理读取的插件展示名。""" + return self.plugin_name + + def __init__(self): + """初始化插件运行期依赖与一次性任务状态。""" + super().__init__() + self._config: Optional[PluginConfig] = None + self._task_manager: Optional[TaskDataManager] = None + self._event_proxy: Optional[EventProxy] = None + self._paused_probe_coordinator: Optional[PausedProbeCoordinator] = None + self._modules: dict = {} + self._onlyonce = False + # DB oper / chain 在 init_plugin 实例化后注入各业务域模块。 + self._subscribe_oper: Optional[SubscribeOper] = None + self._subscribe_chain: Optional[SubscribeChain] = None + self._tmdb_chain: Optional[TmdbChain] = None + self._storage_chain: Optional[StorageChain] = None + self._transferhistory_oper: Optional[TransferHistoryOper] = None + self._downloadhistory_oper: Optional[DownloadHistoryOper] = None + self._downloader_helper: Optional[DownloaderHelper] = None + + def init_plugin(self, config: dict = None): + """解析配置 → 注入 DB/chain 依赖 → 初始化各业务域模块。""" + self.stop_service() + + raw_config, should_persist = self._normalize_persisted_config(config or {}) + self._config = PluginConfig(raw_config) + + # 依赖注入:构造即可用且不触发外部网络,供洗版、下载、补搜等业务域写库与查询。 + self._subscribe_oper = SubscribeOper() + self._subscribe_chain = SubscribeChain() + self._tmdb_chain = TmdbChain() + self._storage_chain = StorageChain() + self._transferhistory_oper = TransferHistoryOper() + self._downloadhistory_oper = DownloadHistoryOper() + self._downloader_helper = DownloaderHelper() + + # 任务数据统一走 _PluginBase 的 get_data/save_data 持久化接口。 + self._task_manager = TaskDataManager( + get_data_fn=self.get_data, + save_data_fn=self.save_data, + ) + + self._init_modules() + + self._onlyonce = self._config.onlyonce + if self._config.reset_task: + self._reset_task_data() + if self._config.backfill_best_version_now: + self._run_backfill_now() + if self._config.onlyonce or self._config.reset_task or self._config.backfill_best_version_now: + raw_config["onlyonce"] = False + raw_config["reset_task"] = False + raw_config["backfill_best_version_now"] = False + should_persist = True + if should_persist: + self.update_config(raw_config) + self._config = PluginConfig(raw_config) + + # 启动摘要:一眼看清各业务域开关,排查"某能力为何不生效"先看这条 + cfg = self._config + recognition_mode = cfg.recognition_guard_mode + recognition_notify = cfg.recognition_guard_notify + recognition_interval = cfg.recognition_guard_notify_interval + recognition_recheck = cfg.recognition_guard_tmdb_recheck_mode + recognition_cache_size = cfg.recognition_guard_cache_maxsize + recognition_warnings = ",".join(sorted(cfg.recognition_guard_config_warnings)) or "none" + logger.info( + "初始化完成:" + f"总开关={cfg.enabled} 完成守卫模式={cfg.completion_guard_mode} " + f"待定增强={cfg.pending_enhanced_enabled} 暂停优化={cfg.pause_enhanced_enabled} " + f"洗版类型={cfg.best_version_type} 下载管理={cfg.download_monitor_enabled} " + f"完成验证={cfg.verify_enabled} 识别增强={recognition_mode} " + f"站点集数探测={cfg.site_total_probe_enabled} " + f"站点完结信号={cfg.site_completion_evidence_enabled} " + f"识别增强通知={recognition_notify} 二次识别={recognition_recheck} " + f"识别增强通知限频={recognition_interval} 识别增强缓存={recognition_cache_size} " + f"识别增强告警={recognition_warnings} 通知={cfg.notify}" + ) + + @staticmethod + def _normalize_persisted_config(config: dict) -> Tuple[dict, bool]: + """规范化需要持久安全默认值的配置,避免旧空值覆盖表单默认 model。""" + raw = dict(config or {}) + changed = False + retired_config_keys = { + "recognition_guard_enabled", + "recognition_guard_active", + "recognition_guard_keyword_config", + "recognition_guard_target_mode", + "recognition_guard_missing_year_policy", + "open_tracker_dialog", + "progress_diagnostic_mode", + "progress_diagnostic_stalled_rounds", + "progress_diagnostic_cooldown_hours", + } + for key in retired_config_keys: + if key in raw: + raw.pop(key, None) + changed = True + default_text_fields = { + "delete_exclude_tags": DEFAULT_DELETE_EXCLUDE_TAGS, + "default_tracker_response": DEFAULT_TRACKER_RESPONSE, + } + for key, default in default_text_fields.items(): + if key in raw and not str(raw.get(key) or "").strip(): + raw[key] = default + changed = True + recognition_defaults = { + "recognition_guard_mode": "off", + "recognition_guard_notify": "off", + "recognition_guard_notify_interval": 3600, + "recognition_guard_tmdb_recheck_mode": "balanced_strict", + "recognition_guard_cache_maxsize": 100000, + "recognition_guard_custom_config": DEFAULT_RECOGNITION_GUARD_CUSTOM_CONFIG, + } + for key, default in recognition_defaults.items(): + if key not in raw: + raw[key] = default + changed = True + return raw, changed + + def _init_modules(self): + """初始化各域模块并注入运行期依赖。""" + cfg = self._config + tm = self._task_manager + + volatility = VolatilityTracker(tm, window_days=cfg.volatility_window_days) + timeout_manager = PendingTimeoutManager( + tm.read, tm.update, + timeout_days=cfg.timeout_release_days, + cadence_acceleration=cfg.timeout_cadence_acceleration, + subscribe_get_fn=self._subscribe_oper.get, + ) + completion_rebuilder = CompletionSubscribeRebuilder( + subscribe_chain=self._subscribe_chain, + subscribe_oper=self._subscribe_oper, + default_config_getter=self.systemconfig.get, + plugin_name=self.plugin_name, + ) + verifier = CompletionVerifier( + tm.read, tm.update, + tmdb_episodes_fn=self._tmdb_episodes, + subscribe_oper=self._subscribe_oper, + retention_days=cfg.verify_retention_days, + notify_fn=self._notify_subscribe, + rebuild_subscribe_fn=completion_rebuilder.rebuild, + validate_rebuild_subscribe_fn=completion_rebuilder.validate, + get_subscribe_image_fn=self._get_subscribe_image, + ) + priority_manager = PriorityManager( + tm.read, + tm.update, + subscribe_oper=self._subscribe_oper, + plugin_name=self.plugin_name, + ) + converter = BestVersionConverter( + subscribe_oper=self._subscribe_oper, + clear_tasks_fn=self._task_manager.clear_tasks, + send_event_fn=eventmanager.send_event, + notify_fn=self._notify_subscribe, + restore_fn=self._restore_subscribe_from_snapshot, + snapshot_fn=verifier.snapshot, + format_desc_fn=lambda subscribe, mediainfo: self._format_subscribe_desc(subscribe, mediainfo), + notification_image_fn=self._resolve_notification_image, + plugin_name=self.plugin_name, + ) + pending_refresh = PendingRefresh() + pending_state = PendingStateCoordinator( + tm.read, + tm.update, + subscribe_oper=self._subscribe_oper, + ) + # 用户名自动暂停名单:逗号分隔字符串解析为列表,剔除空白与空项;空名单即不启用该能力 + auto_pause_users = [u.strip() for u in (cfg.auto_pause_users or "").split(",") if u.strip()] + # 注入 subscribe_oper:pause()/resume() 据此真实写订阅 DB state(S/R),否则只写插件任务数据 + pause_manager = PauseManager( + tm.read, + tm.update, + subscribe_oper=self._subscribe_oper, + auto_pause_users=auto_pause_users, + notify_fn=self._send_subscribe_status_notification, + pending_state=pending_state, + pause_enhanced_enabled=cfg.pause_enhanced_enabled, + ) + no_download_policy = NoDownloadPolicy( + movie_days=cfg.movie_no_download_days, + tv_days=cfg.tv_no_download_days, + actions=cfg.no_download_actions, + ) + tracker_keywords = [k.strip() for k in (cfg.default_tracker_response or "").splitlines() if k.strip()] + if not cfg.tracker_response_listen: + tracker_keywords = [] + exclude_tags = [t.strip() for t in (cfg.delete_exclude_tags or "").replace("&", ",").split(",") if t.strip()] + download_monitor = DownloadMonitor( + 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, + subscribe_oper=self._subscribe_oper, + state_coordinator=None, + fetch_fn=self._fetch_downloader_torrent, + present_fn=self._downloader_torrent_present, + manual_delete_enabled=cfg.download_monitor_enabled and cfg.manual_delete_listen, + pending_download_enabled=cfg.pending_download_enabled, + ) + + deletes_store = DeletesStore(tm.read, tm.update) + torrent_cleanup = TorrentCleanup( + priority_manager=priority_manager, + clear_download_pending_fn=download_monitor.clear_download_pending, + task_data_update=tm.update, + task_data_read=tm.read, + deletes_store=deletes_store, + delete_torrent_fn=self._delete_downloader_torrent, + search_fn=self._search_subscribe if cfg.auto_search_when_delete else None, + notify_fn=self._notify_subscribe, + get_subscribe_image_fn=self._get_subscribe_image, + subscribe_oper=self._subscribe_oper, + ) + + site_store = SiteEvidenceStore(tm) + if not cfg.site_total_probe_enabled: + site_store.clear_all_leases() + completion_pipeline = CompletionEvidencePipeline( + tmdb_episodes_fn=self._tmdb_episodes, + volatility_tracker=volatility, + config=cfg, + site_evidence_provider=site_store.read_snapshot, + ) + site_evidence = SiteEvidenceScanner( + config=cfg, + store=site_store, + candidate_provider=self._site_cache_candidates, + ) + site_refresh = SiteEpisodesRefreshHandler( + config=cfg, + store=site_store, + subscribe_oper=self._subscribe_oper, + resolve_missing_fn=self._resolve_subscribe_missing, + mediainfo_from_dict=self._mediainfo_from_dict, + ) + + airing_checker = AiringPauseChecker( + pause_days=cfg.airing_pause_days, + evidence_pipeline=completion_pipeline, + movie_air_days=cfg.movie_air_pause_days, + tv_air_days=cfg.tv_air_pause_days, + ) + + pending_judge = PendingJudge( + config=cfg, + evidence_pipeline=completion_pipeline, + subscribe_oper=self._subscribe_oper, + timeout_manager=timeout_manager, + task_data_read=tm.read, + task_data_update=tm.update, + resolve_missing_fn=self._resolve_subscribe_missing, + notify_fn=self._send_subscribe_status_notification, + state_coordinator=pending_state, + ) + + lifecycle = SubscribeLifecycleCoordinator( + config=cfg, + subscribe_oper=self._subscribe_oper, + pause_manager=pause_manager, + pending_judge=pending_judge, + pending_state=pending_state, + airing_checker=airing_checker if cfg.pause_enhanced_enabled else None, + tmdb_episodes_fn=lambda *args, **kwargs: self._tmdb_episodes(*args, **kwargs), + recognize_mediainfo_fn=lambda subscribe: self._recognize_mediainfo(subscribe), + is_tv_fn=lambda mediainfo: self._is_tv_media(mediainfo), + schedule_initial_pending_search_fn=lambda subscribe: self._schedule_initial_pending_search(subscribe), + has_active_downloads_fn=lambda sid: bool( + download_monitor and download_monitor.has_active_downloads(sid) + ), + clear_orphan_completion_observation_fn=self._clear_orphan_completion_observation, + clear_tasks_for_pause_fn=lambda subscribe_id: self._task_manager.clear_tasks_for_pause( + subscribe_id, + preserve_subscribe_keys=[ + "pause_reason", + "pause_since", + "pause_detail", + "paused_probe_resume_guard_reason", + "paused_probe_resume_guard_until", + ], + ), + ) + download_monitor.set_state_coordinator(lifecycle.download_pending_adapter()) + + guard = CompletionGuard( + evidence_pipeline=completion_pipeline, + has_active_downloads_fn=lambda sub: download_monitor.has_active_downloads( + sub.id), + mark_pending_fn=lambda subscribe, source="guard_veto", reason="": lifecycle.enter_guard_pending( + subscribe, + reason=reason, + ), + timeout_manager=timeout_manager, + mode=cfg.completion_guard_mode, + pending_download_enabled=cfg.pending_download_enabled, + resolve_missing_fn=self._resolve_subscribe_missing, + ) + recognition_guard = RecognitionGuard( + settings=RecognitionSettings( + mode=cfg.recognition_guard_mode, + notify_mode=cfg.recognition_guard_notify, + notify_interval=cfg.recognition_guard_notify_interval, + tmdb_recheck_mode=cfg.recognition_guard_tmdb_recheck_mode, + cache_maxsize=cfg.recognition_guard_cache_maxsize, + custom_config=cfg.recognition_guard_custom_config, + ), + runtime=RecognitionRuntime( + target_mediainfo_resolver=self._recognize_mediainfo, + tmdb_episodes_fn=self._tmdb_episodes, + secondary_recognizer=self._recognize_by_meta_for_recognition, + logger_fn=detail, + ), + ) + + orchestrator = BestVersionOrchestrator( + priority_manager=priority_manager, + subscribe_oper=self._subscribe_oper, + send_subscribe_added_fn=self._send_subscribe_added, + notify_fn=self._notify_subscribe, + related_downloads_fn=self._related_download_histories, + best_version_type=cfg.best_version_type, + notification_image_fn=self._resolve_notification_image, + plugin_name=self.plugin_name, + ) + subscription_cleanup = SubscriptionCleanup( + task_data_read=tm.read, + task_data_update=tm.update, + get_histories_fn=self._get_transfer_histories, + delete_media_file_fn=self._delete_media_file, + delete_history_fn=self._transferhistory_oper.delete, + send_download_file_deleted_fn=self._send_download_file_deleted, + notify_fn=self._notify_subscribe, + get_subscribe_image_fn=self._get_subscribe_image, + torrent_exists_fn=self._torrent_exists, + cleanup_history_type=cfg.subscription_cleanup_history_type, + cleanup_history_scenes=cfg.subscription_cleanup_history_scenes, + ) + migrated_cleanup_snapshots = subscription_cleanup.migrate_snapshot_identities() + if migrated_cleanup_snapshots: + logger.info(f"订阅清理:已迁移 {migrated_cleanup_snapshots} 条 V2 清理快照的媒体身份") + paused_probe = PausedProbeCoordinator( + cfg, + tm.read, + tm.update, + subscribe_oper=self._subscribe_oper, + subscribe_chain=self._subscribe_chain, + pause_manager=pause_manager, + download_monitor=download_monitor, + ) + self._paused_probe_coordinator = paused_probe + + self._event_proxy = EventProxy( + task_manager=tm, + subscribe_oper=self._subscribe_oper, + post_message=self.post_message, + notify_fn=self._notify_subscribe, + notification_image_fn=self._resolve_notification_image, + plugin_name=self.plugin_name, + deletes_store=deletes_store if cfg.download_monitor_enabled else None, + skip_deletion=cfg.skip_deletion, + backfill_enabled=cfg.best_version_backfill_enabled, + pending_download_enabled=cfg.pending_download_enabled, + download_monitor_enabled=cfg.download_monitor_enabled, + guard=guard if cfg.completion_guard_mode != "off" else None, + recognition_guard=recognition_guard if cfg.recognition_guard_mode != "off" else None, + volatility=volatility if cfg.volatility_enabled else None, + site_refresh=site_refresh, + pending_refresh=pending_refresh if cfg.pending_enhanced_enabled else None, + pause_manager=pause_manager if cfg.pause_enhanced_enabled else None, + airing_checker=airing_checker if cfg.pause_enhanced_enabled else None, + pending_judge=pending_judge if cfg.pending_enhanced_enabled else None, + pending_state=pending_state, + lifecycle=lifecycle, + tmdb_episodes_fn=self._tmdb_episodes, + mediainfo_from_dict=self._mediainfo_from_dict, + is_tv_fn=self._is_tv_media, + detect_existing_episodes_fn=self._detect_existing_episodes, + detect_backfill_episodes_fn=self._detect_backfill_episodes, + detect_missing_episodes_fn=self._detect_missing_episodes, + schedule_initial_pending_search_fn=self._schedule_initial_pending_search, + resolve_missing_fn=self._resolve_subscribe_missing, + recognize_mediainfo_fn=self._recognize_mediainfo, + priority_manager=priority_manager, + download_monitor=download_monitor, + verifier=verifier, + orchestrator=orchestrator, + subscription_cleanup=subscription_cleanup, + converter=converter, + best_version_episode_to_full=cfg.best_version_episode_to_full, + convert_episode_best_version_to_full_fn=self._convert_episode_best_version_to_full_if_ready, + ) + + self._modules = { + "volatility": volatility, + "timeout_manager": timeout_manager, + "completion_rebuilder": completion_rebuilder, + "verifier": verifier, + "priority_manager": priority_manager, + "converter": converter, + "pending_judge": pending_judge, + "pending_state": pending_state, + "lifecycle": lifecycle, + "pending_refresh": pending_refresh, + "pause_manager": pause_manager, + # airing_checker 同时放入 _modules,供 run_meta_check 周期巡检按 enabled 门控读取 + "airing_checker": airing_checker if cfg.pause_enhanced_enabled else None, + "no_download_policy": no_download_policy, + "download_monitor": download_monitor, + "paused_probe": paused_probe, + "torrent_cleanup": torrent_cleanup, + "deletes_store": deletes_store, + "guard": guard, + "recognition_guard": recognition_guard, + "orchestrator": orchestrator, + "subscription_cleanup": subscription_cleanup, + "completion_pipeline": completion_pipeline, + "site_evidence_store": site_store, + "site_evidence": site_evidence, + "site_refresh": site_refresh, + } + + def stop_service(self): + """清理定时任务和事件监听。""" + if self._paused_probe_coordinator: + self._paused_probe_coordinator.stop() + self._paused_probe_coordinator = None + self._event_proxy = None + self._modules = {} + + @staticmethod + def _format_service_registration(service: Dict[str, Any], schedules: Dict[str, str]) -> str: + """生成定时任务注册摘要;周期信息由注册入口按配置显式传入,避免从触发器反推。""" + schedule = schedules.get(service["id"]) + if schedule: + return f"{service['name']}={schedule}" + return service["name"] + + def get_service(self) -> List[Dict[str, Any]]: + """按域开关注册定时任务,并按元数据周期复查待定订阅。 + + 插件总开关关闭时不注册任何任务。 + 每个 job 的 func 指向插件类薄方法,委托对应域模块执行;模块周期方法未就绪时安全跳过。 + 周期 job 多用 interval 触发器;洗版订阅检查用 cron 触发器(CronTrigger);一次性全量巡检用 date 触发器延迟执行。 + """ + if not self._config: + return [] + if not self._config.enabled: + return [] + cfg = self._config + name = self.__class__.__name__ + services: List[Dict[str, Any]] = [] + service_schedules: Dict[str, str] = {} + if self._onlyonce: + service_id = f"{name}_onlyonce" + services.append({ + "id": service_id, + "name": "立即运行一次", + "trigger": "date", + "run_date": datetime.datetime.now() + datetime.timedelta(seconds=3), + "func": self.run_all_checks, + "kwargs": {}, + }) + service_schedules[service_id] = "约3s后" + service_id = f"{name}_meta_check" + services.append({ + "id": service_id, + "name": "元数据检查", + "trigger": "interval", + "func": self.run_meta_check, + "kwargs": {"hours": cfg.meta_check_interval_hours}, + }) + service_schedules[service_id] = f"{cfg.meta_check_interval_hours}h" + if cfg.pending_download_enabled or cfg.download_monitor_enabled: + service_id = f"{name}_download" + services.append({ + "id": service_id, + "name": "下载任务检查", + "trigger": "interval", + "func": self.run_download_timeout_check, + "kwargs": {"minutes": cfg.download_check_interval_minutes}, + }) + service_schedules[service_id] = f"{cfg.download_check_interval_minutes}m" + if cfg.best_version_type != "no" and cfg.best_version_cron: + # 洗版按 cron 调度,区别于其余域的 interval 周期;cron 为空则不注册该任务 + service_id = f"{name}_best_version" + services.append({ + "id": service_id, + "name": "洗版订阅检查", + "trigger": CronTrigger.from_crontab(cfg.best_version_cron), + "func": self.run_best_version_check, + }) + service_schedules[service_id] = f"cron({cfg.best_version_cron})" + if cfg.verify_enabled: + service_id = f"{name}_verify" + services.append({ + "id": service_id, + "name": "自动纠错", + "trigger": "interval", + "func": self.run_completion_verify, + "kwargs": {"hours": cfg.verify_interval_hours}, + }) + service_schedules[service_id] = f"{cfg.verify_interval_hours}h" + service_id = f"{name}_common_check" + services.append({ + "id": service_id, + "name": "通用巡检", + "trigger": "interval", + "func": self.run_common_check, + "kwargs": {"minutes": cfg.auto_check_interval_minutes}, + }) + service_schedules[service_id] = f"{cfg.auto_check_interval_minutes}m" + detail("注册定时任务:" + "、".join( + self._format_service_registration(service, service_schedules) for service in services + )) + return services + + def run_all_checks(self): + """一次性执行所有周期检查;各检查会按功能开关自行跳过。""" + logger.info("立即运行一次:开始全量巡检") + self.run_meta_check() + self.run_download_timeout_check() + self.run_best_version_check() + if self._config.verify_enabled: + self.run_completion_verify() + self.run_common_check() + + def _reset_task_data(self): + """先恢复增强版持有的订阅状态,再清空全部插件任务数据。""" + if self._paused_probe_coordinator: + self._paused_probe_coordinator.stop() + lifecycle = self._modules.get("lifecycle") + result = lifecycle.restore_owned_states_before_reset() if lifecycle else None + if result and result.changed: + summary = result.message or result.reason + logger.info(f"重置任务:数据清空前已恢复订阅状态;{summary}") + self._notify_subscribe("订阅助手数据重置前已恢复订阅状态", text=summary) + else: + logger.info("重置任务:数据清空前未发现需要恢复的订阅状态") + for key in [ + "subscribes", + "torrents", + "blocks", + "releases", + "snapshots", + "deletes", + "volatility", + "site_evidence", + "subscription_cleanup_histories", + ]: + self.save_data(key, {}) + logger.info("重置任务:已清空全部插件任务数据(订阅、下载任务、完成前观察记录、放行令牌、完成快照、删除指纹、集数变化记录、站点证据、订阅清理记录)") + + def _run_backfill_now(self): + """对现有分集洗版订阅执行一次下载事实回填,并推送扫描结果汇总。""" + results = {"scanned": 0, "updated": 0, "skipped": 0, "filled_episodes": 0} + priority = self._modules["priority_manager"] + for subscribe in (self._subscribe_oper.list(state="N,R,P") or []): + if ( + not subscribe + or resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV) + or not subscribe.best_version + ): + continue + results["scanned"] += 1 + if not priority.can_backfill(subscribe): + results["skipped"] += 1 + continue + existing = self._detect_backfill_episodes(subscribe) + filled_episodes = [ + episode for episode in existing + if str(episode) not in (subscribe.episode_priority or {}) + ] + scene = f"plugin_backfill<{self.plugin_name}>" + if existing and priority.backfill_existing(subscribe, existing, scene=scene): + results["updated"] += 1 + results["filled_episodes"] += len(filled_episodes) + detail(f"洗版回填:{format_subscribe(subscribe)} 回填已下载集 {filled_episodes}") + else: + results["skipped"] += 1 + logger.info( + f"洗版回填:完成,扫描 {results['scanned']} 个,写入 {results['updated']} 个," + f"跳过 {results['skipped']} 个,累计补写 {results['filled_episodes']} 集" + ) + self._notify_subscribe( + "洗版下载事实回填完成", + action=( + f"扫描 {results['scanned']} 个订阅,成功回填 {results['updated']} 个," + f"跳过 {results['skipped']} 个,累计补写 {results['filled_episodes']} 集" + ), + ) + + def run_download_timeout_check(self): + """下载任务检查:读取下载器状态,处理超时无进度、Tracker 删除关键字和手动删种。""" + monitor = self._modules.get("download_monitor") + cleanup = self._modules.get("torrent_cleanup") if ( + self._config and self._config.download_monitor_enabled + ) else None + if monitor: + detail("下载任务检查:开始") + monitor.run_timeout_check(cleanup) + + def _ensure_best_version_anchor(self, sid, now) -> float: + """读取洗版首次观察锚点;缺失时以当前时间写入订阅任务数据。""" + subscribes = self._task_manager.read("subscribes") or {} + anchor = (subscribes.get(str(sid)) or {}).get("best_version_anchor") + if anchor: + return anchor + + def set_anchor(data): + """在保留订阅既有任务字段的前提下写入首次观察锚点。""" + data = dict(data or {}) + record = dict(data.get(str(sid)) or {}) + record["best_version_anchor"] = now + data[str(sid)] = record + return data + + self._task_manager.update("subscribes", set_anchor) + return now + + def _best_version_timeout_days(self, subscribe) -> int: + """按媒体类型读取洗版时限。""" + if resolve_subscribe_media_type(subscribe) == MediaType.MOVIE: + return self._config.best_version_movie_remaining_days + return self._config.best_version_tv_remaining_days + + def _best_version_overdue(self, subscribe, now=None) -> bool: + """洗版是否超时限:从最近活动时间起算超过对应媒体类型洗版时限。 + + 活动时间取该订阅在 torrents 任务数据中的最新记录时间; + 无下载记录则按首次观察锚点(缺失则置当前时间)。 + remaining_days=0 表示不限,永不超时。 + """ + days = self._best_version_timeout_days(subscribe) + if not days: + return False + now = now or time.time() + sid = subscribe.id + torrents = self._task_manager.read("torrents") or {} + times = [ + torrent.get("time", 0) + for torrent in torrents.values() + if torrent.get("subscribe_id") == sid + ] + anchor = self._ensure_best_version_anchor(sid, now) + last = max(times + [anchor]) if times else anchor + return (now - last) > days * 86400 + + def run_best_version_check(self): + """洗版巡检:处理洗版超时终止,并兜底推进分集洗版转全集。""" + if self._config and self._config.best_version_type == "no": + return + priority = self._modules.get("priority_manager") + converter = self._modules.get("converter") + if not priority or not self._subscribe_oper: + return + detail("洗版巡检:开始") + for subscribe in (self._subscribe_oper.list(state="N,R,P") or []): + if ( + resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV) + or not subscribe.best_version + ): + continue + mode_label = self._best_version_mode_label(subscribe) + mediainfo = self._recognize_mediainfo(subscribe) + if mediainfo: + if is_full_best_version_subscribe(subscribe) and self._best_version_overdue(subscribe): + logger.info(f"洗版巡检:{format_subscribe(subscribe)} {mode_label}超过洗版时限,标记洗版完成并停止洗版") + priority.mark_full_best_version_complete(subscribe) + self._notify_subscribe( + f"{format_subscribe(subscribe)} {mode_label}超过时限" + f"({self._best_version_timeout_days(subscribe)}天),已标记洗版优先级为完成", + image=self._resolve_notification_image(subscribe, mediainfo), + ) + continue + if ( + self._config.best_version_episode_to_full + and converter + and is_tv_episode_best_version_subscribe(subscribe) + ): + self._convert_episode_best_version_to_full_if_ready( + subscribe.id, + subscribe, + mediainfo, + trigger="洗版巡检", + ) + continue + else: + detail( + f"洗版巡检:{format_subscribe(subscribe)} {mode_label}媒体识别失败,本轮跳过;" + f"订阅ID:{subscribe.id},媒体身份:" + f"{subscribe.media_source or '未设置'}:{subscribe.media_id or '未设置'}," + f"媒体类型:{subscribe.type or '未设置'},季号:{subscribe.season if subscribe.season is not None else '未设置'};" + f"建议检查订阅名称、年份、媒体来源和媒体 ID、媒体类型和季号" + ) + + @staticmethod + def _best_version_mode_label(subscribe) -> str: + """按订阅实际洗版形态返回日志和通知标签。""" + if is_full_best_version_subscribe(subscribe): + return "洗版" + if is_tv_episode_best_version_subscribe(subscribe): + return "分集洗版" + return "" + + def run_meta_check(self): + """元数据检查巡检:枚举订阅并委托生命周期协调器处理单订阅状态流转。""" + if not self._subscribe_oper: + return + lifecycle = self._modules.get("lifecycle") + detail("元数据巡检:开始") + for subscribe in (self._subscribe_oper.list(state="N,R,P,S") or []): + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + continue + if lifecycle: + lifecycle.handle_meta_check_subscription(subscribe) + + @staticmethod + def _pending_release_sources(task: dict) -> list[str]: + """返回需要通过待定判定器复核的业务待定来源。""" + sources = task.get("pending_sources") if isinstance(task, dict) else None + if isinstance(sources, dict) and sources: + ordered_sources = [ + source for source in ("pending_judge", "guard_veto") + if source in sources + ] + return ordered_sources or ["pending_judge"] + source = task.get("source") if isinstance(task, dict) else None + if source in ("pending_judge", "guard_veto"): + return [source] + return ["pending_judge"] + + def run_pending_release(self): + """待定释放巡检:活跃来源走待定判定器,残留观察记录只做清理。 + + PendingStateCoordinator 对 download_pending、pending_judge、guard_veto 做多来源仲裁; + guard_veto 退出必须经完成证据流水线复核,孤儿观察记录不参与状态释放。 + """ + detail("待定释放巡检:开始") + lifecycle = self._modules.get("lifecycle") + if lifecycle and self._subscribe_oper: + task_data = self.get_data("subscribes") or {} + for subscribe in (self._subscribe_oper.list(state="P") or []): + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + continue + task = task_data.get(str(subscribe.id), {}) + for source in self._pending_release_sources(task): + lifecycle.release_pending_source( + subscribe, + source=source, + reason="待定释放巡检", + ) + + timeout_manager = self._modules.get("timeout_manager") + if not timeout_manager or not self._subscribe_oper: + return + for sid in list((self.get_data("blocks") or {}).keys()): + subscribe = self._subscribe_oper.get(int(sid)) + if not subscribe: + detail(f"待定释放:{format_subscribe_label(subscribe_id=sid)} 已不存在,清理残留完成前观察记录") + timeout_manager.clear_observation(int(sid)) + timeout_manager.clear_release_token(int(sid)) + continue + task_data = self.get_data("subscribes") or {} + task = task_data.get(str(sid), {}) + has_guard_source = ( + task.get("source") == "guard_veto" + or "guard_veto" in (task.get("pending_sources") or {}) + ) + if subscribe.state == "P" and has_guard_source: + continue + detail(f"待定释放:{format_subscribe(subscribe)} 无活跃完成前观察来源,清理残留记录") + timeout_manager.clear_observation(int(sid)) + timeout_manager.clear_release_token(int(sid)) + + def run_pending_state_reconcile(self): + """修复增强版任务仍声明 P、但所有待定来源均已丢失的状态残留。""" + lifecycle = self._modules.get("lifecycle") + if not lifecycle or not self._subscribe_oper: + return + for subscribe in (self._subscribe_oper.list(state="P") or []): + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + continue + lifecycle.reconcile_pending( + subscribe, + reason="待定状态一致性检查", + ) + + def _clear_orphan_completion_observation(self, subscribe): + """恢复无活跃 guard_veto 的 P 残留后,清理同订阅完成前观察状态。""" + timeout_manager = self._modules.get("timeout_manager") + if not timeout_manager or not subscribe: + return + timeout_manager.clear_observation(subscribe.id) + timeout_manager.clear_release_token(subscribe.id) + + def run_common_check(self): + """统一执行待定、无下载及各类本地过期数据清理。 + + 每个子任务独立捕获异常,避免单个检查失败阻断同轮其他检查。 + """ + tasks = [("待定释放", self.run_pending_release)] + tasks.append(("待定状态一致性检查", self.run_pending_state_reconcile)) + tasks.append(("无下载处理", self.run_no_download_check)) + tasks.append(("暂停订阅低频补搜", self.run_paused_probe_check)) + tasks.append(("站点证据采样", self.run_site_evidence_scan)) + if self._config.download_monitor_enabled: + tasks.append(("删除记录清理", self.run_deletes_cleanup)) + tasks.append(("完成快照清理", self.run_completion_snapshot_cleanup)) + tasks.append(("订阅清理事务清理", self.run_subscription_cleanup_expired)) + + detail("通用巡检:开始") + for task_name, task in tasks: + try: + task() + except Exception as err: + logger.error(f"通用巡检:{task_name}执行失败:{err}", exc_info=True) + + def run_paused_probe_check(self): + """暂停订阅低频补搜巡检:登记外部暂停,并按配置安排单订阅搜索。""" + coordinator = self._modules.get("paused_probe") + if coordinator: + coordinator.run() + + def run_site_evidence_scan(self): + """站点证据采样:只读主程序 RSS/spider 缓存并固化短窗口资源证据。""" + site_evidence = self._modules.get("site_evidence") + if not site_evidence or not self._subscribe_oper: + return + detail("站点证据采样:开始") + for subscribe in (self._subscribe_oper.list(state="P,R") or []): + if ( + subscribe.state in ("P", "R") + and + resolve_subscribe_media_type(subscribe) == MediaType.TV + and not is_full_best_version_subscribe(subscribe) + and not bool(subscribe.manual_total_episode) + ): + site_evidence.refresh_subscribe(subscribe) + + def run_completion_verify(self): + """完成后自验证巡检:复查完成快照,发现 TMDB 增集后重建订阅并通知。""" + verifier = self._modules.get("verifier") + if verifier: + detail("完成后验证:开始") + verifier.verify_all() + + def run_completion_snapshot_cleanup(self): + """按 verify_retention_days 清理 H 快照,不触发自动纠错或 TMDB 请求。""" + verifier = self._modules.get("verifier") + if verifier: + removed = verifier.cleanup_expired() + if removed: + logger.info(f"完成快照清理:已清理 {removed} 条过期快照") + + def run_subscription_cleanup_expired(self): + """清理超过 36 小时的订阅清理事务。""" + subscription_cleanup = self._modules.get("subscription_cleanup") + if subscription_cleanup: + removed = subscription_cleanup.cleanup_expired_clear_histories() + if removed: + logger.info(f"订阅清理事务:已清理 {removed} 条超过 36 小时的记录") + + def _last_download_date(self, subscribe) -> Optional[datetime.date]: + """订阅最近一次真实下载日期(取自主程序下载历史),无则 None。""" + try: + mtype = subscribe.type + title = subscribe.name + year = subscribe.year + media_source, media_id = subscribe_media_identity(subscribe) + if mtype == "电影": + histories = self._downloadhistory_oper.get_last_by( + mtype=mtype, + title=title, + year=year, + media_source=media_source, + media_id=media_id, + ) + else: + season = subscribe.season + histories = self._downloadhistory_oper.get_last_by( + mtype=mtype, + title=title, + year=year, + season=f"S{int(season):02d}" if season is not None else None, + media_source=media_source, + media_id=media_id, + ) + history_dates = [history.date for history in histories or [] if history.date] + if not history_dates: + return None + last_download = max(history_dates) + if isinstance(last_download, datetime.datetime): + return last_download.date() + if isinstance(last_download, datetime.date): + return last_download + return ( + parse_date(last_download, fmt="%Y-%m-%d %H:%M:%S") + or parse_date(last_download) + ) + except Exception: + return None + + def _related_download_histories(self, subscribe, raise_on_error: bool = False) -> list: + """获取同一订阅完成后的分集下载历史,用于判断是否应自动洗版。""" + try: + if subscribe.type == "电影": + histories = self._downloadhistory_oper.get_last_by( + mtype=subscribe.type, + title=subscribe.name, + year=subscribe.year, + media_source=subscribe.media_source, + media_id=subscribe.media_id, + ) + else: + histories = self._downloadhistory_oper.get_last_by( + mtype=subscribe.type, + title=subscribe.name, + year=subscribe.year, + season=f"S{int(subscribe.season):02d}" if subscribe.season is not None else None, + media_source=subscribe.media_source, + media_id=subscribe.media_id, + ) + except Exception as err: + logger.warning(f"洗版编排:查询关联下载历史失败,跳过分集洗版判定:{err}") + if raise_on_error: + raise + return [] + + related = [] + subscribe_date = self._parse_datetime(subscribe.date) + for history in histories or []: + source = history.note.get("source") if isinstance(history.note, dict) else "" + source_info = self._subscribe_info_from_source(source) + if not source_info: + continue + if source_info.get("id") != subscribe.id: + continue + if ( + source_info.get("media_source") != subscribe.media_source + or str(source_info.get("media_id") or "") != str(subscribe.media_id or "") + ): + continue + if source_info.get("year") != subscribe.year: + continue + history_date = self._parse_datetime(history.date) + if subscribe_date and history_date and history_date <= subscribe_date: + continue + if subscribe.type != "电影": + if source_info.get("season") != subscribe.season: + continue + source_episode_group = source_info.get("episode_group") + if source_episode_group and source_episode_group != subscribe.episode_group: + continue + if history.episode_group and history.episode_group != subscribe.episode_group: + continue + if self._is_full_pack_download(history, subscribe.total_episode): + continue + related.append(history) + return related + + def _convert_episode_best_version_to_full_if_ready( + self, + subscribe_id, + subscribe=None, + mediainfo=None, + trigger: str = "洗版巡检", + ) -> bool: + """在下载待定已释放且媒体库完整覆盖目标范围时,将分集洗版转为全集洗版。""" + if not self._config or not self._config.best_version_episode_to_full: + return False + if not subscribe_id or not self._subscribe_oper: + return False + subscribe = subscribe or self._subscribe_oper.get(subscribe_id) + if not subscribe or not is_tv_episode_best_version_subscribe(subscribe): + return False + + try: + start_episode = int(subscribe.start_episode or 1) + total_episode = int(subscribe.total_episode or 0) + except (TypeError, ValueError): + return False + start_episode = max(start_episode, 1) + if total_episode < start_episode: + return False + target_episodes = set(range(start_episode, total_episode + 1)) + + download_monitor = self._modules.get("download_monitor") + if download_monitor and download_monitor.has_active_downloads(subscribe.id): + detail(f"{trigger}:{format_subscribe(subscribe)} 仍有下载待定,跳过分集转全集") + return False + + existing_episodes, missing_episodes = self._detect_episode_coverage(subscribe) + if missing_episodes or not target_episodes.issubset(set(existing_episodes)): + return False + + try: + episode_histories = self._related_download_histories(subscribe, raise_on_error=True) + except Exception: + return False + try: + current_priority = int(subscribe.current_priority or 0) + except (TypeError, ValueError): + current_priority = 0 + full_priority = 0 if len(episode_histories) > 1 else current_priority + + mediainfo = mediainfo or self._recognize_mediainfo(subscribe) + converter = self._modules.get("converter") + if not mediainfo or not converter: + return False + logger.info( + f"{trigger}:{format_subscribe(subscribe)} 媒体库已完整覆盖目标范围," + f"分集下载历史={len(episode_histories)},转为全集洗版" + ) + return converter.convert_to_full( + subscribe, + mediainfo, + current_priority=full_priority, + ) + + @staticmethod + def _is_full_pack_download(history, total_episode: Optional[int]) -> bool: + """判断下载历史是否为合集/全集包;全集包不参与分集洗版触发计数。""" + if not total_episode: + return False + meta_info = MetaInfo(title=history.torrent_name, subtitle=history.torrent_description) + if meta_info.total_episode == total_episode: + return True + text = f"{history.torrent_name or ''} {history.torrent_description or ''}" + patterns = ( + rf"全\s*{int(total_episode)}\s*集", + rf"complete\s*{int(total_episode)}\s*(?:episodes?|eps?)", + rf"{int(total_episode)}\s*(?:episodes?|eps?)\s*complete", + ) + return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns) + + @staticmethod + def _subscribe_info_from_source(source: str) -> dict: + """从下载历史 source 中解析订阅信息;解析失败按无关联处理。""" + if not source or "|" not in source: + return {} + _prefix, raw = source.split("|", 1) + try: + data = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + @staticmethod + def _parse_datetime(value): + """解析下载历史/订阅时间,无法解析时返回 None。""" + if not value: + return None + if isinstance(value, datetime.datetime): + return value + if isinstance(value, datetime.date): + return datetime.datetime.combine(value, datetime.time.min) + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"): + try: + return datetime.datetime.strptime(str(value), fmt) + except ValueError: + continue + return None + + def run_no_download_check(self): + """无下载处理巡检:上映后超期且无下载的订阅按策略暂停、完成或删除。""" + policy = self._modules.get("no_download_policy") + lifecycle = self._modules.get("lifecycle") + if not policy or not lifecycle or not self._subscribe_oper: + return + + detail("无下载处理巡检:开始") + for subscribe in (self._subscribe_oper.list(state="N,R,P") or []): + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + continue + mediainfo = self._recognize_mediainfo(subscribe) + if not mediainfo: + continue + decision = policy.evaluate_detail( + subscribe, + mediainfo, + self._last_download_date(subscribe), + ) + action = decision.action if decision else None + subscribe_id = subscribe.id + if action == "pause": + logger.info( + f"无下载处理:{format_subscribe(subscribe)}(id={subscribe_id}) " + f"原因={decision.reason},处理=暂停订阅" + ) + result = lifecycle.pause_for_no_download(subscribe, decision.reason) + if not result.changed: + continue + elif action == "complete": + logger.info( + f"无下载处理:{format_subscribe(subscribe)}(id={subscribe_id}) " + f"原因={decision.reason},处理=写入完成历史并删除订阅" + ) + payload = subscribe.to_dict() + self._subscribe_oper.add_history(**payload) + self._subscribe_oper.delete(subscribe_id) + elif action == "delete": + logger.info( + f"无下载处理:{format_subscribe(subscribe)}(id={subscribe_id}) " + f"原因={decision.reason},处理=删除订阅" + ) + self._subscribe_oper.delete(subscribe_id) + else: + continue + if action != "pause": + self._task_manager.clear_tasks(subscribe_id) + self._send_no_download_notification(subscribe, mediainfo, action, reason=decision.reason) + + def run_deletes_cleanup(self): + """删除指纹老化清理:移除超过保留期的近期删除资源,避免长期误挡同源资源。""" + deletes_store = self._modules.get("deletes_store") + if deletes_store: + removed = deletes_store.cleanup_expired(self._config.delete_record_retention_hours) + if removed: + logger.info(f"删除指纹清理:已清理 {removed} 条过期记录(近期删除资源)") + + def get_state(self) -> bool: + """返回插件总开关状态。""" + return self._config is not None and self._config.enabled + + # ---- 事件处理器:注册在插件类上。主程序按 handler.__qualname__ 的首段(类名=plugin_id) + # 解析运行实例分发(app/core/event.py),故 handler 必须是插件类方法,不能注册 EventProxy + # 的绑定方法(否则按 "EventProxy" 找不到运行插件、事件永不触发)。实际逻辑委托 EventProxy, + # 未启用的域在 EventProxy 内部按 get() 短路。---- + + @eventmanager.register(ChainEventType.SubscribeCompletionCheck) + def on_completion_check(self, event): + """订阅完成检查 → 完成守卫(链式事件,可否决完成)。""" + if self._event_proxy: + self._event_proxy.on_completion_check(event) + + @eventmanager.register(ChainEventType.SubscribeEpisodesRefresh) + def on_episodes_refresh(self, event): + """订阅集数刷新 → 变更速率记录 + 站点证据消费 + 待定状态观察。""" + if self._event_proxy: + self._event_proxy.on_episodes_refresh(event) + + @eventmanager.register(EventType.SubscribeAdded) + def on_subscribe_added(self, event): + """订阅新增 → 优先级回填 + 播出暂停 + 待定判定。""" + if self._event_proxy: + self._event_proxy.on_subscribe_added(event) + + @eventmanager.register(EventType.SubscribeDeleted) + def on_subscribe_deleted(self, event): + """订阅删除 → 清理关联任务数据。""" + if self._event_proxy: + self._event_proxy.on_subscribe_deleted(event) + + @eventmanager.register(EventType.SubscribeModified) + def on_subscribe_modified(self, event): + """订阅修改 → 任务状态重置 + 普通转洗版回填。""" + if self._event_proxy: + self._event_proxy.on_subscribe_modified(event) + + @eventmanager.register(EventType.SubscribeComplete) + def on_subscribe_complete(self, event): + """订阅完成 → 任务清理 + H 完成快照 + 自动洗版编排。""" + if self._event_proxy: + self._event_proxy.on_subscribe_complete(event) + + @eventmanager.register(EventType.DownloadAdded) + def on_download_added(self, event): + """DownloadAdded → 种子监控登记 + 下载待定 hash 确认。""" + if self._event_proxy: + self._event_proxy.on_download_added(event) + + @eventmanager.register(EventType.TransferComplete) + def on_transfer_complete(self, event): + """整理完成 → 移动模式任务同步清理 + 下载待定清除。""" + if self._event_proxy: + self._event_proxy.on_transfer_complete(event) + + @eventmanager.register(ChainEventType.ResourceSelection) + def on_resource_selection(self, event): + """ResourceSelection → 洗版待定按集串行 + 识别增强候选准入 + 删除指纹防重过滤。""" + if self._event_proxy: + self._event_proxy.on_resource_selection(event) + + @eventmanager.register(ChainEventType.ResourceDownload, priority=9999) + def on_resource_download(self, event): + """ResourceDownload → 订阅清理 + 无 hash 下载待定 + 洗版优先级基线。""" + if self._event_proxy: + self._event_proxy.on_resource_download(event) + + @eventmanager.register(ChainEventType.TransferIntercept, priority=9999) + def on_transfer_intercept(self, event): + """整理拦截 → 订阅清理目标媒体文件。""" + if self._event_proxy: + self._event_proxy.on_transfer_intercept(event) + + @eventmanager.register(EventType.PluginAction) + def on_plugin_action(self, event): + """插件命令 → /subscribe_toggle 切换订阅状态。""" + if self._event_proxy: + self._event_proxy.on_plugin_action(event) + + @eventmanager.register(ChainEventType.PluginDataReset) + def on_plugin_data_reset(self, event): + """插件数据重置前 → 恢复增强版持有的订阅状态。""" + event_data = event.event_data + if not event_data or event_data.plugin_id != self.__class__.__name__ or not event_data.reset_data: + return + self._reset_task_data() + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """注册 /subscribe_toggle 远程命令:切换订阅启用/禁用状态。""" + return [{ + "cmd": "/subscribe_toggle", + "event": EventType.PluginAction, + "desc": "切换订阅状态", + "category": "订阅", + "data": {"action": "subscribe_toggle"}, + }] + + def get_api(self) -> List[Dict[str, Any]]: + """暴露只读概览接口:返回各业务域启用状态与待定/监控计数。""" + return [{ + "path": "/summary", + "endpoint": self._api_summary, + "methods": ["GET"], + "auth": "bear", + "summary": "订阅助手(增强版)概览", + "description": "返回各业务域启用状态与待定/监控计数", + "response_model": schemas.Response[dict], + }] + + def _api_summary(self) -> Dict[str, Any]: + """概览数据:各业务域启用状态 + 待定订阅与监控种子计数。""" + cfg = self._config or PluginConfig({}) + subscribes = self.get_data("subscribes") or {} + torrents = self.get_data("torrents") or {} + pending = sum(1 for task in subscribes.values() + if isinstance(task, dict) and task.get("state") == "P") + return { + "domains": { + "完结守卫模式": cfg.completion_guard_mode, + "待定增强": cfg.pending_enhanced_enabled, + "暂停优化": cfg.pause_enhanced_enabled, + "自动洗版": cfg.best_version_type != "no", + "下载管理": cfg.download_monitor_enabled, + "完成后验证": cfg.verify_enabled, + "站点集数探测": cfg.site_total_probe_enabled, + "站点完结信号": cfg.site_completion_evidence_enabled, + "识别增强": cfg.recognition_guard_mode, + }, + "pending_count": pending, + "monitored_torrents": len(torrents), + } + + @staticmethod + def get_render_mode() -> Tuple[str, str]: + """使用 Vue 联邦组件渲染配置页,构建产物随插件发布。""" + return "vue", "frontend/dist/assets" + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """返回宿主配置接口需要的表单结构和默认模型,Vue Config 使用默认模型初始化。""" + from .form import build_form + return build_form() + + def get_page(self) -> Optional[List[dict]]: + """不提供详情页:框架按 has_page=False 处理,运行概况由 summary API 提供。""" + pass + + def _tmdb_episodes(self, tmdbid: int, season: int, episode_group: str = None): + """查询 TMDB 季内集信息供完成证据流水线构建 SeasonScope;不可用时返回空列表。""" + if not self._tmdb_chain or not tmdbid or season is None: + return [] + return self._tmdb_chain.tmdb_episodes( + tmdbid=tmdbid, season=season, episode_group=episode_group + ) or [] + + @staticmethod + def _site_cache_candidates(subscribe, **kwargs): + """读取主程序已有 RSS/spider 缓存候选;不触发站点刷新或缓存写入。""" + chain = TorrentsChain() + helper = getattr(chain, "get_subscribe_cache_candidates", None) + if not callable(helper): + if not SubscribeAssistantEnhanced._site_cache_candidate_helper_warned: + logger.warning( + "信号引擎(S):当前 MoviePilot 主程序缺少站点缓存候选读取能力," + "跳过站点证据扫描;请升级主程序后再启用站点证据" + ) + SubscribeAssistantEnhanced._site_cache_candidate_helper_warned = True + return [] + return helper(subscribe, **kwargs) + + @staticmethod + def _mediainfo_from_dict(data): + """从事件 mediainfo dict 重建 MediaInfo 对象;空数据返回 None。""" + if not data: + return None + from app.core.context import MediaInfo + mediainfo = MediaInfo() + mediainfo.from_dict(data) + return mediainfo + + @staticmethod + def _is_tv_media(mediainfo) -> bool: + """媒体是否为剧集(电影无季/集,不做播出暂停与待定)。""" + from app.schemas.types import MediaType + return mediainfo.type == MediaType.TV + + def _recognize_mediainfo(self, subscribe): + """从订阅识别 MediaInfo,供定时巡检评估完结/释放;识别失败返回 None。""" + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + return None + meta = build_subscribe_meta(subscribe, failure_context="媒体识别失败") + if meta is None: + return None + try: + return self.chain.recognize_media( + meta=meta, mtype=meta.type, + media_source=subscribe.media_source, + media_id=subscribe.media_id, + episode_group=subscribe.episode_group, + cache=False) + except Exception as err: + logger.warning(f"媒体识别失败:{format_subscribe(subscribe)},错误:{redact_sensitive_text(err)}") + return None + + def _recognize_by_meta_for_recognition(self, meta_info): + """识别增强二次识别入口;外部识别失败按无补充证据处理。""" + if not meta_info: + return None + try: + return self.chain.recognize_media(meta=meta_info, mtype=getattr(meta_info, "type", None), cache=False) + except Exception as err: + logger.warning(f"识别增强二次识别失败:{redact_sensitive_text(err)}") + return None + + def _detect_existing_episodes(self, subscribe) -> list: + """返回订阅目标范围内媒体库已经存在的集。""" + existing, _ = self._detect_episode_coverage(subscribe) + return existing + + def _detect_backfill_episodes(self, subscribe) -> list: + """返回洗版回填候选:媒体库已有集与订阅 note 中的已下载集并集。""" + total_episode = subscribe.total_episode or 0 + try: + total_episode = int(total_episode) + except (TypeError, ValueError): + total_episode = 0 + candidates = { + episode + for episode in self._detect_existing_episodes(subscribe) + if isinstance(episode, int) and 1 <= episode <= total_episode + } + for episode in subscribe.note or []: + try: + episode_number = int(episode) + except (TypeError, ValueError): + continue + if 1 <= episode_number <= total_episode: + candidates.add(episode_number) + return sorted(candidates) + + def _detect_missing_episodes(self, subscribe) -> list: + """返回订阅目标范围内媒体库仍缺失的集。""" + _, missing = self._detect_episode_coverage(subscribe) + return missing + + def _resolve_subscribe_missing(self, subscribe, mediainfo, meta=None, + best_version_accept_downloaded: bool = False): + """按主程序订阅目标口径查询剩余缺集,不触发订阅完成写库。""" + if meta is None: + meta = build_subscribe_meta(subscribe, failure_context="目标缺集查询失败") + if meta is None: + return False, {} + if self._subscribe_chain is None: + logger.warning(f"目标缺集查询失败:{format_subscribe(subscribe)},主程序订阅链未初始化") + return False, {} + return self._subscribe_chain.resolve_subscribe_missing( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + best_version_accept_downloaded=best_version_accept_downloaded, + ) + + def _detect_episode_coverage(self, subscribe) -> Tuple[list, list]: + """复用主程序缺集探测并返回 (已存在集, 缺失集);探测失败按目标集全部缺失处理。""" + total = subscribe.total_episode or 0 + start_episode = subscribe.start_episode or 1 + target = set(range(start_episode, total + 1)) + if not target: + return [], [] + try: + from app.chain.download import DownloadChain + mediainfo = self._recognize_mediainfo(subscribe) + if not mediainfo: + return [], sorted(target) + season = subscribe.season if subscribe.season is not None else 0 + meta = build_subscribe_meta(subscribe, failure_context="媒体库缺集探测失败") + if meta is None: + return [], sorted(target) + totals = {season: total} if subscribe.season is not None and total else {} + exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo, totals=totals) + if exist_flag: + return sorted(target), [] + missing = set() + matched_scope = False + for seasons in (no_exists or {}).values(): + info = seasons.get(season) if isinstance(seasons, dict) else None + if info is None: + continue + matched_scope = True + eps = info.episodes + if eps: + missing.update(eps) + else: + # 主程序以空 episodes 表示该季目标范围整季缺失。 + missing.update(target) + if not matched_scope: + missing.update(target) + if missing and missing.isdisjoint(target): + detail( + f"媒体库缺集探测:{format_subscribe(subscribe)} 返回集号 {sorted(missing)[:5]} " + f"不在订阅目标集 {start_episode}-{total} 内,按目标集仍缺失处理" + ) + missing = set(target) + missing &= target + return sorted(target - missing), sorted(missing) + except Exception: + return [], sorted(target) + + def _delete_downloader_torrent(self, downloader, torrent_hash): + """从下载器删除种子(delete_file=True,连源文件一并删);缺下载器服务或参数时跳过。 + + 删除不可逆,仅由超时/Tracker 巡检判定后经 TorrentCleanup 调用。 + """ + if not self._downloader_helper or not downloader or not torrent_hash: + return + service = self._downloader_helper.get_service(name=downloader) + if service and service.instance: + logger.info(f"删除种子:从下载器 {downloader} 删除种子 {torrent_hash}(含源文件,不可逆)") + service.instance.delete_torrents(delete_file=True, ids=torrent_hash) + + def _fetch_downloader_torrent(self, downloader, torrent_hash): + """连下载器取单个种子并映射为 TorrentInfo;取不到或下载器出错返回 None。 + + 巡检据此判定超时——返回 None 时该种子本轮跳过,避免下载器瞬断被误判为无进度而删种。 + """ + if not self._downloader_helper or not downloader or not torrent_hash: + return None + service = self._downloader_helper.get_service(name=downloader) + if not service or not service.instance: + return None + torrents, error = service.instance.get_torrents(ids=torrent_hash) + if error or not torrents: + detail(f"下载器查询:{downloader} 取种子 {torrent_hash} 无结果或瞬断(error={bool(error)}),本轮跳过该种子") + return None + from .download.torrent import TorrentAdapter + return TorrentAdapter.get_info(torrents[0], service.type) + + def _downloader_torrent_present(self, downloader, torrent_hash): + """探测种子是否仍在下载器:True=在;False=下载器可达但已不存在;None=不可判定(无服务/报错)。 + + 与 _fetch_downloader_torrent 的区别:后者把"报错"与"不存在"都压成 None;本方法据 get_torrents + 的 error 标志区分,让手动删除监听把"用户删种"与"下载器瞬断"分开,避免瞬断误触发删除处理。 + 顺序固定为先判下载器可达、再判种子缺失,避免把瞬断当成确删。 + """ + if not self._downloader_helper or not downloader or not torrent_hash: + return None + service = self._downloader_helper.get_service(name=downloader) + if not service or not service.instance: + return None + torrents, error = service.instance.get_torrents(ids=torrent_hash) + if error: + return None + return bool(torrents) + + def _schedule_delayed_subscribe_search(self, subscribe, scene: str): + """随机延迟执行单订阅搜索,并返回实际延迟秒数供调用方展示。""" + if not self._subscribe_chain or not subscribe: + return None + sid = subscribe.id + if sid: + delay_minutes = random.uniform(3, 5) + delay_seconds = delay_minutes * 60 + logger.info( + f"{scene}:{format_subscribe(subscribe)} 将在 {delay_minutes:.2f} 分钟后触发补全搜索" + ) + threading.Timer(delay_seconds, lambda: self._subscribe_chain.search(sid=sid)).start() + return delay_seconds + return None + + def _search_subscribe(self, subscribe): + """删种后随机延迟补搜,并返回实际延迟秒数供通知展示。""" + return self._schedule_delayed_subscribe_search(subscribe, scene="种子删除处理") + + def _schedule_initial_pending_search(self, subscribe): + """新增订阅进入待定前安排一次单订阅搜索。""" + return self._schedule_delayed_subscribe_search(subscribe, scene="新增待定处理") + + def _get_transfer_histories(self, media_source, media_id, mtype, season=None, episode=None): + """按规范媒体身份、类型和季集获取整理历史,避免同名跨来源记录串联。""" + if not self._transferhistory_oper: + return [] + if season is not None and episode is not None: + return self._transferhistory_oper.get_by( + media_source=media_source, media_id=media_id, + mtype=mtype, season=season, episode=episode, + ) or [] + if season is not None: + return self._transferhistory_oper.get_by( + media_source=media_source, media_id=media_id, + mtype=mtype, season=season, + ) or [] + return self._transferhistory_oper.get_by( + media_source=media_source, media_id=media_id, mtype=mtype, + ) or [] + + def _delete_media_file(self, fileitem_dict): + """删除媒体文件(旧源文件或旧媒体库文件);fileitem_dict 为整理记录的 src/dest_fileitem 序列化形态。 + + 删除不可逆,仅由订阅清理调用;清理范围由订阅清理配置控制。 + """ + if not self._storage_chain or not fileitem_dict: + return False + from app import schemas + path = fileitem_dict.get("path") if isinstance(fileitem_dict, dict) else None + logger.info(f"订阅清理:删除媒体文件 {truncate_log_value(path or fileitem_dict)}(不可逆)") + return self._storage_chain.delete_media_file(schemas.FileItem(**fileitem_dict)) + + def _send_download_file_deleted(self, src, download_hash): + """发 DownloadFileDeleted 事件:主程序据此移除历史下载旧种子。""" + detail(f"订阅清理:发送 DownloadFileDeleted 事件,hash={download_hash},通知主程序移除旧下载") + eventmanager.send_event(EventType.DownloadFileDeleted, {"src": src, "hash": download_hash}) + + def _torrent_exists(self, download_hash: str) -> Optional[bool]: + """跨全部下载器查询旧 hash;任一查询失败且均未命中时返回 None。""" + if not self._downloader_helper or not download_hash: + return None + services = self._downloader_helper.get_services() + if not services: + return None + query_failed = False + for name, service in services.items(): + if not service or not service.instance: + query_failed = True + continue + try: + torrents, error = service.instance.get_torrents(ids=download_hash) + except Exception as err: + logger.warning(f"订阅清理:查询下载器 {name} 的旧任务失败 hash={download_hash},错误信息:{err}") + query_failed = True + continue + if error: + logger.warning(f"订阅清理:下载器 {name} 查询旧任务失败 hash={download_hash}") + query_failed = True + continue + if torrents: + return True + if query_failed: + return None + return False + + def _send_subscribe_added(self, subscribe_id, mediainfo=None, username=None): + """发 SubscribeAdded 事件,让主程序和其他插件感知订阅创建。""" + eventmanager.send_event(EventType.SubscribeAdded, { + "subscribe_id": subscribe_id, + "username": username or self.plugin_name, + "mediainfo": mediainfo.to_dict() if mediainfo else {}, + }) + + def _format_subscribe_desc(self, subscribe, mediainfo=None) -> str: + """生成通知标题中的订阅描述,优先使用媒体标题和季号。""" + title = mediainfo.title_year if mediainfo else subscribe.name + season = f" S{subscribe.season}" if subscribe.season is not None else "" + return f"{title}{season}" + + def _restore_subscribe_from_snapshot(self, subscribe_dict: dict, mediainfo=None) -> bool: + """根据订阅快照重建分集洗版订阅,并补发 SubscribeAdded 事件。""" + try: + from app.db.models import Subscribe + restore_payload = { + key: value + for key, value in (subscribe_dict or {}).items() + if hasattr(Subscribe, key) + } + restore_payload["manual_total_episode"] = 0 + restored = Subscribe(**restore_payload) + restored.create(self._subscribe_oper._db) + sid = restore_payload.get("id") + if sid and self._subscribe_oper.get(sid): + self._send_subscribe_added(sid, mediainfo, username=restore_payload.get("username")) + return True + except Exception as err: + logger.error(f"重建分集洗版订阅时发生异常: {err}") + return False + + def _send_no_download_notification(self, subscribe, mediainfo, action: str, + reason: Optional[str] = None): + """发送无下载处理状态通知。""" + action_name = {"pause": "暂停", "complete": "完成", "delete": "删除"}.get(action, action) + days = self._config.tv_no_download_days if subscribe.type == "电视剧" else self._config.movie_no_download_days + title = f"{self._format_subscribe_desc(subscribe, mediainfo)} 近 {days} 天未有下载记录,已标记{action_name}" + self._notify_subscribe( + title, + score=mediainfo.vote_average, + user=subscribe.username, + reason=reason or "上映后超期且无下载", + image=self._resolve_notification_image(subscribe, mediainfo), + link="#/subscribe/tv?tab=mysub" if subscribe.type == "电视剧" else "#/subscribe/movie?tab=mysub", + ) + + def _send_subscribe_status_notification(self, subscribe, title_suffix: str, + mediainfo=None, detail: Optional[str] = None): + """发送订阅状态变更通知,沿用状态类消息的标题和正文结构。""" + mediainfo = mediainfo or self._recognize_mediainfo(subscribe) + title = f"{self._format_subscribe_desc(subscribe, mediainfo)} {title_suffix}" + media_type = mediainfo.type.value if mediainfo else subscribe.type + self._notify_subscribe( + title, + score=mediainfo.vote_average if mediainfo else None, + user=subscribe.username, + reason=detail, + image=self._resolve_notification_image(subscribe, mediainfo), + link="#/subscribe/tv?tab=mysub" if media_type == "电视剧" else "#/subscribe/movie?tab=mysub", + ) + + def _notify_subscribe(self, title, text=None, image=None, link=None, + score=None, user=None, reason=None, action=None, + follow_up=None, next_step=None, diagnostic: bool = False): + """按通知开关发送订阅卡片,并统一正文字段顺序。 + + 状态结果使用单行字段,诊断明细使用多行字段;没有值的字段不输出。 + """ + if not self._config or not self._config.notify: + return + from app.schemas import NotificationType + from app.core.config import settings + if link and link.startswith("#"): + link = settings.MP_DOMAIN(link) + text = self._format_notification_text( + text=text, + score=score, + user=user, + reason=reason, + action=action, + follow_up=follow_up if follow_up is not None else next_step, + diagnostic=diagnostic, + ) + message_options = {} + if not image: + text = self._append_notification_source(text) + message_options["disable_web_page_preview"] = True + self.post_message( + mtype=NotificationType.Subscribe, + title=title, + text=text, + image=image or None, + link=link, + **message_options, + ) + + def _append_notification_source(self, text: Optional[str]) -> str: + """无图消息追加插件来源,便于在通知转发和多插件场景中识别发送方。""" + source = f"来源:{self.plugin_name}" + return f"{text}\n\n{source}" if text else source + + @staticmethod + def _format_notification_text(text=None, score=None, user=None, reason=None, + action=None, follow_up=None, diagnostic: bool = False): + """按评分、用户、原因、处理、后续顺序生成通知正文。""" + fields = [ + ("评分", score), + ("用户", user), + ("原因", reason), + ("处理", action), + ("后续", follow_up), + ] + parts = [] + if text not in (None, ""): + parts.append(str(text)) + parts.extend([ + f"{label}:{str(value).replace(chr(10), ';')}" + for label, value in fields + if value not in (None, "") + ]) + if not parts: + return text + separator = "\n" if diagnostic else "," + return separator.join(parts) + + @staticmethod + def _get_subscribe_image(subscribe): + """优先返回订阅背景图,其次返回海报的 w500 地址。""" + if subscribe.backdrop: + return subscribe.backdrop.replace("original", "w500") + if subscribe.poster: + return subscribe.poster.replace("original", "w500") + return "" + + def _resolve_notification_image(self, subscribe=None, mediainfo=None): + """解析媒体通知图片,优先保持订阅卡片当前展示图片。""" + subscribe_image = self._get_subscribe_image(subscribe) if subscribe else "" + if subscribe_image: + return subscribe_image + return mediainfo.get_message_image() if mediainfo else None diff --git a/tests/v2/subscribeassistantenhanced/__init__.py b/plugins.v3/subscribeassistantenhanced/best_version/__init__.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/__init__.py rename to plugins.v3/subscribeassistantenhanced/best_version/__init__.py diff --git a/plugins.v3/subscribeassistantenhanced/best_version/converter.py b/plugins.v3/subscribeassistantenhanced/best_version/converter.py new file mode 100644 index 00000000..2d74a652 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/best_version/converter.py @@ -0,0 +1,152 @@ +"""分集到全集转换:以替换订阅方式切换为全集洗版。""" + +from app.log import logger +from app.schemas.types import EventType + + +DROP_REBUILT_FIELDS = { + "id", "name", "year", "type", "tmdbid", "imdbid", "tvdbid", "doubanid", "bangumiid", + "poster", "backdrop", "vote", "description", "date", "last_update", +} + + +class BestVersionConverter: + """分集洗版升级为全集洗版。 + + 转换会归档并删除分集订阅,再以同一配置创建全集洗版订阅;episode_group 属于订阅范围约束, + 需要随 payload 保留,避免绝对季或剧集组范围在转换时丢失。 + """ + + def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None, + notify_fn=None, restore_fn=None, snapshot_fn=None, format_desc_fn=None, + notification_image_fn=None, + plugin_name: str = "订阅助手(增强版)"): + """注入订阅写库、任务清理、事件、通知、完成快照和失败恢复依赖。""" + self._subscribe_oper = subscribe_oper + self._clear_tasks = clear_tasks_fn + self._send_event = send_event_fn + self._notify = notify_fn + self._restore = restore_fn + self._snapshot = snapshot_fn + self._format_desc = format_desc_fn + self._notification_image = notification_image_fn + self._plugin_name = plugin_name + + def convert_to_full(self, subscribe, mediainfo=None, current_priority=None) -> bool: + """按指定全集准入基线替换为全集洗版订阅;失败时尽量恢复分集订阅。""" + sid = subscribe.id + if not sid or not self._subscribe_oper or not mediainfo: + return False + + subscribe_dict = subscribe.to_dict() + subscribe_desc = self._format_subscribe_desc(subscribe, mediainfo) + full_payload = self._build_full_payload(subscribe_dict, current_priority=current_priority) + + try: + if self._snapshot: + self._snapshot(subscribe=subscribe, mediainfo=mediainfo, scope=None) + except Exception as err: + logger.error(f"{subscribe_desc} 原因=登记完成快照失败,处理=停止转全集处理,错误={err}") + self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo) + return False + + try: + self._subscribe_oper.add_history(**subscribe_dict) + except Exception as err: + logger.error(f"{subscribe_desc} 原因=写入订阅历史失败,处理=停止转全集处理,错误={err}") + self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo) + return False + + try: + self._subscribe_oper.delete(sid=sid) + except Exception as err: + logger.error(f"{subscribe_desc} 原因=删除分集洗版订阅失败,处理=停止转全集处理,错误={err}") + self._notify_failure(subscribe, subscribe_desc, str(err), mediainfo=mediainfo) + return False + + if self._clear_tasks: + try: + self._clear_tasks(sid) + except Exception as err: + logger.warning(f"{subscribe_desc} 清理旧订阅任务失败,继续创建全集洗版订阅,错误={err}") + + try: + new_sid, err_msg = self._subscribe_oper.add(mediainfo=mediainfo, **full_payload) + except Exception as err: + new_sid, err_msg = None, str(err) + + if new_sid: + logger.info(f"{subscribe_desc} 原因=分集洗版集数已符合目标集数,处理=已转为全集洗版订阅 (ID: {new_sid})") + self._send_subscribe_added(new_sid, mediainfo) + self._notify_success(subscribe, subscribe_desc, mediainfo) + return True + + restored = self._restore(subscribe_dict, mediainfo) if self._restore else False + logger.error( + f"{subscribe_desc} 原因=转为全集洗版订阅失败,处理=尝试重建分集订阅," + f"错误信息={err_msg},分集订阅重建状态={restored}" + ) + restore_text = "分集洗版订阅已尝试重建" if restored else "分集洗版订阅重建失败,请手动检查" + self._notify_failure(subscribe, subscribe_desc, f"{err_msg}\n{restore_text}", mediainfo=mediainfo) + return False + + def _build_full_payload(self, subscribe_dict: dict, current_priority=None) -> dict: + """从订阅快照构造全集洗版 payload,并保留订阅范围字段。""" + payload = dict(subscribe_dict or {}) + for field in DROP_REBUILT_FIELDS: + payload.pop(field, None) + payload["best_version"] = 1 + payload["best_version_full"] = 1 + payload["username"] = self._plugin_name + payload["state"] = "N" + payload["manual_total_episode"] = 0 + if current_priority is not None: + payload["current_priority"] = current_priority + return payload + + def _format_subscribe_desc(self, subscribe, mediainfo) -> str: + """格式化通知标题中的订阅描述。""" + if self._format_desc: + return self._format_desc(subscribe, mediainfo) + season = f" S{subscribe.season}" if subscribe.season is not None else "" + return f"{subscribe.name}{season}" + + def _send_subscribe_added(self, sid, mediainfo): + """全集洗版订阅创建成功后发 SubscribeAdded 事件。""" + if not self._send_event: + return + media_payload = mediainfo.to_dict() + self._send_event(EventType.SubscribeAdded, { + "subscribe_id": sid, + "username": self._plugin_name, + "mediainfo": media_payload, + }) + + def _notify_success(self, subscribe, subscribe_desc: str, mediainfo): + """发送转全集成功通知。""" + if not self._notify: + return + self._notify( + f"{subscribe_desc} 分集洗版集数已符合目标集数,已从分集洗版转为全集洗版订阅", + score=mediainfo.vote_average, + image=self._resolve_notification_image(subscribe, mediainfo), + link="#/subscribe/tv?tab=mysub", + ) + + def _notify_failure(self, subscribe, subscribe_desc: str, text: str, mediainfo=None): + """发送转全集失败通知。""" + if not self._notify: + return + self._notify( + f"{subscribe_desc} 转为全集洗版订阅失败", + text=text, + follow_up="请检查订阅状态", + diagnostic=True, + image=self._resolve_notification_image(subscribe, mediainfo), + ) + + def _resolve_notification_image(self, subscribe, mediainfo=None): + """解析转全集通知图片;未注入统一解析器时沿用媒体图片。""" + if self._notification_image: + return self._notification_image(subscribe, mediainfo) + return mediainfo.get_message_image() if mediainfo else None diff --git a/plugins.v3/subscribeassistantenhanced/best_version/orchestrator.py b/plugins.v3/subscribeassistantenhanced/best_version/orchestrator.py new file mode 100644 index 00000000..2bfc1279 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/best_version/orchestrator.py @@ -0,0 +1,172 @@ +"""洗版全流程编排:按配置创建洗版订阅。""" +from typing import Callable, Optional + +from app.log import logger +from app.schemas.types import MediaType + +from ..shared.subscribe import ( + format_subscribe_desc, + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, +) +from .priority import PriorityManager + + +class BestVersionOrchestrator: + """洗版全流程编排器,负责按配置创建洗版订阅。""" + + def __init__(self, priority_manager: PriorityManager, + subscribe_oper=None, + send_subscribe_added_fn: Optional[Callable] = None, + notify_fn: Optional[Callable] = None, + related_downloads_fn: Optional[Callable] = None, + best_version_type: str = "no", + notification_image_fn: Optional[Callable] = None, + plugin_name: str = "订阅助手(增强版)"): + """注入洗版编排依赖与自动洗版范围。""" + self._priority = priority_manager + self._subscribe_oper = subscribe_oper + self._send_subscribe_added = send_subscribe_added_fn + self._notify = notify_fn + self._related_downloads = related_downloads_fn + self._best_version_type = best_version_type + self._notification_image = notification_image_fn + self._plugin_name = plugin_name + + def build_payload(self, subscribe) -> dict: + """构建洗版订阅 payload,保留 episode_group。""" + payload = { + "name": subscribe.name, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, + "episode_group": subscribe.episode_group, + "save_path": subscribe.save_path, + "sites": subscribe.sites, + "filter": subscribe.filter, + "filter_groups": subscribe.filter_groups, + } + payload = {key: value for key, value in payload.items() if value is not None} + payload["best_version"] = 1 + payload["manual_total_episode"] = 0 + return payload + + def start_best_version(self, subscribe, mediainfo): + """普通订阅完成后按配置自动创建洗版订阅。 + + 分集下载洗版只在历史上存在多次分集下载时创建,避免单次全集包完成后误进入洗版。 + """ + if not self._subscribe_oper or not mediainfo: + return None + if subscribe.best_version: + return None + media_type = resolve_subscribe_media_type(subscribe) + if not self._type_matches(media_type, self._best_version_type): + return None + is_movie = media_type == MediaType.MOVIE + if is_movie and self._movie_current_priority(subscribe) >= 100: + logger.info( + f"洗版编排:{format_subscribe_desc(subscribe)} " + f"普通订阅完成资源已达顶档,跳过自动创建洗版订阅" + ) + if self._notify: + self._notify( + f"{format_subscribe_desc(subscribe)} 已达顶档,跳过洗版订阅", + image=self._resolve_notification_image(subscribe, mediainfo), + link="#/subscribe/movie?tab=mysub", + ) + return None + if self._best_version_type == "tv_episode" and not is_movie: + downloads = self._related_downloads(subscribe) if self._related_downloads else [] + download_count = len(downloads or []) + if download_count <= 1: + logger.info( + f"洗版编排:{format_subscribe_desc(subscribe)} 只找到 {download_count} 条分集下载记录," + f"不是多次分集下载完成,跳过自动创建洗版订阅" + ) + return None + payload = { + "best_version": 1, + "season": subscribe.season, + "episode_group": subscribe.episode_group, + "save_path": subscribe.save_path, + "sites": subscribe.sites, + "filter": subscribe.filter, + "filter_groups": subscribe.filter_groups, + } + # 普通剧集订阅完成后直接进入洗版,才能在新资源下载前执行整季既有版本清理。 + if not is_movie: + payload["best_version_full"] = 1 + else: + payload["current_priority"] = self._movie_current_priority(subscribe) + payload = {key: value for key, value in payload.items() if value is not None} + # 插件创建的订阅始终重新跟随 TMDB 总集数,不继承已完成订阅的手动锁定状态。 + payload["manual_total_episode"] = 0 + sid, err_msg = self._subscribe_oper.add(mediainfo=mediainfo, **payload) + if sid: + mode_label = "洗版" + logger.info( + f"洗版编排:{format_subscribe_desc(subscribe)} " + f"原因=订阅完成,处理=已创建{mode_label}订阅(id={sid})" + ) + if self._send_subscribe_added: + self._send_subscribe_added(sid, mediainfo, username=self._plugin_name) + if self._notify: + self._notify( + f"{format_subscribe_desc(subscribe)} 已添加{mode_label}订阅", + score=mediainfo.vote_average, + image=self._resolve_notification_image(subscribe, mediainfo), + link="#/subscribe/movie?tab=mysub" if is_movie else "#/subscribe/tv?tab=mysub", + ) + elif self._notify: + logger.error( + f"洗版编排:{format_subscribe_desc(subscribe)} " + f"原因=添加洗版订阅失败,处理=请检查订阅创建错误,错误={err_msg}" + ) + self._notify( + f"{format_subscribe_desc(subscribe)} 添加洗版订阅失败", + reason=err_msg, + follow_up="请检查订阅创建错误", + diagnostic=True, + image=self._resolve_notification_image(subscribe, mediainfo), + ) + return sid + + def _resolve_notification_image(self, subscribe, mediainfo): + """解析洗版通知图片;未注入统一解析器时沿用媒体图片。""" + if self._notification_image: + return self._notification_image(subscribe, mediainfo) + return mediainfo.get_message_image() + + @staticmethod + def _mode_label(subscribe) -> str: + """按订阅实际洗版形态返回用户可见标签。""" + if is_full_best_version_subscribe(subscribe): + return "洗版" + if is_tv_episode_best_version_subscribe(subscribe): + return "分集洗版" + return "" + + @staticmethod + def _movie_current_priority(subscribe) -> int: + """读取电影订阅当前质量优先级,空值按未建立质量基线处理。""" + try: + return int(subscribe.current_priority or 0) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _type_matches(media_type: MediaType, type_setting) -> bool: + """判断媒体类型是否落在自动洗版范围:no/all/movie/tv/tv_episode。""" + if media_type == MediaType.UNKNOWN: + return False + if type_setting == "no": + return False + if type_setting == "all": + return True + if type_setting == "movie": + return media_type == MediaType.MOVIE + if type_setting in ("tv", "tv_episode"): + return media_type == MediaType.TV + return False diff --git a/plugins.v3/subscribeassistantenhanced/best_version/priority.py b/plugins.v3/subscribeassistantenhanced/best_version/priority.py new file mode 100644 index 00000000..c32609d6 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/best_version/priority.py @@ -0,0 +1,223 @@ +"""订阅下载事实管理:记录回填入口与按种子基线。""" +from typing import Callable, Optional + +from app.chain.subscribe import SubscribeChain +from app.schemas.types import MediaType + +from ..shared.log import detail +from ..shared.subscribe import ( + format_subscribe_label, + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, +) +from ..shared.update import update_subscribe + + +class PriorityManager: + """订阅事实管理,实现 PriorityManagerProtocol。""" + + def __init__(self, task_data_read: Callable, task_data_update: Callable, + subscribe_oper=None, plugin_name: str = "订阅助手(增强版)"): + self._read = task_data_read + self._update = task_data_update + self._subscribe_oper = subscribe_oper + self._plugin_name = plugin_name + + def _format_backfill_scene(self, scene: str) -> str: + """为主程序 backfill 场景补充插件名,便于按来源追踪写入。""" + if scene.endswith(">") and "<" in scene: + return scene + return f"{scene}<{self._plugin_name}>" + + def capture_baseline(self, subscribe, torrent_priority: int) -> dict: + """下载前记录整体优先级基线,用于失败回滚。""" + sid = str(subscribe.id) + baseline = { + "episode_priority": dict(subscribe.episode_priority or {}), + "current_priority": subscribe.current_priority or 0, + "torrent_priority": torrent_priority, + } + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + task["priority_baseline"] = baseline + data[sid] = task + return data + + self._update("subscribes", updater) + return baseline + + def update_on_download(self, subscribe, episodes: list, new_priority: int): + """下载事实由主程序下载链路写入;插件侧保留协议方法但不再直接写 TV 进度字段。""" + return + + def rollback(self, subscribe, baseline: Optional[dict] = None): + """下载失败或种子删除后按媒体类型恢复事实字段。""" + if not baseline: + sid = str(subscribe.id) + data = self._read("subscribes") + task = data.get(sid, {}) + baseline = task.get("priority_baseline") + if not baseline: + return + + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.TV: + episode_priority = baseline.get("episode_priority", {}) + detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复到下载前剧集优先级基线") + current_priority = baseline.get("current_priority", 0) \ + if is_full_best_version_subscribe(subscribe) else None + self._update_tv_episode_priority( + subscribe, + episode_priority, + scene="plugin_rollback", + current_priority=current_priority, + ) + return + + if media_type == MediaType.MOVIE and self._subscribe_oper: + payload = {"current_priority": baseline.get("current_priority", 0)} + detail( + f"洗版优先级:{self._format_subscribe_label(subscribe)} " + f"已恢复到下载前优先级 current_priority={payload['current_priority']}" + ) + update_subscribe(self._subscribe_oper, subscribe.id, payload) + + def capture_torrent_baseline(self, subscribe, torrent_id, episodes, contributed_priority, + target_episodes=None): + """按种子记录洗版优先级基线,用于按集归属回滚。 + + episode_priority_baseline 保存各集旧值,contributed_priority 保存本种子贡献档位; + 整季包 episodes 为空时回退到目标集范围。多种子并行时各自保存基线,避免串号污染。 + """ + if not torrent_id: + return + sid = str(subscribe.id) + ep_priority = self._episode_priority_snapshot(subscribe) + eps = episodes or target_episodes or [] + ep_baseline = {str(ep): ep_priority.get(str(ep), 0) for ep in eps} + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + baselines = task.get("priority_baselines", {}) + baselines[str(torrent_id)] = { + "episode_priority_baseline": ep_baseline, + "contributed_priority": contributed_priority, + "current_priority_baseline": subscribe.current_priority or 0, + } + task["priority_baselines"] = baselines + data[sid] = task + return data + + self._update("subscribes", updater) + + def rollback_torrent(self, subscribe, torrent_id): + """按集归属回滚单个种子的洗版贡献,并保留其他种子已提升的优先级。""" + if not torrent_id: + return + sid = str(subscribe.id) + data = self._read("subscribes") + baseline = data.get(sid, {}).get("priority_baselines", {}).get(str(torrent_id)) + if not baseline: + return + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.TV: + contributed = baseline.get("contributed_priority", 0) + ep_baseline = baseline.get("episode_priority_baseline", {}) + ep_priority = self._episode_priority_snapshot(subscribe) + for ep_key, old_value in ep_baseline.items(): + # 仅回滚当前值仍等于本种子贡献档位的集;其他种子已升级的集必须保留。 + if ep_priority.get(ep_key, 0) == contributed: + ep_priority[ep_key] = old_value + detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复种子 {torrent_id} 对应集的优先级") + current_priority = None + if is_full_best_version_subscribe(subscribe): + current = subscribe.current_priority or 0 + contributed = baseline.get("contributed_priority", 0) + if current == contributed: + current_priority = baseline.get("current_priority_baseline", 0) + self._update_tv_episode_priority( + subscribe, + ep_priority, + scene="plugin_rollback", + current_priority=current_priority, + ) + elif media_type == MediaType.MOVIE and self._subscribe_oper: + current = subscribe.current_priority or 0 + contributed = baseline.get("contributed_priority", 0) + if current == contributed: + payload = {"current_priority": baseline.get("current_priority_baseline", 0)} + detail( + f"洗版优先级:{self._format_subscribe_label(subscribe)} " + f"已恢复种子 {torrent_id} 的电影优先级 current_priority={payload['current_priority']}" + ) + update_subscribe(self._subscribe_oper, subscribe.id, payload) + + def cleaner(d: dict) -> dict: + d.get(sid, {}).get("priority_baselines", {}).pop(str(torrent_id), None) + return d + + self._update("subscribes", cleaner) + + @staticmethod + def can_backfill(subscribe) -> bool: + """判断订阅是否允许按媒体库已有集回填;仅剧集分集洗版适用。""" + return is_tv_episode_best_version_subscribe(subscribe) + + def backfill_existing(self, subscribe, existing_episodes: list, scene: str = "plugin_backfill") -> bool: + """为分集洗版把在库集交给主程序 backfill 合同落库,产生写入时返回 True。""" + if not self.can_backfill(subscribe) or not existing_episodes: + return False + summary = SubscribeChain().backfill_existing_episodes( + subscribe, + existing_episodes, + priority=100, + scene=self._format_backfill_scene(scene), + ) + return bool(summary and summary.get("updated")) + + def mark_full_best_version_complete(self, subscribe): + """将超时的电影或全集洗版标记为完成,仅更新当前模式的资源准入基线。""" + payload = {"current_priority": 100} + mode_label = self._mode_label(subscribe) + detail(f"洗版优先级:{self._format_subscribe_label(subscribe)} 标记{mode_label}完成(priority=100)") + if self._subscribe_oper: + update_subscribe(self._subscribe_oper, subscribe.id, payload) + + @staticmethod + def _format_subscribe_label(subscribe) -> str: + """生成洗版优先级日志标签;字段不足时由公共格式化器回退到 ID。""" + return format_subscribe_label(subscribe) + + @staticmethod + def _mode_label(subscribe) -> str: + """按订阅实际洗版形态返回优先级日志标签。""" + if is_full_best_version_subscribe(subscribe): + return "洗版" + if is_tv_episode_best_version_subscribe(subscribe): + return "分集洗版" + return "洗版" + + @staticmethod + def _episode_priority_snapshot(subscribe) -> dict: + """读取剧集优先级快照;无按集事实时复用主程序 current_priority 兜底口径。""" + return SubscribeChain.get_episode_priority(subscribe) + + def _update_tv_episode_priority( + self, + subscribe, + episode_priority: dict, + scene: str, + current_priority: Optional[int] = None, + ): + """写回 TV 剧集事实后刷新主程序进度字段。""" + if not self._subscribe_oper: + return + payload = {"episode_priority": episode_priority} + if current_priority is not None: + payload["current_priority"] = current_priority + subscribe.current_priority = current_priority + update_subscribe(self._subscribe_oper, subscribe.id, payload) + subscribe.episode_priority = episode_priority + SubscribeChain().refresh_subscribe_progress(subscribe, scene=scene) diff --git a/plugins.v3/subscribeassistantenhanced/cleanup/__init__.py b/plugins.v3/subscribeassistantenhanced/cleanup/__init__.py new file mode 100644 index 00000000..e5521494 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/cleanup/__init__.py @@ -0,0 +1,5 @@ +"""订阅清理域:按配置清理旧整理记录、源文件和媒体库目标文件。""" + +from .subscription import SubscriptionCleanup + +__all__ = ["SubscriptionCleanup"] diff --git a/plugins.v3/subscribeassistantenhanced/cleanup/subscription.py b/plugins.v3/subscribeassistantenhanced/cleanup/subscription.py new file mode 100644 index 00000000..7dfb687b --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/cleanup/subscription.py @@ -0,0 +1,853 @@ +"""订阅清理编排:下载前清源记录,整理前清旧目标文件。""" +import re +import time +from typing import Callable, Optional + +from app.core.metainfo import MetaInfo +from app.log import logger +from app.schemas.types import MediaType +from app.utils.string import StringUtils + +from ..shared.log import detail +from ..shared.subscribe import ( + format_subscribe_desc, + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, +) + +SUBSCRIPTION_CLEANUP_TTL_SECONDS = 36 * 3600 +SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY = "subscription_cleanup_histories" + + +class SubscriptionCleanup: + """订阅清理事务编排器。 + + 事务顺序固定为:保存快照、删除旧源文件、发送 DownloadFileDeleted、删除整理记录、 + 等待旧 hash 释放;目标媒体库文件延迟到 TransferIntercept 阶段按快照删除。 + """ + + def __init__(self, + task_data_read: Optional[Callable] = None, + task_data_update: Optional[Callable] = None, + get_histories_fn: Optional[Callable] = None, + delete_media_file_fn: Optional[Callable] = None, + delete_history_fn: Optional[Callable] = None, + send_download_file_deleted_fn: Optional[Callable] = None, + notify_fn: Optional[Callable] = None, + get_subscribe_image_fn: Optional[Callable] = None, + season_of_fn: Optional[Callable] = None, + torrent_exists_fn: Optional[Callable] = None, + sleep_fn: Optional[Callable] = None, + cleanup_history_type: str = "no", + cleanup_history_scenes: Optional[list] = None): + """注入清理事务依赖和媒体类型/场景门控配置。""" + self._read = task_data_read + self._update = task_data_update + self._get_histories = get_histories_fn + self._delete_media_file = delete_media_file_fn + self._delete_history = delete_history_fn + self._send_dfd = send_download_file_deleted_fn + self._notify = notify_fn + self._get_subscribe_image = get_subscribe_image_fn + self._season_of = season_of_fn + self._torrent_exists = torrent_exists_fn + self._sleep = sleep_fn or time.sleep + self._cleanup_history_type = cleanup_history_type + self._cleanup_history_scenes = list(cleanup_history_scenes or []) + + def handle_resource_download_history_clear(self, subscribe, context=None, episodes=None) -> bool: + """清理旧整理记录并等待关联下载任务释放,允许继续下载时返回 True。 + + 订阅清理只在媒体类型和场景都命中配置时进入破坏性事务。普通订阅和分集洗版按本次目标集 + 过滤整理记录;剧集全集洗版确认资源覆盖订阅目标范围后按同季清理。明确存在的旧 hash + 最多等待 3 分钟,查询失败最多等待 1 分钟,达到上限后降级放行。 + """ + media_type = resolve_subscribe_media_type(subscribe) + if not self._cleanup_enabled_for(subscribe, media_type): + return True + scene = self._cleanup_scene(subscribe) + mode_label = self._cleanup_scene_label(scene, media_type) + if scene == "best_version" and media_type == MediaType.TV: + actual_episodes, source = self._download_resource_episodes(context=context, episodes=episodes) + target_episodes = self._subscribe_target_episodes(subscribe) + if not actual_episodes or not target_episodes or not set(target_episodes).issubset(actual_episodes): + self._notify_history_clear_skipped( + subscribe=subscribe, + context=context, + target_episodes=target_episodes, + actual_episodes=actual_episodes, + source=source, + ) + return True + media_source = subscribe.media_source + media_id = subscribe.media_id + if not media_source or not media_id or self._get_histories is None: + return True + season = self._history_season(subscribe) if media_type == MediaType.TV else None + if media_type == MediaType.TV and season is None: + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {mode_label}无法确定有效季号," + "为避免扩大清理范围,跳过旧整理记录清理" + ) + return True + target_episodes = [] + episode_scoped_cleanup = self._episode_scoped_cleanup_scene(scene) + if media_type == MediaType.TV: + target_episodes = self._clear_target_episodes(subscribe, context=context, episodes=episodes, scene=scene) + if episode_scoped_cleanup and not target_episodes: + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {mode_label}无法确定本次目标集," + "为避免扩大清理范围,跳过旧整理记录清理" + ) + return True + histories = self._get_histories(media_source, media_id, subscribe.type, season) or [] + if media_type == MediaType.TV and episode_scoped_cleanup: + histories = self._filter_histories_by_episodes(histories, target_episodes) + if not histories: + logger.info( + f"订阅清理:{format_subscribe_desc(subscribe)} {mode_label}未找到匹配的整理记录," + f"查询季号={season or '无'},跳过清理" + ) + return True + self.clear_transfer_src_histories( + subscribe=subscribe, + histories=histories, + media_type=media_type, + season=season, + scene=scene, + target_episodes=target_episodes, + ) + old_hashes = { + self._field(history, "download_hash") + for history in histories + if self._field(history, "download_hash") + } + return self._wait_for_torrents_removed(subscribe=subscribe, download_hashes=old_hashes) + + def migrate_snapshot_identities(self) -> int: + """把旧 TMDB 清理快照转换为 V3 规范媒体身份,返回迁移记录数。 + + 迁移在同一个持久化 key 的原子读改写中完成。无法转换的记录保持原样,避免在来源 + 不明确时猜测身份;底层保存失败时异常向上传递,旧数据也不会被提前删除。 + """ + if not self._update: + return 0 + migrated = {"count": 0} + + def updater(data: dict) -> dict: + for task_key, task in list((data or {}).items()): + if not isinstance(task, dict): + continue + if task.get("media_source") and task.get("media_id"): + continue + tmdb_id = task.get("tmdbid") + if tmdb_id in (None, ""): + continue + converted = dict(task) + converted["media_source"] = "themoviedb" + converted["media_id"] = str(tmdb_id) + converted.pop("tmdbid", None) + data[task_key] = converted + migrated["count"] += 1 + return data + + self._update(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY, updater) + return migrated["count"] + + def _clear_target_episodes(self, subscribe, context=None, episodes=None, scene: str = "") -> list[int]: + """返回订阅清理目标集范围;整季洗版通过覆盖保护后按季清理。""" + if scene == "best_version": + return [] + target_episodes, _source = self._download_resource_episodes(context=context, episodes=episodes) + return target_episodes + + @staticmethod + def _episode_scoped_cleanup_scene(scene: str) -> bool: + """判断清理事务是否必须绑定到明确集数,避免误清同季其他集。""" + return scene in {"normal", "best_version_episode"} + + @staticmethod + def _subscribe_target_episodes(subscribe) -> list[int]: + """返回剧集订阅明确声明的目标集数范围。""" + if not subscribe or not subscribe.total_episode: + return [] + start_episode = subscribe.start_episode or 1 + return list(range(start_episode, subscribe.total_episode + 1)) + + @staticmethod + def _normalize_episode_numbers(episodes) -> list[int]: + """规整事件或标题中的集数,忽略不能转换为正整数的值。""" + normalized = set() + for episode in episodes or []: + try: + number = int(episode) + except (TypeError, ValueError): + continue + if number > 0: + normalized.add(number) + return sorted(normalized) + + def _download_resource_episodes(self, context=None, episodes=None) -> tuple[list[int], str]: + """按下载事件、上下文和资源标题顺序解析本次资源覆盖的集数。""" + event_episodes = self._normalize_episode_numbers(episodes) + if event_episodes: + return event_episodes, "下载事件" + + selected_episodes = self._normalize_episode_numbers( + getattr(context, "selected_episodes", None) + ) + if selected_episodes: + return selected_episodes, "下载上下文" + + torrent_info = getattr(context, "torrent_info", None) + if not torrent_info: + return [], "" + meta = MetaInfo( + title=getattr(torrent_info, "title", "") or "", + subtitle=getattr(torrent_info, "description", "") or "", + ) + title_episodes = self._normalize_episode_numbers(meta.episode_list) + if title_episodes: + return title_episodes, "资源标题" + return [], "" + + @staticmethod + def _history_episode_numbers(history) -> set[int]: + """解析整理记录中的 E01、E01-E03 或逗号分隔集数,用于按集收窄范围。""" + raw = SubscriptionCleanup._field(history, "episodes") + if not raw: + return set() + text = str(raw) + numbers = set() + for start, end in re.findall(r"E?(\d+)\s*-\s*E?(\d+)", text, flags=re.IGNORECASE): + first, last = int(start), int(end) + if first <= last: + numbers.update(range(first, last + 1)) + for number in re.findall(r"(? 0} + + @classmethod + def _filter_histories_by_episodes(cls, histories, target_episodes: list[int]): + """只保留与目标集有交集的整理记录;无法判断集数的记录不参与剧集按集清理。""" + targets = set(target_episodes or []) + if not targets: + return [] + return [ + history for history in histories or [] + if cls._history_episode_numbers(history) & targets + ] + + def _notify_history_clear_skipped(self, subscribe, context, target_episodes: list[int], + actual_episodes: list[int], source: str): + """全集资源范围不足时保留旧文件,并发送可人工核对的保护通知。""" + torrent_info = getattr(context, "torrent_info", None) + torrent_title = getattr(torrent_info, "title", "") if torrent_info else "" + target_desc = StringUtils.format_ep(target_episodes) if target_episodes else "未知" + actual_desc = StringUtils.format_ep(actual_episodes) if actual_episodes else "未知" + source_desc = source or "未知来源" + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} " + f"原因=全集资源未覆盖订阅目标范围(目标集数={target_desc},资源集数={actual_desc}," + f"来源={source_desc},种子={torrent_title}),处理=已跳过历史清理,后续=请人工核对资源覆盖范围" + ) + if self._notify: + image = self._get_subscribe_image(subscribe) if self._get_subscribe_image else None + self._notify( + f"{format_subscribe_desc(subscribe)} 洗版资源未覆盖目标范围,已跳过历史清理", + text=( + f"目标集数:{target_desc}\n" + f"资源集数:{actual_desc}\n" + f"种子:{torrent_title}" + ), + follow_up="请人工核对资源覆盖范围", + diagnostic=True, + image=image, + ) + + def _wait_for_torrents_removed(self, subscribe, download_hashes: set[str]) -> bool: + """每 5 秒确认旧 hash:查询失败等 1 分钟,明确存在等 3 分钟,超限后放行。""" + if not download_hashes or not self._torrent_exists: + # 删除事件由主程序异步处理,即使无法确认 hash,也保留固定的首轮处理窗口。 + self._sleep(5) + return True + pending_hashes = set(download_hashes) + exists_wait = {download_hash: 0 for download_hash in pending_hashes} + query_failure_wait = {download_hash: 0 for download_hash in pending_hashes} + for waited_seconds in range(5, 181, 5): + self._sleep(5) + next_pending_hashes = set() + for download_hash in pending_hashes: + # DownloadFileDeleted 会跨下载器删除旧任务,确认时也必须跨下载器查询。 + exists = self._torrent_exists(download_hash) + if exists is None: + query_failure_wait[download_hash] += 5 + if query_failure_wait[download_hash] >= 60: + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}" + "累计 60 秒无法查询旧下载任务," + f"降级放行 hash={download_hash}" + ) + continue + next_pending_hashes.add(download_hash) + continue + if exists: + exists_wait[download_hash] += 5 + if exists_wait[download_hash] >= 180: + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}" + "旧下载任务持续存在 180 秒," + f"降级放行 hash={download_hash}" + ) + continue + next_pending_hashes.add(download_hash) + pending_hashes = next_pending_hashes + if not pending_hashes: + logger.info( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}旧下载任务确认完成," + f"等待 {waited_seconds} 秒后继续下载" + ) + return True + detail( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}等待旧下载任务释放 " + f"({waited_seconds}/180 秒),剩余 {len(pending_hashes)} 个" + ) + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}" + "等待旧下载任务达到 180 秒总上限," + f"降级放行剩余 {len(pending_hashes)} 个 hash" + ) + return True + + def _history_season(self, subscribe) -> Optional[str]: + """按主程序整理历史口径把订阅季号转换为 Sxx。""" + if self._season_of: + return self._season_of(subscribe) + season = subscribe.season + if season is None: + return None + try: + return f"S{int(season):02d}" + except (TypeError, ValueError): + logger.warning( + f"订阅清理:{format_subscribe_desc(subscribe)} {self._mode_label(subscribe)}季号无效," + f"无法匹配整理记录:{season}" + ) + return None + + def clear_transfer_src_histories(self, subscribe, histories, media_type: Optional[MediaType] = None, + season: Optional[str] = None, scene: Optional[str] = None, + target_episodes: Optional[list[int]] = None): + """删除源文件与整理历史,并保存 TransferIntercept 阶段消费的清理快照。 + + 快照 key 按媒体身份、场景和目标集生成;旧媒体库目标文件必须等主程序整理新文件前再删, + 因此由后续 TransferIntercept 按同一媒体和集范围消费,避免同 TMDB 并发下载互相覆盖。 + """ + media_source = str(subscribe.media_source or "") + media_id = str(subscribe.media_id or "") + if not media_source or not media_id: + return + subscribe_image = self._get_subscribe_image(subscribe) if self._get_subscribe_image else None + media_type = media_type or resolve_subscribe_media_type(subscribe) + season = season if season is not None else (self._history_season(subscribe) if media_type == MediaType.TV else None) + scene = scene or self._cleanup_scene(subscribe) + target_episodes = self._normalize_episode_numbers(target_episodes) + task_key = self._task_key( + media_source=media_source, + media_id=media_id, + media_type=media_type, + season=season, + scene=scene, + target_episodes=target_episodes, + ) + + def updater(data: dict) -> dict: + data[task_key] = { + "subscribe_id": subscribe.id, + "subscribe_desc": format_subscribe_desc(subscribe), + "subscribe_image": subscribe_image, + "media_source": media_source, + "media_id": media_id, + "type": media_type.value if isinstance(media_type, MediaType) else str(media_type or ""), + "season": season, + "scene": scene, + "target_episodes": target_episodes, + "mode_label": self._cleanup_scene_label(scene, media_type), + "histories": [self._history_to_dict(h) for h in histories], + "time": time.time(), + } + return data + + if self._update: + self._update(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY, updater) + + mode_label = self._cleanup_scene_label(scene, media_type) + logger.info( + f"订阅清理:{format_subscribe_desc(subscribe)} {mode_label}开始删除 " + f"{len(histories)} 条旧整理记录的源文件(不可逆)" + ) + source_file_deleted = 0 + source_paths = [] + download_notice_total = 0 + download_notice_sent = 0 + history_delete_total = 0 + history_deleted = 0 + for history in histories: + src_fileitem = self._field(history, "src_fileitem") + if src_fileitem and self._delete_media_file: + self._delete_media_file(src_fileitem) + source_file_deleted += 1 + source_path = self._fileitem_path(src_fileitem) or self._field(history, "src") + if source_path: + source_paths.append(str(source_path)) + if src_fileitem: + download_notice_total += 1 + if self._send_dfd: + self._send_dfd(self._field(history, "src"), self._field(history, "download_hash")) + download_notice_sent += 1 + history_id = self._field(history, "id") + if history_id is not None: + history_delete_total += 1 + if history_id is not None and self._delete_history: + self._delete_history(history_id) + history_deleted += 1 + + logger.info( + f"订阅清理:{format_subscribe_desc(subscribe)} {mode_label}源文件清理完成," + f"整理记录 {history_deleted}/{history_delete_total} 条," + f"源文件 {source_file_deleted}/{len(histories)} 个," + f"下载记录通知 {download_notice_sent}/{download_notice_total} 个" + ) + + if self._notify: + self._notify( + f"{format_subscribe_desc(subscribe)} " + f"即将开始{mode_label}下载,已处理 {len(histories)} 条整理记录对应的源文件", + text=self._single_episode_cleanup_text(target_episodes, source_paths), + image=subscribe_image, + ) + + def handle_history_clear(self, event) -> bool: + """TransferIntercept 阶段按清理快照删除旧媒体库目标文件,成功后消费快照。""" + data = event.event_data + if not data or data.cancel: + return False + mediainfo = data.mediainfo + media_source = getattr(mediainfo, "media_source", None) if mediainfo else None + media_id = getattr(mediainfo, "media_id", None) if mediainfo else None + if not media_source or not media_id or not self._update: + return False + clear_task = self._claim_clear_history_task(str(media_source), str(media_id), data) + if not clear_task: + return False + try: + if self.clear_transfer_dest_histories(clear_task): + return True + except Exception as err: + logger.warning( + f"订阅整理拦截:{clear_task.get('subscribe_desc', '订阅')} " + f"{clear_task.get('mode_label', '订阅')}媒体库文件清理异常,已恢复清理事务:{err}" + ) + failed_histories = clear_task.pop("_failed_histories", None) + if failed_histories is not None: + restore_task = dict(clear_task) + restore_task["histories"] = failed_histories + restore_episode_set = set() + for history in failed_histories: + restore_episode_set.update(self._history_episode_numbers(history)) + task_episodes = set(self._normalize_episode_numbers(clear_task.get("target_episodes"))) + if task_episodes: + restore_episode_set &= task_episodes + restore_task["target_episodes"] = sorted(restore_episode_set or task_episodes) + else: + restore_task = clear_task + self._restore_clear_history_task(restore_task) + return False + + def _claim_clear_history_task(self, media_source: str, media_id: str, event_data) -> Optional[dict]: + """原子占用本次整理要清理的快照记录,避免并发 TransferIntercept 重复删除。""" + claimed = {} + + def updater(data: dict) -> dict: + task_key, task, event_episodes = self._match_clear_history_task( + media_source, media_id, event_data, data, + ) + if not task_key or not task: + return data + if self._clear_history_task_expired(task): + detail(f"订阅整理拦截:{media_source}:{media_id} 的清理事务已超过 36 小时,丢弃且不删除媒体库文件") + return data + clear_task, remaining_task = self._consume_clear_history_task(task, event_episodes) + if not clear_task: + return data + clear_task["_task_key"] = task_key + claimed["task"] = clear_task + if remaining_task: + data[task_key] = remaining_task + else: + data.pop(task_key, None) + return data + + self._update(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY, updater) + return claimed.get("task") + + def _restore_clear_history_task(self, task: dict): + """目标文件删除失败时恢复已占用的清理事务,允许后续整理事件重试。""" + if not self._update or not task: + return + task_key = task.get("_task_key") + if not task_key: + return + + def updater(data: dict) -> dict: + restored_task = { + key: value for key, value in task.items() + if key != "_task_key" + } + current_task = data.get(task_key) + if not current_task: + data[task_key] = restored_task + return data + + histories = list(current_task.get("histories") or []) + seen_history_keys = {self._history_identity(history) for history in histories} + for history in restored_task.get("histories") or []: + history_key = self._history_identity(history) + if history_key not in seen_history_keys: + histories.append(history) + seen_history_keys.add(history_key) + current_task["histories"] = histories + current_task["target_episodes"] = sorted( + set(self._normalize_episode_numbers(current_task.get("target_episodes"))) + | set(self._normalize_episode_numbers(restored_task.get("target_episodes"))) + ) + data[task_key] = current_task + return data + + self._update(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY, updater) + + def _match_clear_history_task(self, media_source: str, media_id: str, event_data, + snapshots: dict) -> tuple[Optional[str], Optional[dict], list[int]]: + """按规范媒体身份和整理目标集匹配待消费清理事务。""" + meta_episodes = self._event_meta_episodes(event_data) + event_episodes = meta_episodes or self._event_target_episodes(event_data) + event_media_type = self._event_media_type(event_data) + event_season = self._event_season(event_data, event_media_type) + for key, task in (snapshots or {}).items(): + if ( + str((task or {}).get("media_source") or "") != media_source + or str((task or {}).get("media_id") or "") != media_id + ): + continue + task_type = task.get("type") + if event_media_type and task_type and task_type != event_media_type.value: + continue + task_scene = (task or {}).get("scene") + if task_scene in {"normal", "best_version_episode"} and task_type == MediaType.TV.value and not meta_episodes: + continue + if task_type == MediaType.TV.value and task.get("season") is not None: + if event_season is None or task.get("season") != event_season: + continue + task_episodes = set(self._normalize_episode_numbers(task.get("target_episodes"))) + if task_episodes: + if not event_episodes or not (task_episodes & set(event_episodes)): + continue + return str(key), task, event_episodes + return None, None, [] + + def _consume_clear_history_task(self, task: dict, event_episodes: list[int]) -> tuple[dict, Optional[dict]]: + """按本次整理集数拆分待清理记录;普通订阅和分集洗版只消费当前集。""" + if (task or {}).get("scene") not in {"normal", "best_version_episode"}: + return task, None + event_episode_set = set(self._normalize_episode_numbers(event_episodes)) + if not event_episode_set: + return task, None + histories = (task or {}).get("histories") or [] + consumed_histories = [] + remaining_histories = [] + for history in histories: + if self._history_episode_numbers(history) & event_episode_set: + consumed_histories.append(history) + else: + remaining_histories.append(history) + if not consumed_histories: + return task, None + consumed_task = dict(task or {}) + consumed_task["histories"] = consumed_histories + consumed_episodes = sorted( + set(self._normalize_episode_numbers((task or {}).get("target_episodes"))) & event_episode_set + ) + consumed_task["target_episodes"] = consumed_episodes or sorted(event_episode_set) + if not remaining_histories: + return consumed_task, None + remaining_task = dict(task or {}) + remaining_task["histories"] = remaining_histories + remaining_episode_set = set() + for history in remaining_histories: + remaining_episode_set.update(self._history_episode_numbers(history)) + task_episodes = set(self._normalize_episode_numbers((task or {}).get("target_episodes"))) + if task_episodes: + remaining_episode_set &= task_episodes + remaining_task["target_episodes"] = sorted(remaining_episode_set) + return consumed_task, remaining_task + + def _event_target_episodes(self, event_data) -> list[int]: + """从整理拦截事件的 meta、源文件或目标路径解析本次整理集数。""" + episodes = self._event_meta_episodes(event_data) + if episodes: + return episodes + fileitem = getattr(event_data, "fileitem", None) + path_text = " ".join( + str(value) for value in ( + getattr(fileitem, "path", None), + getattr(event_data, "target_path", None), + ) + if value + ) + return sorted(self._path_episode_numbers(path_text)) + + def _event_meta_episodes(self, event_data) -> list[int]: + """返回文件级整理事件 meta 明确给出的集数。""" + meta = getattr(event_data, "meta", None) + return self._normalize_episode_numbers(getattr(meta, "episode_list", None)) + + @staticmethod + def _path_episode_numbers(text: str) -> set[int]: + """从文件路径解析显式集数标记,避免把 Sxx、年份、分辨率等数字当作集数。""" + if not text: + return set() + numbers = set() + for start, end in re.findall(r"(?i)(?:S\d{1,4})?E(\d{1,4})\s*-\s*(?:S\d{1,4})?E?(\d{1,4})", text): + first, last = int(start), int(end) + if first <= last: + numbers.update(range(first, last + 1)) + for number in re.findall(r"(?i)S\d{1,4}E(\d{1,4})(?!\d)", text): + numbers.add(int(number)) + for number in re.findall(r"(?i)(? 0} + + @staticmethod + def _event_media_type(event_data) -> Optional[MediaType]: + """从整理拦截事件媒体信息解析媒体类型,缺失时不作为匹配条件。""" + mediainfo = getattr(event_data, "mediainfo", None) + media_type = getattr(mediainfo, "type", None) + if isinstance(media_type, MediaType): + return media_type + if isinstance(media_type, str): + try: + return MediaType(media_type) + except ValueError: + return None + return None + + @staticmethod + def _event_season(event_data, media_type: Optional[MediaType]) -> Optional[str]: + """按整理拦截事件季号生成主程序整理历史使用的 Sxx 口径。""" + if media_type != MediaType.TV: + return None + mediainfo = getattr(event_data, "mediainfo", None) + meta = getattr(event_data, "meta", None) + meta_season = getattr(meta, "begin_season", None) + season = meta_season if meta_season is not None else getattr(mediainfo, "season", None) + if season is None: + return None + try: + return f"S{int(season):02d}" + except (TypeError, ValueError): + return None + + def cleanup_expired_clear_histories(self) -> int: + """清理超过 36 小时或缺少有效时间戳的订阅清理事务。""" + snapshots = self._read(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY) if self._read else {} + expired_keys = [ + str(key) for key, task in (snapshots or {}).items() + if self._clear_history_task_expired(task) + ] + if not expired_keys or not self._update: + return 0 + + def updater(data: dict) -> dict: + for key in expired_keys: + data.pop(key, None) + return data + + self._update(SUBSCRIPTION_CLEANUP_SNAPSHOT_KEY, updater) + return len(expired_keys) + + @staticmethod + def _clear_history_task_expired(task: dict) -> bool: + """判断破坏性清理事务是否仍处于允许消费的 36 小时窗口。""" + created_at = (task or {}).get("time") + if not isinstance(created_at, (int, float)): + return True + return time.time() - created_at > SUBSCRIPTION_CLEANUP_TTL_SECONDS + + def clear_transfer_dest_histories(self, task) -> bool: + """删除清理快照中的媒体库目标文件;空快照也视为已处理。""" + histories = (task or {}).get("histories") or [] + mode_label = (task or {}).get("mode_label") or "订阅" + if histories: + detail(f"订阅整理拦截:{mode_label}删除 {len(histories)} 条旧整理记录对应的媒体库文件(不可逆)") + dest_paths = [] + failed_dest_paths = [] + failed_histories = [] + for history in histories: + dest_fileitem = history.get("dest_fileitem") if isinstance(history, dict) else None + if dest_fileitem and self._delete_media_file: + try: + delete_state = self._delete_media_file(dest_fileitem) + except Exception as err: + delete_state = False + logger.warning(f"订阅整理拦截:媒体库文件删除异常 {self._fileitem_path(dest_fileitem) or dest_fileitem}:{err}") + if delete_state is False: + failed_dest_paths.append(self._fileitem_path(dest_fileitem) or str(dest_fileitem)) + failed_histories.append(history) + dest_path = self._fileitem_path(dest_fileitem) or (history.get("dest") if isinstance(history, dict) else None) + if dest_path: + dest_paths.append(str(dest_path)) + dest_file_total = sum( + 1 for history in histories + if isinstance(history, dict) and history.get("dest_fileitem") + ) + if failed_dest_paths: + logger.warning( + f"订阅整理拦截:{(task or {}).get('subscribe_desc', '订阅')} " + f"{mode_label}媒体库文件清理失败,目标文件 {dest_file_total - len(failed_dest_paths)}/{len(histories)} 个," + f"失败路径:{'; '.join(failed_dest_paths)}" + ) + task["_failed_histories"] = failed_histories + return False + logger.info( + f"订阅整理拦截:{(task or {}).get('subscribe_desc', '订阅')} " + f"{mode_label}媒体库文件清理完成,目标文件 {dest_file_total}/{len(histories)} 个" + ) + if self._notify: + self._notify( + f"{(task or {}).get('subscribe_desc', '订阅')} " + f"即将开始{mode_label}整理,已处理 {len(histories)} 条整理记录对应的媒体库文件", + text=self._single_episode_cleanup_text((task or {}).get("target_episodes"), dest_paths), + image=(task or {}).get("subscribe_image"), + ) + return True + + @staticmethod + def _fileitem_path(fileitem) -> Optional[str]: + """从整理记录序列化的 FileItem 中取路径,用于单集清理通知。""" + if isinstance(fileitem, dict): + return fileitem.get("path") + return None + + @classmethod + def _single_episode_cleanup_text(cls, target_episodes, paths: list[str]) -> Optional[str]: + """单集清理通知附带具体路径;多集或无路径时保持摘要通知。""" + if len(cls._normalize_episode_numbers(target_episodes)) != 1: + return None + clean_paths = [path for path in paths if path] + if not clean_paths: + return None + return "清理路径:\n" + "\n".join(clean_paths) + + @classmethod + def _history_identity(cls, history) -> str: + """生成整理记录快照内的去重键,用于失败恢复时避免重复写回。""" + if isinstance(history, dict): + history_id = history.get("id") + if history_id is not None: + return f"id:{history_id}" + src_path = history.get("src") or cls._fileitem_path(history.get("src_fileitem")) or "" + dest_path = history.get("dest") or cls._fileitem_path(history.get("dest_fileitem")) or "" + return f"{src_path}|{dest_path}|{history.get('episodes') or ''}" + history_id = cls._field(history, "id") + if history_id is not None: + return f"id:{history_id}" + src_path = cls._field(history, "src") or cls._fileitem_path(cls._field(history, "src_fileitem")) or "" + dest_path = cls._field(history, "dest") or cls._fileitem_path(cls._field(history, "dest_fileitem")) or "" + return ( + f"{src_path}|" + f"{dest_path}|" + f"{cls._field(history, 'episodes') or ''}" + ) + + @staticmethod + def _mode_label(subscribe) -> str: + """按订阅清理场景返回用户可见标签。""" + media_type = resolve_subscribe_media_type(subscribe) + return SubscriptionCleanup._cleanup_scene_label( + SubscriptionCleanup._cleanup_scene(subscribe), + media_type, + ) + + @staticmethod + def _cleanup_scene(subscribe) -> str: + """按订阅下载形态归类订阅清理场景。""" + if is_full_best_version_subscribe(subscribe): + return "best_version" + if is_tv_episode_best_version_subscribe(subscribe): + return "best_version_episode" + return "normal" + + @staticmethod + def _cleanup_scene_label(scene: str, media_type: Optional[MediaType] = None) -> str: + """返回订阅清理场景的用户可见名称。""" + if scene == "best_version": + return "洗版" + if scene == "best_version_episode": + return "分集洗版" + return { + "normal": "普通订阅", + }.get(scene, "订阅") + + def _cleanup_enabled_for(self, subscribe, media_type: MediaType) -> bool: + """清理范围和场景同时命中时才允许执行破坏性订阅清理事务。""" + if not self._type_matches(media_type, self._cleanup_history_type): + return False + return self._cleanup_scene(subscribe) in self._cleanup_history_scenes + + @classmethod + def _task_key(cls, media_source, media_id, media_type: MediaType, season: Optional[str], + scene: str, target_episodes: Optional[list[int]]) -> str: + """生成订阅清理事务键;同一媒体的不同场景和集范围必须互不覆盖。""" + media_value = media_type.value if isinstance(media_type, MediaType) else str(media_type or "") + episodes = ",".join(str(episode) for episode in cls._normalize_episode_numbers(target_episodes)) + return "|".join([ + str(media_source or ""), + str(media_id or ""), + media_value, + season or "", + scene or "", + episodes or "all", + ]) + + @staticmethod + def _type_matches(media_type: MediaType, type_setting) -> bool: + """判断媒体类型是否落在清理范围:no/all/movie/tv。""" + if media_type == MediaType.UNKNOWN: + return False + if type_setting == "no": + return False + if type_setting == "all": + return True + if type_setting == "movie": + return media_type == MediaType.MOVIE + if type_setting == "tv": + return media_type == MediaType.TV + return False + + @staticmethod + def _field(history, name): + """兼容 TransferHistory 对象与 dict 两种整理记录形态。""" + if isinstance(history, dict): + return history.get(name) + return getattr(history, name, None) + + @staticmethod + def _history_to_dict(history) -> dict: + """把整理记录转换为可持久化到清理快照的 dict。""" + if isinstance(history, dict): + return history + to_dict = getattr(history, "to_dict", None) + return to_dict() if callable(to_dict) else dict(getattr(history, "__dict__", {})) diff --git a/plugins.v3/subscribeassistantenhanced/docs/architecture.md b/plugins.v3/subscribeassistantenhanced/docs/architecture.md new file mode 100644 index 00000000..a021d335 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/docs/architecture.md @@ -0,0 +1,409 @@ +# 订阅助手(增强版)整体架构设计 + +## 设计目标 + +订阅助手(增强版)在 MoviePilot 订阅体系之上提供完成前观察、剧集待定、上映/播出暂停、下载待定、洗版编排、订阅清理、识别增强和站点证据能力。插件架构以订阅生命周期为主线,将状态归属、证据来源、资源清理和用户通知拆分为独立领域,避免入口逻辑直接组合复杂副作用。 + +架构约束: + +- 入口层只解析事件、收集上下文并调用领域服务。 +- 生命周期状态由统一生命周期层编排。 +- 判定器只返回判定结果,不写订阅状态。 +- writer 只维护自己负责的状态事实,不跨领域推导业务流程。 +- 证据、诊断、审计和快照模块只提供输入事实,不直接拥有订阅生命周期状态。 + +## 订阅管理策略 + +增强版默认管理所有订阅,各业务域是否运行仍由现有全局开关决定。订阅管理策略只描述例外, +用于缩小管理范围或覆盖插件创建洗版订阅时的有限参数;策略不得重新启用全局已经关闭的能力。 + +策略按用户配置顺序从上到下匹配,第一条命中后停止,未命中时继承全局配置。首期匹配条件限定为 +稳定、可解释的订阅事实:媒体身份、媒体类型、季、剧集组、订阅用户、媒体类别和订阅模式。 +单条订阅规则不能只保存数据库 ID,因为分集转全集、完成后重建等流程会创建新的订阅记录; +订阅身份应由媒体来源 ID、媒体类型、季和剧集组共同确定。 + +媒体类别优先使用订阅显式设置的 `media_category`,没有显式值时使用识别结果中的类别。 +类别无法解析时按未知类别处理并输出可诊断结果,不能假定命中某条类别规则。 + +首期动作限定为: + +- 完全排除订阅,不再进入增强版的业务处理; +- 排除指定业务域,包括待定、暂停、完结守卫、订阅补全、洗版、下载管理、订阅清理和识别增强; +- 覆盖插件创建的全集洗版订阅保存目录,目录只能选择主程序已经配置的下载目录根路径。 + +保存目录覆盖不负责拼接媒体类型或类别目录。主程序负责识别精确命中的下载目录根路径,并按该目录 +已有的“按类型分类”和“按类别分类”设置生成最终路径;用户指定根路径下的自定义子目录时, +主程序必须将其视为完整路径,不能再次分类。 + +策略解析由纯逻辑的统一组件负责,返回命中规则、允许管理的业务域和有限覆盖项。事件入口、定时巡检 +和洗版编排必须复用同一解析结果,禁止在各领域散落用户名、类别或订阅 ID 判断。配置界面应提供规则 +排序、当前命中订阅预览以及订阅、类别和下载目录的受控选择,不提供任意脚本或通用字段修改能力。 + +订阅从受管理变为不受管理时,需要执行一次状态收敛:释放增强版持有的待定和暂停归属,移除插件 +下载监控与观察状态,但不得恢复外部持有的暂停状态,也不得删除下载器任务、文件、种子或整理记录。 +分集转全集或完成后重建产生新订阅后,需要按稳定订阅身份重新解析策略。 + +## 总体结构 + +```mermaid +flowchart TD + MP[MoviePilot 事件 / 链式事件 / 定时任务 / 命令] --> Entry[SubscribeAssistantEnhanced / EventProxy] + + Entry --> Lifecycle[SubscribeLifecycleCoordinator] + Entry --> BestVersion[BestVersionOrchestrator / BestVersionConverter / PriorityManager] + Entry --> Cleanup[SubscriptionCleanup / TorrentCleanup] + Entry --> Recognition[RecognitionGuard] + Entry --> Evidence[CompletionEvidencePipeline / SiteEvidence] + + Lifecycle --> Pause[PauseManager] + Lifecycle --> PendingJudge[PendingJudge] + Lifecycle --> PendingState[PendingStateCoordinator] + Lifecycle --> Airing[AiringPauseChecker] + Lifecycle --> DownloadState[download_pending 归属] + Lifecycle --> Effects[通知 / 补搜 / 恢复保护 / 归属清理] + + Entry --> DownloadMonitor[DownloadMonitor] + DownloadMonitor --> DownloadFacts[(下载任务索引 / hash / 超时事实)] + DownloadState --> PendingState + + Pause --> Store[(订阅表 + 插件任务数据)] + PendingState --> Store + BestVersion --> Store + Cleanup --> Store + Evidence --> Store +``` + +`SubscribeAssistantEnhanced` 是插件实例和依赖组装根。`EventProxy` 是事件代理,负责把主程序事件转换为插件内部调用。生命周期、待定、暂停、下载、洗版、清理、识别和证据模块通过明确接口协作。 + +## 前端联邦子工程 + +插件前端是位于 `frontend/` 的独立 Vue 子工程,与 Python 运行时代码分离: + +```text +frontend/ +├── src/ +│ ├── components/Config.vue +│ ├── config/ +│ └── assets/ +├── dist/assets/ +├── package.json +├── yarn.lock +├── tsconfig.json +└── vite.config.ts +``` + +- `src/components/Config.vue` 负责配置页布局、用户交互和 Host 事件。 +- `src/config/` 负责稳定配置键、默认值、草稿、保存 payload、字段元数据、本地化、运行概况 API 和展示规则。 +- `src/assets/` 保存前端品牌资源;品牌图在联邦构建中内联,避免按宿主根路径解析。 +- `dist/assets/` 是随插件发布的唯一前端运行产物,包含 `remoteEntry.js`、暴露组件和依赖入口。 + +插件入口通过 `get_render_mode()` 返回 `("vue", "frontend/dist/assets")`。MoviePilot 从插件静态文件接口加载 `remoteEntry.js`,再挂载模块联邦暴露的 `Config` 组件。发布包包含 `frontend/dist/assets/`,不依赖源码和 `node_modules` 才能运行。 + +生产构建执行 `yarn build`,会清空并重建 `frontend/dist/`。测试模式只启用 Vue 编译,不启用模块联邦和构建产物清理插件,使组件测试直接覆盖源码。`yarn dev` 使用 Vite watch 持续生成生产形态的联邦产物。 + +本地插件仓通过 `PLUGIN_LOCAL_REPO_PATHS` 接入 MoviePilot,`PLUGIN_AUTO_RELOAD=true` 负责同步构建产物并热加载插件。`DEV=true` 只用于暂停定时任务,不承担源码或联邦产物同步。 + +### Host 契约 + +`Config` 只依赖 Host 提供的输入和事件,不直接持久化插件配置: + +- `initialConfig`:Host 传入的动态配置;草稿层按稳定配置契约规范化。 +- `api`:Host 注入的已认证 API 客户端,用于读取运行概况。 +- `save`:提交包含全部稳定配置键的规范化 payload;一次性动作随本次保存提交并自动复位。 +- `close`:请求 Host 直接关闭配置页,不在插件内部实现未保存修改确认。 +- `layout`:请求 Host 使用 `68rem` 的配置页最大宽度。 + +桌面端“运行一次”提交 `onlyonce=true` 的完整保存 payload,保存成功后的关闭由 Host 统一处理。移动端保留表单内的一次性开关,并由保存动作提交。重置数据同样是保存触发的一次性命令。 + +### 前端测试架构 + +前端测试镜像源码责任域,但物理存放在仓库根测试目录,避免测试、fixture 和测试依赖进入插件运行时副本: + +```text +tests/v3/subscribeassistantenhanced/frontend/ +├── setup.ts +├── src/ +│ ├── components/__tests__/Config.spec.ts +│ └── config/__tests__/*.spec.ts +└── support/ + ├── factories/config.ts + ├── host.ts + ├── msw/server.ts + └── render.ts +``` + +- `setup.ts` 注册 jest-dom、浏览器观察器垫片、统一 cleanup 和 MSW 生命周期;未声明网络请求直接失败。 +- `support/render.ts` 使用真实 Vue 与 Vuetify 挂载组件,只替换 Host 才提供的 CRON 和 YAML 编辑器,并注入 Host 语言契约。 +- `support/host.ts` 提供 Host API 与运行概况测试替身。 +- `support/factories/` 为每个用例创建独立的完整配置,避免共享可变状态。 +- `src/config/__tests__/` 验证规范化、草稿、API、本地化和展示等纯逻辑契约。 +- `Config.spec.ts` 只通过可见 DOM、accessible name、用户操作、emits、Host API 和挂载生命周期验证组件行为。 +- Python 的 `test_vue_config_contract.py` 负责校验 Python Form 与 TypeScript 配置键、默认值和字段元数据的跨语言一致性。 + +Vitest 使用 jsdom、Testing Library、Vue Test Utils、MSW 和 V8 coverage。全局覆盖率门槛为分支 80%、函数 85%、行和语句 85%;纳入覆盖率的每个核心文件至少达到分支 75%、函数、行和语句 80%。`Config.vue` 的样式、容器查询、hover/focus、桌面与移动布局、联邦远程加载和 Host 保存关闭流程由真实 Chrome 验证。 + +前端 CI 仅在 SAE 前端源码、前端测试或工作流变化时运行,使用 Node 24 依次执行 frozen lockfile 安装、`yarn typecheck`、`yarn test:coverage` 和生产构建,并校验提交的 `dist/` 与源码构建结果一致。 + +## 入口层 + +入口层包含: + +- 事件入口:订阅新增、订阅修改、订阅删除、下载新增、整理完成、插件命令。 +- 链式事件入口:完成检查、集数刷新、资源选择、资源下载、整理拦截、数据重置。 +- 定时任务入口:元数据巡检、待定释放、一致性检查、无下载检查、下载任务检查、洗版检查、完成校验、清理任务。 +- 命令入口:用户通过插件命令触发的订阅操作。 + +入口层允许做: + +- payload 校验和转换; +- 订阅、媒体信息、TMDB 分集等上下文读取; +- 非生命周期领域调用,例如优先级回填、识别增强、候选过滤、清理检查; +- 调用生命周期层执行订阅状态相关操作。 + +入口层不直接写 `state`,不直接组合暂停、待定、补搜、恢复保护和状态通知的顺序。 + +## 生命周期层 + +`SubscribeLifecycleCoordinator` 是订阅状态生命周期的统一编排层。凡是会影响以下事实的业务路径,都属于生命周期层职责: + +- 主订阅状态:`N`、`R`、`P`、`S`。 +- 暂停归属:`pause_reason`、`pause_since`、`pause_detail`。 +- 待定归属:`pending_sources`、`source`、`reason`。 +- 下载待定归属:`download_pending`。 +- 状态变化派生副作用:补搜、状态通知、恢复保护、归属清理。 + +生命周期层不替代领域 writer。它负责决定跨领域顺序,并调用下游模块完成具体写入。 + +```mermaid +flowchart LR + Context[入口上下文] --> Lifecycle[SubscribeLifecycleCoordinator] + Lifecycle --> Decision{生命周期决策} + Decision --> Pause[PauseManager] + Decision --> Pending[PendingStateCoordinator] + Decision --> Judge[PendingJudge] + Decision --> Search[单订阅补搜] + Decision --> Notify[状态通知] + Decision --> Guard[恢复保护] +``` + +生命周期方法统一返回 `LifecycleResult`: + +```python +@dataclass +class LifecycleResult: + changed: bool = False + stopped: bool = False + state: str | None = None + reason: str = "" + message: str = "" +``` + +`changed` 表示状态或归属发生变化。`stopped` 表示入口后续生命周期流程应停止。`state`、`reason`、`message` 提供稳定的日志、命令回复和测试语义。 + +## 订阅状态模型 + +主程序订阅状态只表达用户可见状态: + +- `N`:新增,仍处于主程序首次搜索窗口。 +- `R`:启用,正常参与搜索与生命周期巡检。 +- `P`:待定,存在至少一个插件持有的待定来源。 +- `S`:禁用,存在用户、外部或插件持有的暂停事实。 + +插件归属状态解释 `P` 和 `S` 的原因。没有归属的 `P/S` 应被视为外部事实或残留事实,而不是默认归属于自动逻辑。 + +```mermaid +stateDiagram-v2 + [*] --> N + N --> R: 主程序完成新增流程 + N --> P: pending_judge / guard_veto / download_pending + N --> S: auto_user / pre_air / external + R --> P: 任一待定来源进入 + R --> S: 暂停来源进入 + P --> R: 最后一个待定来源释放 + P --> S: 暂停覆盖待定 + S --> R: 插件持有暂停释放 + S --> S: external 归属保持 +``` + +## 暂停领域 + +`PauseManager` 是暂停 writer。它维护: + +- 暂停原因和详情; +- 暂停优先级; +- `S/R` 状态写入; +- 暂停和恢复通知去重; +- 恢复保护相关字段。 + +暂停原因采用优先级仲裁。`external` 代表用户或外部系统持有的暂停事实,并拥有最高优先级。`/subscribe_toggle` 产生的手动暂停也使用 `external`,通过 `pause_detail` 标明来源,例如“插件命令手动暂停”。 + +`AiringPauseChecker` 是暂停判定器。它根据上映日期、开播日期、下一集日期和完成证据返回 `PauseRecord` 或恢复判断,不写状态。 + +```mermaid +flowchart TD + Media[媒体信息 + 分集 + 完成证据] --> Airing[AiringPauseChecker] + Airing --> Record{PauseRecord?} + Record -->|有| Lifecycle[Lifecycle] + Lifecycle --> PauseManager[PauseManager] + PauseManager --> Store[(pause_reason + state=S/R)] +``` + +## 待定领域 + +`PendingJudge` 负责 `pending_judge` 来源的待定判定。它根据开播窗口、集数、air_date 和完成证据判断是否应进入待定。 + +`PendingStateCoordinator` 是多来源待定 writer。它维护: + +- `pending_sources`; +- 主来源 `source`; +- 当前原因 `reason`; +- `P/R` 状态同步; +- 暂停覆盖待定时的归属清理。 + +待定来源包括: + +- `pending_judge`:剧集信息待确认。 +- `guard_veto`:完成前检查未通过,需要观察。 +- `download_pending`:下载已发起但尚未完成整理或下载器确认。 + +任一来源存在时订阅保持 `P`。只有最后一个来源释放后,订阅才恢复 `R`。 + +```mermaid +flowchart LR + Judge[pending_judge] --> Sources[pending_sources] + Guard[guard_veto] --> Sources + Download[download_pending] --> Sources + Sources --> State{是否还有来源?} + State -->|有| P[state=P] + State -->|无| R[state=R] +``` + +## 下载领域 + +`DownloadMonitor` 负责下载事实,不负责跨领域生命周期决策。它维护: + +- 下载任务索引; +- hash 和无 hash 下载的临时匹配; +- 下载任务是否仍活跃; +- 下载待定过期判断。 + +下载事实可触发 `download_pending` 来源进入或释放,但该来源的生命周期归属由生命周期层协调,并最终由 `PendingStateCoordinator` 仲裁。 + +## 完成前观察 + +`CompletionGuard` 处理完成事件前的链式否决。它读取完成证据,判断是否应: + +- 放行完成; +- 取消完成事件; +- 进入 `guard_veto` 待定观察; +- 记录或释放观察令牌。 + +完成前观察是待定来源之一,但不直接拥有待定状态写入。待定状态归属通过生命周期层进入 `PendingStateCoordinator`。 + +## 洗版领域 + +洗版领域包括: + +- `BestVersionOrchestrator`:创建和编排洗版订阅。 +- `BestVersionConverter`:分集洗版转全集洗版。 +- `PriorityManager`:优先级回填和分集优先级维护。 + +洗版领域可以更新洗版相关字段、发送主程序事件和通知,但不拥有订阅生命周期状态。若洗版流程需要改变 `state` 或状态归属,应通过生命周期层。 + +## 清理领域 + +清理领域包括: + +- `SubscriptionCleanup`:根据订阅完成、洗版完成和清理配置处理转移历史、旧媒体文件和旧记录。 +- `TorrentCleanup`:处理旧种子删除、删除指纹、删种后补搜和下载待定清理。 + +清理领域处理资源和历史,不直接持有订阅生命周期状态。清理过程中如需释放 `download_pending` 或触发状态恢复,应通过生命周期层提供的接口。 + +## 识别与证据领域 + +识别和证据领域包括: + +- `RecognitionGuard`:识别增强、候选准入和审计。 +- `CompletionEvidencePipeline`:聚合 TMDB、站点证据、波动和本地完成事实。 +- `SiteEvidence`:站点总集数和剧集证据。 +- 完成快照与快照清理。 + +这些模块提供判定输入或诊断信息,不直接写 `state`、`pause_reason`、`pending_sources` 或 `download_pending`。 + +## 主要数据流 + +### 新增订阅 + +```mermaid +flowchart TD + A[SubscribeAdded] --> B[EventProxy 读取订阅和媒体信息] + B --> C[优先级回填] + C --> L[Lifecycle] + L --> U{用户名规则?} + U -->|命中| S[写 auto_user 暂停] + U -->|未命中| Pre{上映前暂停?} + Pre -->|命中| P0[写 pre_air 暂停] + Pre -->|未命中| Full{全集洗版?} + Full -->|是| Done[结束生命周期处理] + Full -->|否| Pending{剧集待定?} + Pending -->|命中且 state=N| Search[安排单订阅补搜] + Search --> P1[写 pending_judge 待定] + Pending -->|命中且 state!=N| P1 + Pending -->|未命中| Gap{是否允许播出暂停?} + Gap -->|允许| Airing[检查 airing_gap] + Gap -->|不允许| Done +``` + +### 元数据巡检 + +```mermaid +flowchart TD + A[run_meta_check] --> B[遍历 N/R/P/S] + B --> L[Lifecycle] + L --> Flag{标记暂停仍为 S?} + Flag -->|是| Skip[跳过自动恢复] + Flag -->|否| Media[识别媒体信息] + Media --> PendingExit{P 态释放?} + PendingExit -->|已释放| End[结束本订阅] + PendingExit -->|未释放或非 P| Pause[暂停进入/刷新/恢复] + Pause --> PendingEnter[待定进入判定] + PendingEnter --> End +``` + +### 下载命中恢复暂停 + +```mermaid +flowchart TD + A[DownloadAdded] --> Monitor[登记下载事实] + Monitor --> L[Lifecycle] + L --> Paused{state=S?} + Paused -->|否| End[结束] + Paused -->|是| Record{有 pause record?} + Record -->|无| Adopt[登记 external 归属] + Record -->|有| Resume[静默恢复] + Adopt --> Resume + Resume --> Guard{原因为 external?} + Guard -->|否| ResumeGuard[写恢复保护] + Guard -->|是| Notify[发送恢复通知] + ResumeGuard --> Notify +``` + +## 允许的非生命周期更新 + +以下更新不属于生命周期状态: + +- 洗版优先级和分集优先级。 +- 转移历史、文件删除和种子删除。 +- 识别增强审计和通知限频。 +- 站点证据和完成快照。 +- 底层 `shared.update.update_subscribe` 工具调用。 + +判断标准:不改变 `state=N/R/P/S`,不持有或释放 `pause_reason`,不持有或释放 `pending_sources`,不创建或释放 `download_pending`,也不触发状态变化派生的补搜、恢复保护或状态通知。 + +## 演进规则 + +- 新的入口如果改变订阅生命周期状态,必须接入生命周期层。 +- 新的暂停原因、待定来源或下载待定来源,必须同步更新状态模型、生命周期测试和本文档图例。 +- 入口层不得直接拼装多个生命周期副作用。 +- 领域模块不得为了方便绕过生命周期层写状态归属。 +- 测试应按入口、生命周期和领域 writer 分层维护;只验证调用路径、缺少业务断言的测试不应作为长期契约保留。 diff --git a/plugins.v3/subscribeassistantenhanced/download/__init__.py b/plugins.v3/subscribeassistantenhanced/download/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/download/cleanup.py b/plugins.v3/subscribeassistantenhanced/download/cleanup.py new file mode 100644 index 00000000..79dab07f --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/download/cleanup.py @@ -0,0 +1,213 @@ +"""种子删除后的统一善后编排。""" +from typing import Callable, Optional + +from app.chain.subscribe import SubscribeChain +from app.schemas.types import MediaType + +from ..engine.types import PriorityManagerProtocol +from ..shared.log import detail +from ..shared.subscribe import format_subscribe, resolve_subscribe_media_type +from ..shared.update import update_subscribe + + +class TorrentCleanup: + """种子删除统一编排:归档删除指纹 → 删种 → 回滚优先级 → 清任务 → 补搜。 + + 外部副作用通过注入回调执行,避免本模块直接绑定下载器、搜索或文件系统实现。 + """ + + def __init__(self, priority_manager: PriorityManagerProtocol, + clear_download_pending_fn: Callable, + task_data_update: Callable, + task_data_read: Optional[Callable] = None, + deletes_store=None, + delete_torrent_fn: Optional[Callable] = None, + search_fn: Optional[Callable] = None, + notify_fn: Optional[Callable] = None, + get_subscribe_image_fn: Optional[Callable] = None, + subscribe_oper=None): + """注入删种、任务清理、补搜和通知依赖。""" + self._priority = priority_manager + self._clear_pending = clear_download_pending_fn + self._update = task_data_update + self._read = task_data_read + self._deletes = deletes_store + self._delete_torrent = delete_torrent_fn + self._search = search_fn + self._notify = notify_fn + self._get_subscribe_image = get_subscribe_image_fn + self._subscribe_oper = subscribe_oper + + def handle_torrent_deleted(self, subscribe, torrent_hash: str, + reason: str = "download_timeout", + reason_detail: Optional[str] = None, + downloader: Optional[str] = None, + delete_from_downloader: bool = True, + search_enabled: bool = True): + """种子删除后的统一处理,步骤顺序固定,避免中途失败留下不一致状态。 + + delete_from_downloader:仅下载器主动删种(timeout/tracker)为 True;手动删除时种子已不在, + 传 False 跳过删种。删除指纹负责防止同一坏种被立即重选,订阅继续保持可搜索状态。 + """ + sid = subscribe.id + detail( + f"种子删除处理:{format_subscribe(subscribe)} 开始处理 hash={torrent_hash}" + f"(reason={reason}, delete_from_downloader={delete_from_downloader})" + ) + + # 1. 清 torrents 任务前归档删除指纹,供 ResourceSelection 防止坏种立即重选。 + torrent_task = self._read_torrent_task(torrent_hash) + if self._deletes and torrent_task: + detail(f"种子删除处理:已记录种子 {torrent_hash},避免后续被重新选中") + self._deletes.save(torrent_task, reason=reason) + + # 删种后先恢复下载事实,再交给主程序按当前合同刷新订阅进度。 + self._restore_subscribe_missing_state(subscribe, torrent_task) + + # 2. 下载器主动删除场景真正删种;用户手动删除场景种子已不存在。 + if delete_from_downloader and self._delete_torrent and downloader and torrent_hash: + self._delete_torrent(downloader, torrent_hash) + + # 3. 洗版按 enclosure 归属回滚,隔离并行洗版;旧数据无归属时退回整体基线。 + if subscribe.best_version: + enclosure = (torrent_task or {}).get("enclosure") + if enclosure: + detail(f"种子删除处理:{format_subscribe(subscribe)} 恢复本次洗版下载对应集数的优先级") + self._priority.rollback_torrent(subscribe, enclosure) + else: + detail(f"种子删除处理:{format_subscribe(subscribe)} 无法确认对应集数,恢复整体洗版优先级") + self._priority.rollback(subscribe, baseline=None) + + # 4. 清理种子任务与下载待定,避免订阅长期保持下载中。 + self._clean_torrent_task(torrent_hash) + self._clean_subscribe_torrent_task(sid, torrent_hash) + self._clear_pending(sid, torrent_hash) + + # 5. 按配置触发补搜,避免删种后长期缺集。 + search_delay_seconds = None + if search_enabled and self._search and subscribe: + search_delay_seconds = self._search(subscribe) + if not isinstance(search_delay_seconds, (int, float)): + search_delay_seconds = None + self._notify_deleted( + subscribe, torrent_task, reason, + reason_detail=reason_detail, + search_delay_seconds=search_delay_seconds, + ) + + def handle_timeout_manual_review(self, subscribe, torrent_hash: str, + reason_detail: str, ignore_hours: int = 48): + """连续低进度达到保护上限时保留种子,并通知用户人工判断。""" + if not self._notify: + return + torrent_task = self._read_torrent_task(torrent_hash) or {} + detail_parts = [] + if torrent_task.get("title"): + detail_parts.append(f"标题:{torrent_task.get('title')}") + if torrent_task.get("description"): + detail_parts.append(f"内容:{torrent_task.get('description')}") + action = f"已保留当前种子,{ignore_hours} 小时内不再自动删除" + detail(f"种子删除处理:{format_subscribe(subscribe)} 原因={reason_detail},处理={action},后续=请手动判断") + self._notify( + f"{format_subscribe(subscribe)} {self._manual_review_title_reason(reason_detail)},{action}", + "\n".join(detail_parts) if detail_parts else None, + image=self._subscribe_image(subscribe), + follow_up="请手动判断", + diagnostic=True, + ) + + @staticmethod + def _manual_review_title_reason(reason_detail: str) -> str: + """将低进度诊断改写为通知标题语序,保留下载时长与进度判断信息。""" + prefix = "订阅种子," + if reason_detail.startswith(prefix): + return f"{prefix}下载连续超时,{reason_detail[len(prefix):]}" + return f"下载连续超时,{reason_detail}" + + def _read_torrent_task(self, torrent_hash: str) -> Optional[dict]: + """删除前读取种子任务,供删除指纹归档与按集基线回滚。""" + if not self._read or not torrent_hash: + return None + return (self._read("torrents") or {}).get(torrent_hash) + + def _restore_subscribe_missing_state(self, subscribe, torrent_task: Optional[dict]): + """删种善后恢复下载事实,确保后续补搜能覆盖被删集。""" + if not self._subscribe_oper or not subscribe or not torrent_task: + return + media_type = resolve_subscribe_media_type(subscribe) + payload = {} + if media_type == MediaType.TV: + note = list(subscribe.note or []) + episodes = torrent_task.get("episodes") or [] + episode_set = set(episodes if isinstance(episodes, list) else [episodes]) + kept_note = [episode for episode in note if episode not in episode_set] + payload["note"] = kept_note + elif media_type == MediaType.MOVIE: + payload["note"] = [] + if payload: + update_subscribe(self._subscribe_oper, subscribe.id, payload) + for key, value in payload.items(): + setattr(subscribe, key, value) + if media_type == MediaType.TV: + SubscribeChain().refresh_subscribe_progress(subscribe, scene="plugin_delete_rollback") + + def _clean_torrent_task(self, torrent_hash: str): + """清理种子任务数据。""" + def updater(data: dict) -> dict: + data.pop(torrent_hash, None) + return data + self._update("torrents", updater) + + def _clean_subscribe_torrent_task(self, subscribe_id: int, torrent_hash: str): + """同步清理订阅内 torrent_tasks,兼容订阅级种子记录。""" + sid = str(subscribe_id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + torrent_tasks = task.get("torrent_tasks") + if torrent_tasks: + task["torrent_tasks"] = [ + item for item in torrent_tasks + if item.get("hash") != torrent_hash + ] + data[sid] = task + return data + + self._update("subscribes", updater) + + def _notify_deleted(self, subscribe, torrent_task: Optional[dict], reason: str, + reason_detail: Optional[str] = None, + search_delay_seconds: Optional[float] = None): + """发送种子删除通知,标题包含订阅、删除原因和最终动作。""" + if not self._notify: + return + reason_text = { + "timeout": "超时无进度", + "delete_tracker": "Tracker 返回内容包含删除关键字", + "manual": "订阅种子手动删除", + "download_timeout": "超时无进度", + }.get(reason, reason) + detail_parts = [] + if torrent_task: + if torrent_task.get("title"): + detail_parts.append(f"标题:{torrent_task.get('title')}") + if torrent_task.get("description"): + detail_parts.append(f"内容:{torrent_task.get('description')}") + follow_up = None + if search_delay_seconds is not None: + follow_up = f"将在 {search_delay_seconds / 60:.2f} 分钟后触发搜索补全" + detail( + f"种子删除处理:{format_subscribe(subscribe)} 原因={reason_detail or reason_text}," + f"处理=已删除,后续={follow_up or '无'}" + ) + self._notify( + f"{format_subscribe(subscribe)} {reason_detail or reason_text},已删除", + "\n".join(detail_parts) if detail_parts else None, + image=self._subscribe_image(subscribe), + follow_up=follow_up, + diagnostic=True, + ) + + def _subscribe_image(self, subscribe): + """读取订阅通知图片;未注入图片解析器时保持兼容。""" + return self._get_subscribe_image(subscribe) if self._get_subscribe_image else None diff --git a/plugins.v3/subscribeassistantenhanced/download/monitor.py b/plugins.v3/subscribeassistantenhanced/download/monitor.py new file mode 100644 index 00000000..d7e0d635 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/download/monitor.py @@ -0,0 +1,833 @@ +"""下载生命周期状态机、自动删种与下载待定管理。""" +import hashlib +import re +import time +from typing import Callable, Optional + +from app.log import logger +from app.schemas.types import MediaType + +from .torrent import TorrentInfo +from ..shared.log import detail, format_log_title_desc +from ..shared.subscribe import format_subscribe_label, resolve_subscribe_media_type + +TIMEOUT_MANUAL_REVIEW_IGNORE_HOURS = 24 + + +class DownloadMonitor: + """下载状态机:DOWNLOADING → TIMEOUT_CHECK → DELETED/MANUAL_REVIEW/IGNORED。""" + + 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, + subscribe_oper=None, + fetch_fn: Optional[Callable] = None, + present_fn: Optional[Callable] = None, + manual_delete_enabled: bool = True, + manual_miss_threshold: int = 2, + pending_download_enabled: bool = True, + state_coordinator=None, + pending_hash_grace_seconds: int = 10 * 60): + """保存下载检查参数;下载中待定开关不影响自动删种检查。""" + self._read = task_data_read + 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 [] + self._subscribe_oper = subscribe_oper + # fetch_fn(downloader, hash) -> TorrentInfo;未注入时不判定、不删种。 + self._fetch_fn = fetch_fn + # present_fn(downloader, hash) -> Optional[bool]:True=存在,False=可达但不存在,None=不可判定。 + self._present_fn = present_fn + # 关闭监听手动删除时,仍清理本地失效任务,但不触发删种善后。 + self._manual_delete_enabled = manual_delete_enabled + # 连续 miss 达阈值才判手动删除,避免下载器瞬断触发误删善后。 + self._manual_miss_threshold = manual_miss_threshold + self._pending_download_enabled = pending_download_enabled + self._state = state_coordinator + self._pending_hash_grace_seconds = pending_hash_grace_seconds + + def set_state_coordinator(self, state_coordinator): + """替换下载待定状态适配器,避免下载模块直接依赖完整生命周期对象。""" + self._state = state_coordinator + + def mark_download_pending(self, subscribe_id: int, torrent_hash: str): + """记录订阅还有下载未整理完成。""" + if not self._pending_download_enabled: + return + sid = str(subscribe_id) + now = time.time() + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + pending = task.get("download_pending", {}) + pending[torrent_hash] = {"hash": torrent_hash, "started_at": now} + task["download_pending"] = pending + data[sid] = task + return data + + self._update("subscribes", updater) + + def mark_download_started(self, subscribe, episodes=None, downloader: Optional[str] = None, + enclosure: Optional[str] = None, page_url: Optional[str] = None, + title: Optional[str] = None, description: Optional[str] = None): + """ResourceDownload 阶段登记无 hash 下载待定,覆盖 DownloadAdded 前的完成检查空窗。""" + if not self._pending_download_enabled or not subscribe: + return + sid = str(subscribe.id) + now = time.time() + key = self._pending_key(enclosure=enclosure, page_url=page_url, title=title) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + pending = task.get("download_pending", {}) + pending[key] = { + "hash": None, + "started_at": now, + "episodes": list(episodes or []), + "downloader": downloader, + "enclosure": enclosure, + "page_url": page_url, + "title": title, + "description": description, + } + task["download_pending"] = pending + data[sid] = task + return data + + self._update("subscribes", updater) + if self._state: + self._state.mark_active(subscribe, source="download_pending", reason="下载已发起,等待下载器确认任务") + + def clear_download_pending(self, subscribe_id: int, torrent_hash: str): + """清除指定下载任务对应的待定记录。""" + sid = str(subscribe_id) + result = {"had_pending": False, "active": False} + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + pending = task.get("download_pending", {}) + result["had_pending"] = torrent_hash in pending + pending.pop(torrent_hash, None) + if not pending: + task.pop("download_pending", None) + else: + task["download_pending"] = pending + result["active"] = True + data[sid] = task + return data + + self._update("subscribes", updater) + if result["had_pending"] and not result["active"] and self._state: + subscribe = self._resolve_subscribe(subscribe_id) + if subscribe: + self._state.clear_active(subscribe, source="download_pending", reason="下载待定已清除") + + def has_active_downloads(self, subscribe_id: int) -> bool: + """检查订阅是否还有下载未整理完成。""" + sid = str(subscribe_id) + data = self._read("subscribes") + task = data.get(sid, {}) + return self._drop_expired_hashless_pending(subscribe_id, task) + + def on_download(self, subscribe_id, torrent_hash: str, episodes=None, + downloader: Optional[str] = None, progress: float = 0.0, + enclosure: Optional[str] = None, page_url: Optional[str] = None, + title: Optional[str] = None, description: Optional[str] = None): + """DownloadAdded 阶段按 hash 登记种子监控与归属信息。 + + enclosure 用于洗版按集基线回滚,enclosure/page_url 用于删除指纹防重; + 此阶段 hash 已确定,同时补齐 ResourceDownload 建立的无 hash 待定。 + """ + if not torrent_hash: + return + now = time.time() + + def updater(data: dict) -> dict: + data[torrent_hash] = { + "hash": torrent_hash, + "subscribe_id": subscribe_id, + "episodes": list(episodes or []), + "downloader": downloader, + "enclosure": enclosure, + "page_url": page_url, + "title": title, + "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, + } + return data + + self._update("torrents", updater) + if subscribe_id and self._pending_download_enabled: + self._confirm_download_pending( + subscribe_id, + torrent_hash, + episodes=episodes, + downloader=downloader, + enclosure=enclosure, + page_url=page_url, + title=title, + description=description, + now=now, + ) + + def run_timeout_check(self, cleanup=None): + """定时巡检种子实时状态,将超时、Tracker 命中与手动删除交给 cleanup 善后。 + + fetch_fn 未注入时安全空操作;完成或本地失效任务会释放下载待定。 + 只有启用监听手动删除且 present_fn 明确返回 False,才进入删除善后。 + """ + torrents = self._read("torrents") or {} + total = len(torrents) + if total == 0: + detail("下载监控:当前没有记录中的下载任务,跳过本轮检查") + return + if not self._fetch_fn: + detail(f"下载监控:无法读取下载器状态,本轮不检查 {total} 个下载任务") + return + visible_count = 0 + skipped_count = 0 + missing_realtime_count = 0 + no_present_check_count = 0 + unknown_present_count = 0 + present_exists_count = 0 + pending_miss_count = 0 + cleanup_count = 0 + removed_count = 0 + triggered_subscribe_ids = set() + for torrent_hash, task in list(torrents.items()): + downloader = task.get("downloader") + info = self._fetch_fn(downloader, torrent_hash) + if info: + visible_count += 1 + self._reset_missing(torrent_hash) + if info.completed: + logger.info( + f"下载监控:种子 {self._format_torrent_desc(torrent_hash, task)} 已完成," + f"{self._format_task_subscribe_label(task)}," + f"关联集数={self._format_task_episodes(task)},将从订阅下载任务中移除" + ) + self._clean_local_torrent_task(task.get("subscribe_id"), torrent_hash) + removed_count += 1 + continue + if cleanup is None: + continue + action = self.check_torrent(info, task.get("subscribe_id")) + if action in ("timeout", "delete_tracker") and cleanup: + subscribe = self._resolve_subscribe(task.get("subscribe_id")) + if subscribe is not None: + reason_text = "Tracker 返回内容包含删除关键字" if action == "delete_tracker" else "连续观察后仍无进度" + logger.info( + f"下载监控:种子 {self._format_torrent_desc(torrent_hash, task)} 需要删除," + f"{self._format_task_subscribe_label(task)}," + f"关联集数={self._format_task_episodes(task)},原因:{reason_text}" + ) + reason_detail = ( + self.get_timeout_reason(task.get("subscribe_id"), task, info) + if action == "timeout" else None + ) + self._handle_cleanup_once_per_subscribe( + cleanup, subscribe, triggered_subscribe_ids, + torrent_hash, reason=action, + reason_detail=reason_detail, + downloader=downloader, delete_from_downloader=True) + cleanup_count += 1 + elif action == "manual_review" and cleanup: + subscribe = self._resolve_subscribe(task.get("subscribe_id")) + if subscribe is not None: + cleanup.handle_timeout_manual_review( + subscribe, + torrent_hash, + self.get_timeout_reason(task.get("subscribe_id"), task, info), + ignore_hours=TIMEOUT_MANUAL_REVIEW_IGNORE_HOURS, + ) + continue + # 拿不到实时状态时,只有下载器可达且连续确认种子不存在,才按用户手动删除处理。 + missing_realtime_count += 1 + if not self._present_fn: + no_present_check_count += 1 + skipped_count += 1 + continue + present = self._present_fn(downloader, torrent_hash) if self._present_fn else None + if present is not False: + if present is True: + present_exists_count += 1 + else: + unknown_present_count += 1 + skipped_count += 1 + continue + if not self._manual_delete_enabled: + logger.info( + f"下载监控:种子 {self._format_torrent_desc(torrent_hash, task)} 不在下载器中," + f"{self._format_task_subscribe_label(task)}," + f"关联集数={self._format_task_episodes(task)},将按失效下载任务清理" + ) + self._clean_local_torrent_task(task.get("subscribe_id"), torrent_hash) + removed_count += 1 + self._reset_missing(torrent_hash) + continue + if self._bump_missing(torrent_hash) < self._manual_miss_threshold: + pending_miss_count += 1 + skipped_count += 1 + continue + subscribe = self._resolve_subscribe(task.get("subscribe_id")) + if self._manual_delete_enabled and subscribe is not None and cleanup: + logger.info( + f"下载监控:种子 {self._format_torrent_desc(torrent_hash, task)} " + f"连续 {self._manual_miss_threshold} 次不在下载器中," + f"{self._format_task_subscribe_label(task)}," + f"关联集数={self._format_task_episodes(task)},按用户手动删除处理" + ) + self._handle_cleanup_once_per_subscribe( + cleanup, subscribe, triggered_subscribe_ids, + torrent_hash, reason="manual", + downloader=downloader, delete_from_downloader=False) + cleanup_count += 1 + else: + logger.info( + f"下载监控:种子 {self._format_torrent_desc(torrent_hash, task)} 不在下载器中," + f"{self._format_task_subscribe_label(task)}," + f"关联集数={self._format_task_episodes(task)},将按失效下载任务清理" + ) + self._clean_local_torrent_task(task.get("subscribe_id"), torrent_hash) + removed_count += 1 + self._reset_missing(torrent_hash) + skip_detail = self._format_skip_summary( + missing_realtime_count=missing_realtime_count, + no_present_check_count=no_present_check_count, + unknown_present_count=unknown_present_count, + present_exists_count=present_exists_count, + pending_miss_count=pending_miss_count, + skipped_count=skipped_count, + ) + detail( + f"下载监控:本轮检查 {total} 个下载任务,下载器中仍存在 {visible_count} 个," + f"{skip_detail},已处理删除 {cleanup_count} 个,从订阅下载任务移除 {removed_count} 个" + ) + + def _format_task_subscribe_label(self, task: dict) -> str: + """下载任务日志中的订阅标签;优先展示订阅名、季号和订阅 ID。""" + subscribe_id = (task or {}).get("subscribe_id") + subscribe = self._resolve_subscribe(subscribe_id) + return format_subscribe_label(subscribe, subscribe_id) + + @staticmethod + def _format_task_episodes(task: dict) -> str: + """格式化下载任务关联集数,未知集数保持可诊断。""" + episodes = (task or {}).get("episodes") or [] + if not episodes: + return "未知" + return ",".join(str(episode) for episode in episodes) + + @staticmethod + def _format_torrent_desc(torrent_hash: str, task: dict) -> str: + """格式化种子标题和内容;hash 固定放在括号中,便于日志检索。""" + title_desc = format_log_title_desc( + title=(task or {}).get("title"), + description=(task or {}).get("description"), + ) + return f"{title_desc} ({torrent_hash})" if title_desc else f"({torrent_hash})" + + @staticmethod + def _format_skip_summary(missing_realtime_count: int, + no_present_check_count: int, + unknown_present_count: int, + present_exists_count: int, + pending_miss_count: int, + skipped_count: int) -> str: + """把保守跳过的下载任务拆成可诊断原因,便于区分连接失败和去抖保护。""" + if skipped_count <= 0: + return "暂不处理 0 个" + parts = [f"暂不处理 {skipped_count} 个"] + if missing_realtime_count: + parts.append(f"未取到实时任务信息 {missing_realtime_count} 个") + if no_present_check_count: + parts.append(f"缺少任务存在性确认能力 {no_present_check_count} 个") + if unknown_present_count: + parts.append(f"无法确认任务是否仍存在 {unknown_present_count} 个") + if present_exists_count: + parts.append(f"存在性确认仍在下载器中 {present_exists_count} 个") + if pending_miss_count: + parts.append(f"连续缺失未达阈值 {pending_miss_count} 个") + parts.append("建议检查下载器连接、下载器别名配置和本轮任务是否刚被客户端刷新") + return ",".join(parts) + + def _resolve_subscribe(self, subscribe_id): + """按 subscribe_id 解析订阅对象,供删种善后与优先级回滚使用。""" + if self._subscribe_oper and subscribe_id: + return self._subscribe_oper.get(subscribe_id) + return None + + @staticmethod + def _handle_cleanup_once_per_subscribe(cleanup, subscribe, triggered_subscribe_ids: set, + torrent_hash: str, **kwargs): + """同一轮同一订阅只允许首个删种善后触发补搜,其余种子仍完整清理。""" + if subscribe.id in triggered_subscribe_ids: + cleanup.handle_torrent_deleted(subscribe, torrent_hash, search_enabled=False, **kwargs) + return + triggered_subscribe_ids.add(subscribe.id) + cleanup.handle_torrent_deleted(subscribe, torrent_hash, **kwargs) + + def _clean_local_torrent_task(self, subscribe_id: int, torrent_hash: str): + """只清理本地下载任务和下载待定,不记录坏种、不回滚优先级、不触发补搜。""" + self._remove_torrent_task(torrent_hash) + if subscribe_id: + self.clear_download_pending(subscribe_id, torrent_hash) + self._remove_subscribe_torrent_task(subscribe_id, torrent_hash) + + def _remove_torrent_task(self, torrent_hash: str): + """从下载任务表移除指定 hash。""" + def updater(data: dict) -> dict: + data.pop(torrent_hash, None) + return data + + self._update("torrents", updater) + + def _remove_subscribe_torrent_task(self, subscribe_id: int, torrent_hash: str): + """移除订阅内 subscribes.torrent_tasks 的同名种子任务。""" + sid = str(subscribe_id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + torrent_tasks = task.get("torrent_tasks") + if torrent_tasks: + task["torrent_tasks"] = [ + item for item in torrent_tasks + if item.get("hash") != torrent_hash + ] + data[sid] = task + return data + + self._update("subscribes", updater) + + def _confirm_download_pending(self, subscribe_id, torrent_hash: str, episodes=None, + downloader: Optional[str] = None, + enclosure: Optional[str] = None, + page_url: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + now: Optional[float] = None): + """DownloadAdded 补齐下载待定 hash,优先复用 ResourceDownload 的无 hash 记录。""" + sid = str(subscribe_id) + now = now or time.time() + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + pending = task.get("download_pending", {}) + matched_key = self._find_hashless_pending_key(pending, enclosure=enclosure, page_url=page_url) + base = pending.pop(matched_key, {}) if matched_key else {} + pending[torrent_hash] = { + "hash": torrent_hash, + "started_at": base.get("started_at", now), + "episodes": list(episodes if episodes is not None else base.get("episodes") or []), + "downloader": downloader or base.get("downloader"), + "enclosure": enclosure or base.get("enclosure"), + "page_url": page_url or base.get("page_url"), + "title": title or base.get("title"), + "description": description or base.get("description"), + } + task["download_pending"] = pending + data[sid] = task + return data + + self._update("subscribes", updater) + subscribe = self._resolve_subscribe(subscribe_id) + if subscribe and self._state: + self._state.mark_active(subscribe, source="download_pending", reason="下载器已创建任务,等待整理入库") + + def _drop_expired_hashless_pending(self, subscribe_id: int, task: dict) -> bool: + """清理超过宽限期仍未补 hash 的下载待定,并返回是否仍有活跃下载。""" + pending = (task or {}).get("download_pending") or {} + if not pending: + return False + now = time.time() + kept = {} + changed = False + for key, item in pending.items(): + if item.get("hash"): + kept[key] = item + continue + try: + started_at = float(item.get("started_at") or 0) + except (TypeError, ValueError): + started_at = 0 + if started_at > 0 and now - started_at <= self._pending_hash_grace_seconds: + kept[key] = item + continue + changed = True + detail(f"下载待定:订阅 {subscribe_id} 发起下载后超过 {self._pending_hash_grace_seconds} 秒仍未创建下载器任务,解除下载待定") + if not changed: + return True + + sid = str(subscribe_id) + + def updater(data: dict) -> dict: + sub_task = data.get(sid, {}) + if kept: + sub_task["download_pending"] = kept + else: + sub_task.pop("download_pending", None) + data[sid] = sub_task + return data + + self._update("subscribes", updater) + if not kept and self._state: + subscribe = self._resolve_subscribe(subscribe_id) + if subscribe: + self._state.clear_active(subscribe, source="download_pending", reason="下载器长时间未确认任务") + return bool(kept) + + @staticmethod + def _pending_key(enclosure: Optional[str] = None, page_url: Optional[str] = None, + title: Optional[str] = None) -> str: + """生成无 hash 下载待定 key,优先使用 enclosure/page_url/title 指纹。""" + raw = enclosure or page_url or title or str(time.time()) + return f"pending:{hashlib.sha1(raw.encode('utf-8')).hexdigest()}" + + @staticmethod + def _find_hashless_pending_key(pending: dict, enclosure: Optional[str] = None, + page_url: Optional[str] = None) -> Optional[str]: + """按 enclosure/page_url 匹配 ResourceDownload 写入的无 hash 待定。""" + for key, item in pending.items(): + if item.get("hash"): + continue + if enclosure and item.get("enclosure") == enclosure: + return key + if page_url and item.get("page_url") == page_url: + return key + return None + + def _bump_missing(self, torrent_hash: str) -> int: + """累加下载器可达但种子不存在的连续 miss 次数。""" + result = {"count": 0} + + def updater(data: dict) -> dict: + task = data.get(torrent_hash, {}) + task["missing_count"] = task.get("missing_count", 0) + 1 + result["count"] = task["missing_count"] + data[torrent_hash] = task + return data + + self._update("torrents", updater) + return result["count"] + + def _reset_missing(self, torrent_hash: str): + """种子恢复可见或已处理后,清零连续不存在次数。""" + def updater(data: dict) -> dict: + task = data.get(torrent_hash) + if task and "missing_count" in task: + task.pop("missing_count", None) + data[torrent_hash] = task + return data + + self._update("torrents", updater) + + def check_torrent(self, torrent_info: TorrentInfo, subscribe_id: int) -> str: + """检查种子状态,返回 ok/timeout/delete_tracker/manual_review/ignored。""" + if self._should_exclude(torrent_info): + return "ignored" + + if self._matches_tracker_keywords(torrent_info): + detail(f"下载监控:种子 {torrent_info.hash} 的 Tracker 返回内容包含删除关键字 {self._tracker_keywords}") + return "delete_tracker" + + torrent_task = self._get_torrent_task(torrent_info.hash) + + if torrent_info.completed: + return "ok" + + if not torrent_task: + self._init_torrent_task(torrent_info) + return "ok" + + if self._has_progress(torrent_info, torrent_task): + self._refresh_baseline(torrent_info) + self._clear_timeout_state(subscribe_id, torrent_task) + return "ok" + + 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): + detail(f"下载监控:种子 {torrent_info.hash} 处于连续低进度保护期,本轮跳过") + return "ignored" + + timeout_state = self._record_timeout_failure(subscribe_id, torrent_info.hash, torrent_task) + retry_limit = max(int(self._retry_limit or 1), 1) + if timeout_state.get("fail_count", 0) >= retry_limit: + self._mark_timeout_manual_review(subscribe_id, torrent_task) + detail(f"下载监控:种子 {torrent_info.hash} 已达到连续低进度保护上限,本轮保留种子并等待人工确认") + return "manual_review" + + detail( + f"下载监控:种子 {torrent_info.hash} 低进度超时" + f"(低进度删除 {timeout_state.get('fail_count', 0)}/{retry_limit} 次),准备删除" + ) + return "timeout" + + def _should_exclude(self, info: TorrentInfo) -> bool: + if not self._exclude_tags: + return False + return any(tag in self._exclude_tags for tag in info.tags) + + def _matches_tracker_keywords(self, info: TorrentInfo) -> bool: + if not self._tracker_keywords: + return False + for response in info.tracker_responses: + for kw in self._tracker_keywords: + if self._tracker_keyword_matches(kw, response): + return True + return False + + @staticmethod + def _tracker_keyword_matches(keyword: str, response: str) -> bool: + """Tracker 关键字优先按正则匹配;表达式非法时退回大小写不敏感文本包含。""" + try: + return bool(re.search(keyword, response, flags=re.IGNORECASE)) + except re.error: + return keyword.lower() in response.lower() + + def _get_torrent_task(self, torrent_hash: str) -> Optional[dict]: + data = self._read("torrents") + 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": now, + "queue_grace_seconds": 0, + "last_timeout_check_at": now, + "retry_count": 0, + "manual_review_count": 0, + } + return data + self._update("torrents", updater) + + def _has_progress(self, info: TorrentInfo, task: dict) -> bool: + baseline = task.get("baseline_progress", 0.0) + diff = (info.progress - baseline) * 100 + 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"] = 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: + task = data.get(torrent_hash, {}) + task["retry_count"] = current + 1 + data[torrent_hash] = task + return data + self._update("torrents", updater) + + def _mark_manual_review(self, torrent_hash: str): + """首次 timeout 后写 manual_review_count,再次 timeout 转 MANUAL_REVIEW。""" + def updater(data: dict) -> dict: + task = data.get(torrent_hash, {}) + task["manual_review_count"] = task.get("manual_review_count", 0) + 1 + data[torrent_hash] = task + return data + self._update("torrents", updater) + + def _timeout_scope_key(self, subscribe_id: int, torrent_task: dict) -> str: + """生成连续低进度统计范围;剧集按季和集数,其他订阅按 movie 兜底。""" + subscribe = self._resolve_subscribe(subscribe_id) + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.TV: + episodes = torrent_task.get("episodes") or [] + if not isinstance(episodes, list): + episodes = [episodes] + episode_key = ",".join(sorted(str(ep) for ep in episodes if ep is not None)) or "unknown" + season = subscribe.season if subscribe else None + return f"tv:{season if season is not None else 'unknown'}:{episode_key}" + return "movie" + + def _record_timeout_failure(self, subscribe_id: int, torrent_hash: str, torrent_task: dict) -> dict: + """按订阅范围记录低进度超时次数,换种子后仍继承同一 scope 的保护计数。""" + sid = str(subscribe_id) + scope_key = self._timeout_scope_key(subscribe_id, torrent_task) + now = time.time() + result = {"state": {}} + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + states = task.get("timeout_states", {}) + state = states.get(scope_key, {}) + try: + window_start = float(state.get("window_start") or now) + except (TypeError, ValueError): + window_start = now + if self._timeout_retry_window_seconds() and now - window_start > self._timeout_retry_window_seconds(): + state = {} + window_start = now + state["fail_count"] = int(state.get("fail_count") or 0) + 1 + state["window_start"] = window_start + state["last_fail_time"] = now + state["last_torrent_hash"] = torrent_hash + states[scope_key] = state + task["timeout_states"] = states + data[sid] = task + result["state"] = state + return data + + self._update("subscribes", updater) + return result["state"] + + def get_timeout_reason(self, subscribe_id: int, torrent_task: dict, torrent_info: TorrentInfo) -> str: + """描述下载时长、观察窗口、进度增长和连续超时次数。""" + scope_key = self._timeout_scope_key(subscribe_id, torrent_task) + subscribe_task = (self._read("subscribes") or {}).get(str(subscribe_id), {}) + timeout_state = (subscribe_task.get("timeout_states") or {}).get(scope_key, {}) + started_at = torrent_task.get("time") or torrent_task.get("baseline_at") or time.time() + 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"{queue_grace_text}超时窗口 {timeout_hours:g} 小时内进度增长 {progress_delta:.2f}%," + f"低于 {self._progress_threshold:g}%" + f"(低进度删除 {timeout_state.get('fail_count', 0)}/{retry_limit} 次)" + ) + + def _timeout_retry_window_seconds(self) -> float: + """连续低进度统计窗口:至少 24 小时,或超时窗口乘保护次数。""" + return max(24 * 3600, self._timeout_seconds * max(int(self._retry_limit or 1), 1)) + + def _is_timeout_ignore_active(self, subscribe_id: int, torrent_hash: str, torrent_task: dict) -> bool: + """读取人工保护期:同一 hash 在 ignore_until 前不再重复计数或处理。""" + sid = str(subscribe_id) + scope_key = self._timeout_scope_key(subscribe_id, torrent_task) + task = (self._read("subscribes") or {}).get(sid, {}) + state = (task.get("timeout_states") or {}).get(scope_key, {}) + try: + ignore_until = float(state.get("ignore_until") or 0) + except (TypeError, ValueError): + ignore_until = 0 + return state.get("last_torrent_hash") == torrent_hash and ignore_until > time.time() + + def _mark_timeout_manual_review(self, subscribe_id: int, torrent_task: dict): + """达到连续低进度保护上限后,给当前范围写入人工处理保护期。""" + sid = str(subscribe_id) + scope_key = self._timeout_scope_key(subscribe_id, torrent_task) + ignore_until = time.time() + TIMEOUT_MANUAL_REVIEW_IGNORE_HOURS * 3600 + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + states = task.get("timeout_states", {}) + state = states.get(scope_key, {}) + state["ignore_until"] = ignore_until + states[scope_key] = state + task["timeout_states"] = states + data[sid] = task + return data + + self._update("subscribes", updater) + + def _clear_timeout_state(self, subscribe_id: int, torrent_task: dict): + """进度恢复增长时清理同一订阅范围的连续低进度状态。""" + sid = str(subscribe_id) + scope_key = self._timeout_scope_key(subscribe_id, torrent_task) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + states = task.get("timeout_states") + if states: + states.pop(scope_key, None) + task["timeout_states"] = states + data[sid] = task + return data + + self._update("subscribes", updater) diff --git a/plugins.v3/subscribeassistantenhanced/download/torrent.py b/plugins.v3/subscribeassistantenhanced/download/torrent.py new file mode 100644 index 00000000..57ba1b13 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/download/torrent.py @@ -0,0 +1,312 @@ +"""TorrentInfo 标准化结构 + TorrentAdapter(QB/TR 封装)。""" +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +QB_COMPLETE_STATES = { + "uploading", + "stalledUP", + "checkingUP", + "pausedUP", + "stoppedUP", + "queuedUP", + "forcedUP", +} + +DOWNLOAD_QUEUE_STATES = { + "queueddl", + "download pending", + "download_pending", +} + + +@dataclass +class TorrentInfo: + """QB/TR 种子信息标准化结构,保留下载任务判定需要的核心字段。""" + hash: str = "" + title: str = "" + state: str = "" + progress: float = 0.0 + total_size: int = 0 + target_size: int = 0 + downloaded: int = 0 + uploaded: int = 0 + ratio: float = 0.0 + dltime: int = 0 + seeding_time: int = 0 + iatime: int = 0 + avg_upspeed: int = 0 + add_time: str = "" + add_on: int = 0 + tags: list = field(default_factory=list) + tracker: str = "" + tracker_responses: list = field(default_factory=list) + 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 映射。""" + + @staticmethod + def from_qb(torrent: dict) -> TorrentInfo: + """QB 种子字典 → TorrentInfo,以 size 作为已选文件目标体积。""" + state = _get_attr(torrent, "state", default="") + total_size = _as_int(_get_attr(torrent, "total_size", default=0)) + target_size = _positive_int(_get_attr(torrent, "size", default=None)) or total_size + downloaded = _as_int(_get_attr(torrent, "downloaded", default=0)) + seeding_time = _qb_seeding_time(torrent) + progress = _progress_fraction(downloaded, target_size or total_size) + completed, completion_time = _completion_status( + state=state, + seeding_time=seeding_time, + downloaded=downloaded, + target_size=target_size, + dltime=_as_int(_get_attr(torrent, "dltime", default=0)), + state_complete=_is_qb_complete_state(torrent, state), + ) + return TorrentInfo( + hash=_get_attr(torrent, "hash", default=""), + title=_get_attr(torrent, "name", default=""), + state=state, + progress=progress, + total_size=total_size, + target_size=target_size, + downloaded=downloaded, + uploaded=_as_int(_get_attr(torrent, "uploaded", default=0)), + ratio=_as_float(_get_attr(torrent, "ratio", default=0.0)), + dltime=_as_int(_get_attr(torrent, "dltime", default=0)), + seeding_time=seeding_time, + iatime=_as_int(_get_attr(torrent, "inactive_seeding_time", "last_activity", default=0)), + avg_upspeed=_as_int(_get_attr(torrent, "up_limit", default=0)), + add_time=_get_attr(torrent, "added_on_str", default=""), + add_on=_as_int(_get_attr(torrent, "added_on", default=0)), + tags=_parse_tags(_get_attr(torrent, "tags", default="")), + tracker=_get_attr(torrent, "tracker", default=""), + tracker_responses=_get_qb_tracker_responses(torrent), + completed=completed, + completion_time=completion_time, + ) + + @staticmethod + def from_tr(torrent) -> TorrentInfo: + """TR 种子对象 → TorrentInfo,优先使用 size_when_done 作为已选文件目标体积。""" + total_size = _as_int(_get_attr(torrent, "total_size", "totalSize", default=0)) + target_size = total_size + fields = _get_attr(torrent, "fields", default=None) + if fields is None or "size_when_done" in fields or "sizeWhenDone" in fields: + target_size = _positive_int( + _get_attr(torrent, "size_when_done", "sizeWhenDone", default=None) + ) or total_size + downloaded = _get_attr(torrent, "downloaded_ever", "downloadedEver", default=None) + if downloaded is None: + downloaded = int(total_size * (_get_attr(torrent, "progress", default=0.0) or 0) / 100) + downloaded = _as_int(downloaded) + dltime = int(_get_attr(torrent, "seconds_downloading", "secondsDownloading", default=0) or 0) + seeding_time = int(_get_attr(torrent, "seconds_seeding", "secondsSeeding", default=0) or 0) + state = _get_attr(torrent, "status", default="") + progress = _progress_fraction(downloaded, target_size or total_size) + completed, completion_time = _completion_status( + state=state, + seeding_time=seeding_time, + downloaded=downloaded, + target_size=target_size, + dltime=dltime, + ) + ratio = _get_attr(torrent, "ratio", "uploadRatio", default=0.0) or 0.0 + uploaded = _get_attr(torrent, "uploaded_ever", "uploadedEver", default=None) + if uploaded is None: + uploaded = int(downloaded * ratio) + added_date = _get_attr(torrent, "added_date", "addedDate", default=None) + return TorrentInfo( + hash=_get_attr(torrent, "hashString", default=""), + title=_get_attr(torrent, "name", default=""), + state=state, + progress=progress, + total_size=total_size, + target_size=target_size, + downloaded=downloaded, + uploaded=uploaded, + ratio=ratio, + dltime=dltime, + seeding_time=seeding_time, + iatime=int(_get_attr(torrent, "idle_seconds", "idleSeconds", default=0) or 0), + avg_upspeed=int(_get_attr(torrent, "rate_upload", "rateUpload", default=0) or 0), + add_time=str(added_date or ""), + add_on=int(added_date.timestamp()) if hasattr(added_date, "timestamp") else 0, + tags=list(_get_attr(torrent, "labels", default=[]) or []), + tracker=_get_tr_tracker(torrent), + tracker_responses=_get_tr_tracker_responses(torrent), + completed=completed, + completion_time=completion_time, + ) + + @staticmethod + def get_info(torrent: Any, dl_type: str) -> TorrentInfo: + """统一入口,按 dl_type 分发。""" + if dl_type == "qbittorrent": + return TorrentAdapter.from_qb(torrent) + elif dl_type == "transmission": + return TorrentAdapter.from_tr(torrent) + raise ValueError(f"不支持的下载器类型: {dl_type}") + + @staticmethod + def get_tags(info: TorrentInfo) -> list[str]: + """获取种子标签列表。""" + return info.tags + + @staticmethod + def is_completed(info: TorrentInfo) -> tuple[bool, float]: + """判断种子是否已完成下载,返回 (completed, completion_time)。""" + return info.completed, info.completion_time + + @staticmethod + def progress_percent(info: TorrentInfo) -> float: + """获取下载进度百分比 0-100,目标体积优先使用已选择文件大小。""" + return _progress_percent(info.downloaded, info.target_size or info.total_size) + + +def _completion_status(state: str, seeding_time: int, downloaded: int, + target_size: int, dltime: int, + state_complete: bool = False) -> tuple[bool, float]: + """判断种子是否完成:下载器完成态优先,体积兜底必须有有效目标大小。""" + if state_complete or state in ["seeding", "seed_pending"]: + return True, 0.0 + if _positive_int(seeding_time): + return True, 0.0 + if _positive_int(target_size) and downloaded >= target_size: + return True, 0.0 + return False, dltime + + +def _is_qb_complete_state(torrent, state: str) -> bool: + """使用 qB SDK 的 state_enum.is_complete;普通 dict 按 SDK 状态集合兜底。""" + try: + state_enum = getattr(torrent, "state_enum", None) + except Exception: + state_enum = None + try: + if state_enum is not None and bool(getattr(state_enum, "is_complete", False)): + return True + except Exception: + pass + return str(state or "") in QB_COMPLETE_STATES + + +def _qb_seeding_time(torrent) -> int: + """qB completion_on 是完成时间戳;小于等于 0 表示未完成,不能当做种时长。""" + completion_on = _as_int(_get_attr(torrent, "completion_on", default=0)) + if completion_on > 0: + return max(0, int(time.time()) - completion_on) + return _positive_int(_get_attr(torrent, "seeding_time", default=0)) + + +def _progress_fraction(downloaded: int, target_size: int) -> float: + """返回 0-1 的下载进度,内部复用百分比计算并做边界裁剪。""" + return _progress_percent(downloaded, target_size) / 100 + + +def _progress_percent(downloaded: int, target_size: int) -> float: + """按已下载体积与目标体积计算 0-100 下载百分比。""" + try: + downloaded_value = float(downloaded or 0) + target_value = float(target_size or 0) + except (TypeError, ValueError): + return 0.0 + if target_value <= 0: + return 0.0 + return max(0.0, min(downloaded_value / target_value * 100, 100.0)) + + +def _as_int(value, default: int = 0) -> int: + """把 SDK 原始数值统一为 int,异常值按默认值处理。""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _positive_int(value) -> int: + """只接受正整数;下载目标大小和做种时间的 0/负数都视为无效。""" + value = _as_int(value) + return value if value > 0 else 0 + + +def _as_float(value, default: float = 0.0) -> float: + """把 SDK 原始数值统一为 float,异常值按默认值处理。""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _parse_tags(tags_str) -> list: + """解析 QB 标签字符串。""" + if isinstance(tags_str, list): + return tags_str + if not tags_str: + return [] + return [t.strip() for t in str(tags_str).split(",") if t.strip()] + + +def _get_attr(obj, *names, default=None): + """按多个候选属性读取值,兼容下载器 SDK 的 snake/camel 命名差异。""" + for name in names: + if isinstance(obj, dict) and name in obj: + return obj[name] + try: + value = getattr(obj, name) + except Exception: + value = None + if value is not None: + return value + getter = getattr(obj, "get", None) + if callable(getter): + try: + value = getter(name, None) + except Exception: + value = None + if value is not None: + return value + return default + + +def _get_qb_tracker_responses(torrent) -> list: + """读取 qB tracker.msg,过滤禁用 tier 和空响应。""" + trackers = _get_attr(torrent, "trackers", default=[]) or [] + responses = [] + for tracker in trackers: + tier = tracker.get("tier", 0) if isinstance(tracker, dict) else getattr(tracker, "tier", 0) + if tier == -1: + continue + msg = tracker.get("msg", "") if isinstance(tracker, dict) else getattr(tracker, "msg", "") + if msg: + responses.append(str(msg)) + return responses + + +def _get_tr_tracker(torrent) -> str: + trackers = _get_attr(torrent, "trackers", default=[]) + if trackers: + first = trackers[0] if isinstance(trackers, list) else None + if first: + announce = getattr(first, "announce", None) + return announce if announce is not None else str(first) + return "" + + +def _get_tr_tracker_responses(torrent) -> list: + trackers = _get_attr(torrent, "tracker_stats", "trackerStats", default=[]) + responses = [] + for t in (trackers or []): + if _get_attr(t, "tier", default=0) == -1: + continue + msg = _get_attr(t, "last_announce_result", "lastAnnounceResult", default="") + if msg: + responses.append(str(msg)) + return responses diff --git a/plugins.v3/subscribeassistantenhanced/engine/__init__.py b/plugins.v3/subscribeassistantenhanced/engine/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/engine/cadence.py b/plugins.v3/subscribeassistantenhanced/engine/cadence.py new file mode 100644 index 00000000..d7bf84d6 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/cadence.py @@ -0,0 +1,35 @@ +"""G:播出节奏推算,基于已知 air_date 预测窗口是否过期。""" +import statistics +from datetime import date, timedelta +from typing import Optional + +from ..shared.media import episode_field, parse_date + + +def check_cadence_expired(episodes: list, multiplier: float = 2.5, + min_window_days: int = 7, min_episodes: int = 3, + as_of: Optional[date] = None) -> bool: + """检查播出节奏预测窗口是否已过期。不足 min_episodes 已播集时返回 False。""" + today = as_of or date.today() + + aired = [] + for ep in episodes: + d = parse_date(episode_field(ep, "air_date")) + if d and d <= today: + aired.append(d) + + if len(aired) < min_episodes: + return False + + aired.sort() + intervals = [(aired[i + 1] - aired[i]).days for i in range(len(aired) - 1)] + intervals = [iv for iv in intervals if iv > 0] + if not intervals: + return False + + median_interval = statistics.median(intervals) + window_days = max(median_interval * multiplier, min_window_days) + last_aired = aired[-1] + deadline = last_aired + timedelta(days=window_days) + + return today > deadline diff --git a/plugins.v3/subscribeassistantenhanced/engine/local.py b/plugins.v3/subscribeassistantenhanced/engine/local.py new file mode 100644 index 00000000..5d008a06 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/local.py @@ -0,0 +1,98 @@ +"""L:订阅目标覆盖信号。""" +from dataclasses import dataclass +from typing import Optional + +from ..shared.media import episode_field, target_episode_range +from ..shared.subscribe import build_subscribe_meta, is_tv_episode_best_version_subscribe +from .signals import scope_future_episodes +from .types import CompletionSignal, SeasonScope + + +@dataclass +class LocalSignalResult: + """L 信号计算结果与失败诊断,供完成守卫输出可操作日志。""" + signal: Optional[CompletionSignal] = None + blocked_reason: str = "未命中 L" + + +def check_l_signal(subscribe, scope: SeasonScope, mediainfo, meta=None, + resolve_missing_fn=None) -> Optional[CompletionSignal]: + """按主程序订阅目标缺集口径生成低置信 L 信号。""" + return check_l_signal_detail( + subscribe, + scope, + mediainfo=mediainfo, + meta=meta, + resolve_missing_fn=resolve_missing_fn, + ).signal + + +def check_l_signal_detail(subscribe, scope: SeasonScope, mediainfo, meta=None, + resolve_missing_fn=None) -> LocalSignalResult: + """按主程序订阅目标缺集口径生成 L 信号,并保留失败原因。""" + start_episode = subscribe.start_episode or 1 + total_episode = subscribe.total_episode or 0 + if total_episode < start_episode: + return LocalSignalResult(blocked_reason="目标范围无效,未命中 L") + if resolve_missing_fn is None: + return LocalSignalResult(blocked_reason="缺少主程序缺集查询入口,未命中 L") + if meta is None: + meta = build_subscribe_meta(subscribe, failure_context="L 信号缺集查询失败") + if meta is None: + return LocalSignalResult(blocked_reason="缺少主程序缺集查询 meta,未命中 L") + satisfied, _ = resolve_missing_fn( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + best_version_accept_downloaded=is_tv_episode_best_version_subscribe(subscribe), + ) + if not satisfied: + return LocalSignalResult(blocked_reason="主程序缺集口径未满足,未命中 L") + return LocalSignalResult( + signal=CompletionSignal( + completed=True, + confidence="low", + stable=True, + signals=["L:target_satisfied"], + reason="订阅目标范围已无待下载集", + scope_total=scope.total or subscribe.total_episode, + scope_high_risk=scope.high_risk, + ), + blocked_reason="", + ) + + +def format_future_episode(episode) -> str: + """把后续集排期格式化为简短日志片段。""" + if isinstance(episode, dict): + number = episode.get("episode_number") + air_date = episode.get("air_date") + else: + number = getattr(episode, "episode_number", None) + air_date = getattr(episode, "air_date", None) + episode_label = f"E{number}" if number is not None else "未知集号" + return f"{episode_label},播出日期:{air_date or '未知'}" + + +def format_future_blocked_reason(episode) -> str: + """说明 TMDB 已存在当前订阅目标外的后续集。""" + return f"TMDB 已存在目标范围外的后续集({format_future_episode(episode)})" + + +def _future_episode_number(episode) -> int | None: + """解析 TMDB 分集集号;无法确认归属目标范围时按未知处理。""" + number = episode_field(episode, "episode_number", None) + try: + return int(number) + except (TypeError, ValueError): + return None + + +def first_blocking_future_episode(subscribe, scope: SeasonScope, as_of=None): + """返回当前订阅目标范围外的最早后续集。""" + target_episodes = set(target_episode_range(subscribe)) + for episode in scope_future_episodes(scope, as_of=as_of): + number = _future_episode_number(episode) + if number is None or not target_episodes or number not in target_episodes: + return episode + return None diff --git a/plugins.v3/subscribeassistantenhanced/engine/pipeline.py b/plugins.v3/subscribeassistantenhanced/engine/pipeline.py new file mode 100644 index 00000000..8d6cf022 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/pipeline.py @@ -0,0 +1,479 @@ +"""完成证据流水线:汇总当前订阅目标的完成、阻断与观察证据。""" +from datetime import date, datetime, timezone +from typing import Callable, Optional + +from .cadence import check_cadence_expired +from .local import ( + LocalSignalResult, + check_l_signal_detail, + first_blocking_future_episode, + format_future_blocked_reason, +) +from .scope import build_scope +from .site import _eligible_site_evidence_subscribe +from .signals import ( + all_scope_episodes_aired, + check_e_signal, + check_i_signal, + check_m_signal, + has_scope_future_episode, +) +from .types import CompletionEvidence, CompletionSignal, SeasonScope +from .volatility import VolatilityTracker +from ..shared.config import PluginConfig +from ..shared.log import detail +from ..shared.subscribe import format_subscribe + + +class CompletionEvidencePipeline: + """完成证据流水线,按固定阶段汇总当前订阅目标的完成证据。""" + + def __init__(self, tmdb_episodes_fn: Callable, + volatility_tracker: VolatilityTracker, + config: PluginConfig, + site_evidence_provider: Optional[Callable] = None, + now_fn: Optional[Callable[[], datetime]] = None): + self._tmdb_episodes_fn = tmdb_episodes_fn + self._volatility_tracker = volatility_tracker + self._config = config + self._site_evidence_provider = site_evidence_provider + self._now_fn = now_fn or (lambda: datetime.now(timezone.utc)) + + def evaluate(self, subscribe, mediainfo, as_of: Optional[date] = None, + resolve_missing_fn: Optional[Callable] = None, + meta=None, + consume_site_evidence: bool = False) -> CompletionEvidence: + """构建 SeasonScope 后返回所有完成证据与当前主信号。""" + now = _evaluation_now(as_of, self._now_fn) + today = now.date() if isinstance(as_of, datetime) else (as_of or now.date()) + scope = build_scope(subscribe, mediainfo, self._tmdb_episodes_fn) + evidence = CompletionEvidence( + scope_total=scope.total, + scope_high_risk=scope.high_risk, + ) + + m_sig = _attach_scope(check_m_signal(scope, as_of=today), scope) + if m_sig is not None: + evidence.hard_veto = m_sig + evidence.primary_signal = m_sig + evidence.observation_kind = "hard_veto" + return evidence + + e_sig = _attach_scope(check_e_signal(mediainfo, scope, as_of=today), scope) + f_sig = self._unstable_signal(subscribe, scope) + if f_sig is not None: + if f_sig.volatility_direction == "down": + evidence.hard_veto = f_sig + evidence.primary_signal = f_sig + evidence.observation_kind = "hard_veto" + return evidence + evidence.unstable_signal = f_sig + + if e_sig is not None: + evidence.high_completion = e_sig + if consume_site_evidence: + self._record_site_e_diagnostic(subscribe, scope, evidence, e_sig, today, now) + if _e_can_bypass_unstable(e_sig, scope, today, f_sig): + evidence.primary_signal = e_sig + evidence.observation_kind = "high_completion" + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + target_block = first_blocking_future_episode(subscribe, scope, as_of=today) + target_blocked = target_block is not None + if target_blocked: + evidence.local_blocked_reason = format_future_blocked_reason(target_block) + + i_sig = _attach_scope( + check_i_signal( + mediainfo, + scope, + cooldown_days=self._config.season_cooldown_days, + high_risk=scope.high_risk, + as_of=today, + ), + scope, + ) + if i_sig is not None: + if i_sig.confidence == "low": + if not target_blocked: + evidence.i_signal = i_sig + evidence.i_low_signal = i_sig + else: + evidence.i_signal = i_sig + + if not target_blocked: + local_result = check_l_signal_detail( + subscribe, + scope, + mediainfo=mediainfo, + meta=meta, + resolve_missing_fn=resolve_missing_fn, + ) + evidence.local_signal = _attach_scope(local_result.signal, scope) + evidence.local_blocked_reason = local_result.blocked_reason + elif not evidence.local_blocked_reason: + evidence.local_blocked_reason = LocalSignalResult().blocked_reason + + site_action = None + if consume_site_evidence: + site_action = self._apply_site_signal( + subscribe, scope, evidence, e_sig, today, now + ) + if site_action == "hard_veto": + return evidence + if site_action == "target_complete": + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + target_complete = _build_target_complete_signal( + evidence.local_signal, + evidence.i_low_signal, + scope, + ) + if target_complete is not None: + evidence.target_complete_signal = target_complete + evidence.primary_signal = target_complete + evidence.observation_kind = "medium_target_complete" + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + if evidence.unstable_signal is not None: + evidence.primary_signal = evidence.unstable_signal + evidence.observation_kind = "unstable" + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + if evidence.high_completion is not None: + evidence.primary_signal = evidence.high_completion + evidence.observation_kind = "high_completion" + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + if evidence.i_signal is not None: + evidence.primary_signal = evidence.i_signal + evidence.observation_kind = ( + "i_low" if evidence.i_signal.confidence == "low" else "i_medium" + ) + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + if evidence.local_signal is not None: + evidence.primary_signal = evidence.local_signal + evidence.observation_kind = "low_l" + return _with_cadence(evidence, self._cadence_expired(scope, today)) + + cadence_expired = self._cadence_expired(scope, today) + evidence.primary_signal = _attach_scope( + CompletionSignal( + completed=False, + stable=True, + cadence_expired=cadence_expired, + signals=["none"], + reason="无信号确认当前目标范围已播完", + ), + scope, + ) + evidence.cadence_expired = cadence_expired + evidence.observation_kind = "none" + return evidence + + def _unstable_signal(self, subscribe, scope: SeasonScope) -> Optional[CompletionSignal]: + """生成 F 观察信号;total 缩小由主流程提升为硬否决。""" + subscribe_id = getattr(subscribe, "id", None) + if not self._config.volatility_enabled or subscribe_id is None: + return None + if self._volatility_tracker.is_stable(subscribe=subscribe): + return None + + volatility_detail = self._volatility_tracker.recent_change_detail(subscribe=subscribe) + unstable_reason = f"目标总集数最近 {self._config.volatility_window_days} 天发生变化" + if volatility_detail: + unstable_reason = f"{unstable_reason}({volatility_detail})" + return _attach_scope( + CompletionSignal( + completed=False, + stable=False, + signals=["F:unstable"], + reason=unstable_reason, + volatility_direction=self._volatility_tracker.recent_change_direction( + subscribe=subscribe + ), + volatility_detail=volatility_detail, + ), + scope, + ) + + def _cadence_expired(self, scope: SeasonScope, today: date) -> bool: + """计算 G 辅助观察结果,不把 G 写入任何完成信号标识。""" + if scope.high_risk: + return ( + all_scope_episodes_aired(scope, as_of=today) + and not has_scope_future_episode(scope, as_of=today) + ) + if not self._config.cadence_enabled: + return False + return check_cadence_expired( + scope.episodes, + multiplier=self._config.cadence_multiplier, + min_window_days=self._config.cadence_min_window_days, + min_episodes=self._config.cadence_min_episodes, + as_of=today, + ) + + def _apply_site_signal(self, subscribe, scope: SeasonScope, + evidence: CompletionEvidence, + e_sig: Optional[CompletionSignal], + today: date, + now: datetime) -> Optional[str]: + """把站点证据归一到现有完成证据槽位,不让 S 独立完成订阅。""" + if not self._site_evidence_provider: + return None + site_evidence = self._site_evidence_provider(subscribe) + if not site_evidence or site_evidence.is_expired(now): + return None + + kind = getattr(site_evidence, "kind", "") + if kind in ("no_evidence", ""): + return None + if not _eligible_site_evidence_subscribe(subscribe): + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 当前订阅不在站点证据适用范围," + f"只记录诊断,信号={evidence.site_conflict.signals[0]} 原因={evidence.site_conflict.reason}" + ) + return None + + live_total = max( + _safe_int(getattr(subscribe, "total_episode", None)) or 0, + scope.total or 0, + ) + + if kind == "site_complete_pack": + return self._apply_site_completion( + subscribe=subscribe, + evidence=evidence, + site_evidence=site_evidence, + scope=scope, + signal_name="S:site_complete_pack", + reason="站点标题包含完结提示", + ) + + site_total = _safe_int(getattr(site_evidence, "site_candidate_total", None)) or 0 + if kind == "site_conflict" or not site_total: + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据只记录诊断," + f"信号={evidence.site_conflict.signals[0]} 原因={evidence.site_conflict.reason}" + ) + return None + + if site_total > live_total: + if ( + e_sig is not None + or not getattr(self._config, "site_total_probe_enabled", False) + ): + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据只记录诊断," + f"站点总集数={site_total} 当前目标={live_total} 原因={evidence.site_conflict.reason}" + ) + return None + signal = _attach_scope( + CompletionSignal( + completed=False, + stable=True, + signals=["S:site_total_ahead"], + reason=( + f"站点证据显示目标总集数 {site_total} 高于当前目标 {live_total}," + "等待扩展订阅目标" + ), + ), + scope, + ) + evidence.hard_veto = signal + evidence.site_total_ahead_veto = signal + evidence.primary_signal = signal + evidence.observation_kind = "hard_veto" + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据显示当前目标偏小," + f"站点总集数={site_total} 当前目标={live_total},否决完成" + ) + return "hard_veto" + + if site_total < live_total: + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据低于当前目标,只记录诊断," + f"站点总集数={site_total} 当前目标={live_total}" + ) + return None + + if not (_safe_int(getattr(site_evidence, "site_total", None)) or getattr(site_evidence, "complete_hint", False)): + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据仅证明目标集存在," + "缺少可靠总集数或完结标题,只记录诊断" + ) + return None + + return self._apply_site_completion( + subscribe=subscribe, + evidence=evidence, + site_evidence=site_evidence, + scope=scope, + signal_name="S:site_complete_total", + reason=f"站点证据确认目标总集数 {live_total}", + ) + + def _apply_site_completion(self, *, subscribe, + evidence: CompletionEvidence, + site_evidence, + scope: SeasonScope, + signal_name: str, + reason: str) -> Optional[str]: + """S 完结证据必须与 L 目标满足合成 medium,不能单独放行完成。""" + if not getattr(self._config, "site_completion_evidence_enabled", False): + return None + if ( + evidence.local_signal is None + or evidence.local_signal.signals != ["L:target_satisfied"] + ): + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点完结信号缺少 L 目标满足佐证," + f"只记录诊断,原因={evidence.site_conflict.reason}" + ) + return None + + signal = _attach_scope( + CompletionSignal( + completed=True, + confidence="medium", + stable=True, + signals=["L:target_satisfied", signal_name], + reason=f"{evidence.local_signal.reason},{reason}", + ), + scope, + ) + evidence.site_signal = signal + evidence.target_complete_signal = signal + evidence.primary_signal = signal + evidence.observation_kind = "medium_target_complete" + detail( + f"信号引擎(L+S):{format_subscribe(subscribe)} 命中当前目标完成证据," + f"信号={_signal_tags(signal)} 原因={signal.reason}" + ) + return "target_complete" + + def _record_site_e_diagnostic(self, subscribe, scope: SeasonScope, + evidence: CompletionEvidence, + e_sig: CompletionSignal, + today: date, + now: datetime) -> None: + """事件携带的 TMDB 高置信完结信号成立时,S ahead 只保留诊断,不影响 E 放行。""" + if not self._site_evidence_provider: + return + site_evidence = self._site_evidence_provider(subscribe) + if not site_evidence or site_evidence.is_expired(now): + return + site_total = _safe_int(getattr(site_evidence, "site_candidate_total", None)) or 0 + live_total = max( + _safe_int(getattr(subscribe, "total_episode", None)) or 0, + scope.total or 0, + ) + if getattr(site_evidence, "kind", "") == "site_total_ahead" and site_total > live_total: + evidence.site_conflict = _site_conflict_signal(site_evidence, scope) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 事件携带的 TMDB 完结信号 " + f"{_signal_tags(e_sig)} 已成立,站点 ahead 只记录诊断" + ) + + +def _e_can_bypass_unstable(e_sig: CompletionSignal, scope: SeasonScope, + today: date, + unstable_signal: Optional[CompletionSignal]) -> bool: + """高置信 E 在目标范围仍有后续集时不能跳过活跃 F 观察。""" + if unstable_signal is None: + return True + return not has_scope_future_episode(scope, as_of=today) + + +def _site_conflict_signal(site_evidence, scope: SeasonScope) -> CompletionSignal: + """生成只用于诊断的 S 冲突信号,不参与完成放行。""" + kind = getattr(site_evidence, "kind", "") or "site_conflict" + reason = getattr(site_evidence, "reason", "") or "站点证据与当前订阅目标不一致" + return _attach_scope( + CompletionSignal( + completed=False, + confidence="none", + stable=True, + signals=["S:site_conflict"], + reason=f"{kind}: {reason}", + ), + scope, + ) + + +def _evaluation_now(as_of: Optional[date], + now_fn: Callable[[], datetime]) -> datetime: + """返回 TTL 判断使用的真实时点;日期型 as_of 只影响播出日历判断。""" + if isinstance(as_of, datetime): + return _ensure_aware(as_of) + return _ensure_aware(now_fn()) + + +def _ensure_aware(value: datetime) -> datetime: + """站点证据过期时间按 timezone-aware datetime 比较,缺省时区视为 UTC。""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def _safe_int(value) -> Optional[int]: + """解析可选整数,失败时返回 None。""" + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _signal_tags(signal: CompletionSignal) -> str: + """把完成信号来源压缩成日志可读的组合标签。""" + return " + ".join(signal.signals or ["none"]) if signal else "none" + + +def _build_target_complete_signal(local_signal: Optional[CompletionSignal], + i_signal: Optional[CompletionSignal], + scope: SeasonScope) -> Optional[CompletionSignal]: + """仅 L 与 I:all_aired/I:cooldown 可合成当前订阅目标完成证据。""" + if local_signal is None or i_signal is None: + return None + if local_signal.signals != ["L:target_satisfied"]: + return None + if i_signal.signals not in (["I:all_aired"], ["I:cooldown"]): + return None + return _attach_scope( + CompletionSignal( + completed=True, + confidence="medium", + stable=True, + signals=["L:target_satisfied", i_signal.signals[0]], + reason=f"{local_signal.reason},{i_signal.reason}", + ), + scope, + ) + + +def _with_cadence(evidence: CompletionEvidence, + cadence_expired: bool) -> CompletionEvidence: + """把 G 辅助结果同步到 evidence 与主信号的独立字段。""" + evidence.cadence_expired = cadence_expired + evidence.primary_signal.cadence_expired = cadence_expired + return evidence + + +def _attach_scope(signal: Optional[CompletionSignal], + scope: SeasonScope) -> Optional[CompletionSignal]: + """所有流水线产物都携带同一 SeasonScope 的总数与风险标记。""" + if signal is None: + return None + signal.scope_total = scope.total + signal.scope_high_risk = scope.high_risk + return signal diff --git a/plugins.v3/subscribeassistantenhanced/engine/proximity.py b/plugins.v3/subscribeassistantenhanced/engine/proximity.py new file mode 100644 index 00000000..553072e0 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/proximity.py @@ -0,0 +1,64 @@ +"""完成阶段接近度判断,供 F 信号和待定入口共享。""" +from dataclasses import dataclass, field +from datetime import date, timedelta +from typing import Iterable, Optional + +from ..shared.media import episode_field, parse_date + + +@dataclass +class CompletionProximity: + """目标范围是否已进入完成前风险区。""" + near_completion: bool + aired_ratio: float = 0.0 + remaining_count: Optional[int] = None + reasons: list[str] = field(default_factory=list) + + +def assess_completion_proximity( + episodes: Iterable, + total: int, + missing_episodes: Optional[list[int]] = None, + as_of: Optional[date] = None, + completion_check: bool = False, +) -> CompletionProximity: + """组合已播比例、末集日期、剩余目标和完成检查上下文判断是否接近完结。""" + today = as_of or date.today() + episode_list = list(episodes or []) + total_count = total or len(episode_list) + air_dates = [ + parsed + for parsed in ( + parse_date(episode_field(episode, "air_date")) + for episode in episode_list + ) + if parsed is not None + ] + last_scope_air_date = _last_scope_air_date(episode_list) + aired_count = sum(1 for air_date in air_dates if air_date <= today) + aired_ratio = (aired_count / total_count) if total_count else 0.0 + remaining_count = None if missing_episodes is None else len(missing_episodes) + + reasons = [] + if total_count >= 3 and aired_ratio >= 0.8: + reasons.append("aired_ratio") + if last_scope_air_date and today >= last_scope_air_date - timedelta(days=3): + reasons.append("last_air_date") + if remaining_count is not None and total_count >= 3 and remaining_count <= 2: + reasons.append("few_remaining") + if completion_check: + reasons.append("completion_check") + + return CompletionProximity( + near_completion=bool(reasons), + aired_ratio=aired_ratio, + remaining_count=remaining_count, + reasons=reasons, + ) + + +def _last_scope_air_date(episodes: list) -> Optional[date]: + """返回目标范围最后一集的播出日期;未知日期不推断为已接近完结。""" + if not episodes: + return None + return parse_date(episode_field(episodes[-1], "air_date")) diff --git a/plugins.v3/subscribeassistantenhanced/engine/scope.py b/plugins.v3/subscribeassistantenhanced/engine/scope.py new file mode 100644 index 00000000..5ea8aa5d --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/scope.py @@ -0,0 +1,71 @@ +"""SeasonScope 构建与 high_risk 绝对季风险检测。""" +from typing import Callable, Optional + +from .types import SeasonScope +from .signals import _field +from ..shared.subscribe import subscribe_tmdb_id + +PRODUCTION_GROUP_TYPE = 7 # TMDB 剧集组类型枚举值 7:按制作/拍摄顺序分组(绝对季常见来源) +HIGH_RISK_EPISODE_THRESHOLD = 80 # 超长绝对季阈值,低于该集数时不单独触发 high_risk。 + + +def build_scope(subscribe, mediainfo, tmdb_episodes_fn: Callable) -> SeasonScope: + """按订阅季与 episode_group 构建统一的 SeasonScope。""" + tmdbid = subscribe_tmdb_id(subscribe) + season = subscribe.season + episode_group = subscribe.episode_group + + if tmdbid is None: + episodes = [] + source = "tmdb_unavailable" + elif episode_group: + episodes = tmdb_episodes_fn(tmdbid, season, episode_group=episode_group) + source = "episode_group" + else: + episodes = tmdb_episodes_fn(tmdbid, season) + source = "main_season" + + scope = SeasonScope( + tmdbid=tmdbid, + season=season, + episode_group_id=episode_group, + episodes=episodes or [], + total=len(episodes) if episodes else 0, + source=source, + ) + scope.high_risk = detect_high_risk(scope, mediainfo) + return scope + + +def detect_high_risk(scope: SeasonScope, mediainfo) -> bool: + """检测 high_risk 范围:超长季、阶段标记或多个制作顺序剧集组。""" + if len(scope.episodes) >= HIGH_RISK_EPISODE_THRESHOLD: + return True + + for ep in scope.episodes[:-1]: + if _field(ep, "episode_type") == "mid_season": + return True + + finale_episodes = [ep for ep in scope.episodes if _field(ep, "episode_type") == "finale"] + if len(finale_episodes) == 1 and finale_episodes[0] is not scope.episodes[-1]: + return True + + production_count = _count_production_groups(mediainfo) + if production_count >= 2: + return True + + return False + + +def _count_production_groups(mediainfo) -> int: + """统计 production/story 类剧集组数量。""" + tmdb_info = mediainfo.tmdb_info + if not tmdb_info: + return 0 + episode_groups = _field(tmdb_info, "episode_groups", None) + if not episode_groups: + return 0 + results = _field(episode_groups, "results", None) + if not results: + return 0 + return sum(1 for g in results if _field(g, "type", 0) == PRODUCTION_GROUP_TYPE) diff --git a/plugins.v3/subscribeassistantenhanced/engine/signals.py b/plugins.v3/subscribeassistantenhanced/engine/signals.py new file mode 100644 index 00000000..58302ed1 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/signals.py @@ -0,0 +1,211 @@ +"""M + E + I 完结信号实现。""" +from datetime import date +from typing import Optional + +from .types import CompletionSignal, SeasonScope +from ..shared.media import parse_date + + +def _field(data, name: str, default=None): + """读取 TMDB 原始 dict 或对象字段,避免不同来源的数据形态吞掉信号。""" + if isinstance(data, dict): + return data.get(name, default) + return getattr(data, name, default) + + +def _episode_number(episode) -> Optional[int]: + """返回可比较集号;缺失或不可解析时返回 None。""" + number = _field(episode, "episode_number", None) + try: + return int(number) + except (TypeError, ValueError): + return None + + +def _episode_air_date(episode) -> Optional[date]: + """返回集播出日期,兼容对象和 TMDB 原始 dict。""" + return parse_date(_field(episode, "air_date", None)) + + +def _scope_last_aired_episode(episodes: list, as_of: Optional[date] = None): + """返回 scope 内最后一集已播分集,字段读取兼容对象和 TMDB 原始 dict。""" + today = as_of or date.today() + aired = [] + for episode in episodes or []: + air = _episode_air_date(episode) + if air and air <= today: + aired.append((air, _episode_number(episode) or 0, episode)) + if not aired: + return None + return max(aired, key=lambda item: (item[0], item[1]))[2] + + +def all_scope_episodes_aired(scope: SeasonScope, as_of: Optional[date] = None) -> bool: + """判断 SeasonScope 内所有分集是否都已播出,兼容对象和 TMDB 原始 dict。""" + if not scope.episodes: + return False + today = as_of or date.today() + for episode in scope.episodes: + air = _episode_air_date(episode) + if not air or air > today: + return False + return True + + +def check_m_signal(scope: SeasonScope, as_of: Optional[date] = None) -> Optional[CompletionSignal]: + """M:mid_season 硬否决,SeasonScope 内最后已播集为阶段中场时判定未完结。""" + last = _scope_last_aired_episode(scope.episodes, as_of=as_of) + if not last: + return None + if _field(last, "episode_type") == "mid_season": + return CompletionSignal( + completed=False, stable=True, + signals=["M:mid_season"], + reason="最后已播集为 mid_season,阶段中场", + ) + return None + + +def check_e_signal(mediainfo, scope: SeasonScope, + as_of: Optional[date] = None) -> Optional[CompletionSignal]: + """E:基线信号,按剧级状态或 SeasonScope 末集 finale 判断完结。""" + status = _field(mediainfo.tmdb_info, "status", "") + if status in ("Ended", "Canceled"): + return CompletionSignal( + completed=True, confidence="high", + signals=[f"E:{status.lower()}"], + reason=f"status={status}", + ) + if has_scope_finale(scope, as_of=as_of) and not has_scope_future_episode(scope, as_of=as_of): + return CompletionSignal( + completed=True, confidence="high", + signals=["E:finale"], + reason="目标范围末集有 finale 标记", + ) + return None + + +def check_i_signal(mediainfo, scope: SeasonScope, cooldown_days: int = 14, + high_risk: bool = False, + as_of: Optional[date] = None) -> Optional[CompletionSignal]: + """I:季级信号;I-3/I-4 在 high_risk 范围内不放行。""" + today = as_of or date.today() + tmdb_info = mediainfo.tmdb_info + if not tmdb_info: + return None + has_scope_future = has_scope_future_episode(scope, as_of=today) + if has_scope_future: + return None + + # I-1:TMDB 有更晚的季 + seasons = _field(tmdb_info, "seasons", []) or [] + for s in seasons: + season_number = _field(s, "season_number", 0) + if season_number > scope.season: + return CompletionSignal( + completed=True, confidence="medium", + signals=["I:next_season"], + reason=f"TMDB 存在 S{season_number}", + ) + + # I-2:last_episode_to_air 季号 > 当前季 + last_ep = _field(tmdb_info, "last_episode_to_air", None) + last_season = _field(last_ep, "season_number", 0) if last_ep else 0 + if last_ep and last_season > scope.season: + return CompletionSignal( + completed=True, confidence="medium", + signals=["I:last_ep_beyond"], + reason=f"last_episode_to_air 属于 S{last_season}", + ) + + # I-3 和 I-4 在 high_risk 范围内不放行,避免绝对季断档期间误判完结。 + if high_risk or scope.high_risk: + return None + + # I-3:SeasonScope 内所有集已播,且目标范围内没有后续集。 + if all_scope_episodes_aired(scope, as_of=today) and not has_scope_future: + return CompletionSignal( + completed=True, confidence="low", + signals=["I:all_aired"], + reason="目标范围内所有集已播且未发现后续集", + ) + + # I-4:SeasonScope 内无后续播出日期,且最后已播集超过冷却期。 + if not has_scope_future: + last_aired = _scope_last_aired_episode(scope.episodes, as_of=today) + if last_aired: + air = _episode_air_date(last_aired) + if air and (today - air).days > cooldown_days: + return CompletionSignal( + completed=True, confidence="low", + signals=["I:cooldown"], + reason=f"最后集播出超 {cooldown_days} 天,未发现后续集", + ) + + return None + + +def scope_future_episodes(scope: SeasonScope, as_of: Optional[date] = None) -> list: + """返回当前 SeasonScope 内证明目标范围尚未播完的分集列表。""" + today = as_of or date.today() + last_aired = _scope_last_aired_episode(scope.episodes, as_of=today) + last_aired_number = _episode_number(last_aired) if last_aired else None + candidates = [] + for episode in scope.episodes or []: + number = _episode_number(episode) + air = _episode_air_date(episode) + if air and air > today: + candidates.append(episode) + elif ( + air is None + and number is not None + and last_aired_number is not None + and number > last_aired_number + ): + candidates.append(episode) + return sorted( + candidates, + key=lambda episode: ( + _episode_air_date(episode) or date.max, + _episode_number(episode) or 0, + ), + ) + + +def scope_future_episode(scope: SeasonScope, as_of: Optional[date] = None): + """返回当前 SeasonScope 内证明目标范围尚未播完的最早分集。""" + candidates = scope_future_episodes(scope, as_of=as_of) + if not candidates: + return None + return candidates[0] + + +def has_scope_future_episode(scope: SeasonScope, as_of: Optional[date] = None) -> bool: + """判断当前 SeasonScope 是否存在后续集。""" + return scope_future_episode(scope, as_of=as_of) is not None + + +def scope_finale_episode(scope: SeasonScope): + """返回可信的目标范围 finale;多标记或非末集标记均视为 TMDB 数据异常。""" + if not scope.episodes: + return None + finale_episodes = [ep for ep in scope.episodes if _field(ep, "episode_type") == "finale"] + if len(finale_episodes) != 1: + return None + if finale_episodes[0] is not scope.episodes[-1]: + return None + return finale_episodes[0] + + +def has_scope_finale(scope: SeasonScope, as_of: Optional[date] = None) -> bool: + """finale 必须在 SeasonScope 内唯一、位于最后一集且已播出,才可确认当前范围完结。""" + finale = scope_finale_episode(scope) + if not finale: + return False + air = _episode_air_date(finale) + return bool(air and air <= (as_of or date.today())) + + +def last_aired_episode(episodes: list, as_of: Optional[date] = None): + """返回 SeasonScope 内最后一个已播出的集。""" + return _scope_last_aired_episode(episodes, as_of=as_of) diff --git a/plugins.v3/subscribeassistantenhanced/engine/site.py b/plugins.v3/subscribeassistantenhanced/engine/site.py new file mode 100644 index 00000000..a31df9c8 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/site.py @@ -0,0 +1,874 @@ +"""站点资源证据:把 RSS/spider 缓存候选归一为订阅可消费的 S 信号快照。""" +from __future__ import annotations + +import re +import copy +from dataclasses import asdict, dataclass, field +from datetime import date, datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Callable, Optional + +from app.log import logger +from app.schemas.event import SubscribeEpisodesRefreshEventData +from app.schemas.types import MediaSource, MediaType + +from .signals import check_e_signal +from .types import SeasonScope +from ..shared.log import detail +from ..shared.subscribe import ( + format_subscribe, + is_full_best_version_subscribe, + resolve_subscribe_media_type, + subscribe_media_identity, + subscribe_tmdb_id, +) +from ..shared.task import TaskDataManager + + +SITE_EVIDENCE_KEY = "site_evidence" +SITE_EVIDENCE_TTL_HOURS = 24 +SITE_APPLIED_MIN_HOURS = 6 +SITE_APPLIED_MAX_HOURS = 24 +_COMPLETE_HINT_RE = re.compile( + r"\b(?:complete|completed|end|ended)\b|完结|全集|全\s*\d+\s*集", + re.IGNORECASE, +) + + +@dataclass +class SiteEvidence: + """当前订阅的站点证据快照,只描述证据,不直接修改订阅。""" + kind: str + confidence: str + media_source: str = "" + media_id: str = "" + season: Optional[int] = None + episode_group: str = "" + type: str = "" + site_candidate_total: int = 0 + max_episode: int = 0 + site_total: int = 0 + complete_hint: bool = False + current_target_total: int = 0 + match_level: str = "strict" + source: str = "mixed" + sample_titles: list[str] = field(default_factory=list) + scanned_at: str = "" + expires_at: str = "" + reason: str = "" + + @classmethod + def no_evidence(cls, subscribe, now: datetime) -> "SiteEvidence": + """生成无站点证据快照,保留订阅身份和 TTL。""" + return cls( + kind="no_evidence", + confidence="none", + media_source=str(subscribe.media_source or ""), + media_id=str(subscribe.media_id or ""), + season=_safe_int(getattr(subscribe, "season", None)), + episode_group=_normalize_text(getattr(subscribe, "episode_group", None)), + type=str(getattr(subscribe, "type", "") or ""), + current_target_total=_safe_int(getattr(subscribe, "total_episode", None)) or 0, + scanned_at=_iso(now), + expires_at=_iso(now + timedelta(hours=SITE_EVIDENCE_TTL_HOURS)), + reason="未发现可用站点证据", + ) + + @classmethod + def from_dict(cls, data: dict | None) -> Optional["SiteEvidence"]: + """从持久化字典恢复证据快照。""" + if not data: + return None + known = {field_name for field_name in cls.__dataclass_fields__} + return cls(**{key: value for key, value in data.items() if key in known}) + + def to_dict(self) -> dict: + """转换为可 JSON 持久化的字典。""" + return asdict(self) + + def is_expired(self, now: Optional[datetime] = None) -> bool: + """判断证据是否超过消费 TTL;无法解析过期时间时按过期处理。""" + if not self.expires_at: + return True + now = now or datetime.now(timezone.utc) + try: + expires_at = datetime.fromisoformat(self.expires_at) + except ValueError: + return True + if expires_at.tzinfo is None and now.tzinfo is not None: + expires_at = expires_at.replace(tzinfo=now.tzinfo) + return expires_at <= now + + +@dataclass +class SiteAppliedMarker: + """站点扩集应用标记,用于诊断和后续停止旧证据继续向上覆盖。""" + applied_total: int + applied_base_total: int + applied_at: str = "" + applied_reason: str = "" + + @classmethod + def now(cls, applied_total: int, applied_base_total: int, reason: str, + now: Optional[datetime] = None) -> "SiteAppliedMarker": + """生成当前时间的应用标记。""" + return cls( + applied_total=applied_total, + applied_base_total=applied_base_total, + applied_at=_iso(now or datetime.now(timezone.utc)), + applied_reason=reason, + ) + + @classmethod + def from_dict(cls, data: dict | None) -> Optional["SiteAppliedMarker"]: + """从持久化字典恢复应用标记。""" + if not data: + return None + known = {field_name for field_name in cls.__dataclass_fields__} + return cls(**{key: value for key, value in data.items() if key in known}) + + def to_dict(self) -> dict: + """转换为可 JSON 持久化的字典。""" + return asdict(self) + + +def classify_site_contexts(subscribe, contexts: list, now: datetime) -> SiteEvidence: + """把缓存 Context 归一为当前订阅的站点证据快照。 + + 当前目标内的普通单集资源不能否决同身份、同季的更高集数证据;只有标题明确 + 表示全集或完结时,较低完成证据才与扩集证据构成真实冲突。 + """ + if not contexts: + return SiteEvidence.no_evidence(subscribe, now) + + target_total = _safe_int(getattr(subscribe, "total_episode", None)) or 0 + evidence_list: list[SiteEvidence] = [] + conflict_list: list[SiteEvidence] = [] + for context in contexts: + evidence = _classify_context(subscribe, context, target_total, now) + if evidence.kind == "site_conflict": + conflict_list.append(evidence) + continue + if evidence.kind != "no_evidence": + evidence_list.append(evidence) + + if not evidence_list: + if conflict_list: + return _select_site_conflict(conflict_list) + return SiteEvidence.no_evidence(subscribe, now) + + kinds = {evidence.kind for evidence in evidence_list} + explicit_completion = any( + evidence.kind != "site_total_ahead" and evidence.complete_hint + for evidence in evidence_list + ) or any( + evidence.match_level == "strict" and evidence.complete_hint + for evidence in conflict_list + ) + if "site_total_ahead" in kinds and explicit_completion: + return _build_evidence( + subscribe, contexts[0], now, + kind="site_conflict", + confidence="none", + match_level="strict", + reason="站点候选同时出现扩集与当前目标完成证据", + current_target_total=target_total, + ) + + if "site_total_ahead" in kinds: + return max(evidence_list, key=lambda item: item.site_candidate_total) + if "site_complete_total" in kinds: + return next(evidence for evidence in evidence_list if evidence.kind == "site_complete_total") + if "site_complete_pack" in kinds: + return next(evidence for evidence in evidence_list if evidence.kind == "site_complete_pack") + return SiteEvidence.no_evidence(subscribe, now) + + +def _select_site_conflict(conflicts: list[SiteEvidence]) -> SiteEvidence: + """多个诊断候选只保留最接近订阅目标的一条,避免缓存顺序影响日志价值。""" + strict_conflicts = [item for item in conflicts if item.match_level == "strict"] + return max(strict_conflicts or conflicts, key=lambda item: item.site_candidate_total) + + +class SiteEvidenceStore: + """保存当前站点证据和站点扩集应用标记。""" + def __init__(self, task_manager: TaskDataManager): + self._task = task_manager + + def read_snapshot(self, subscribe) -> Optional[SiteEvidence]: + """读取订阅当前证据快照。""" + row = (self._task.read(SITE_EVIDENCE_KEY) or {}).get(str(getattr(subscribe, "id", ""))) or {} + if not _row_identity_matches(row, subscribe): + return None + return SiteEvidence.from_dict(row.get("snapshot") or {}) + + def save_snapshot(self, subscribe, evidence: SiteEvidence) -> None: + """保存订阅当前证据快照,不影响应用标记。""" + sid = str(getattr(subscribe, "id", "")) + + def update(data: dict) -> dict: + row = data.get(sid) or {} + if row and not _row_identity_matches(row, subscribe): + row = {} + row["identity"] = _identity(subscribe) + row["snapshot"] = evidence.to_dict() + data[sid] = row + return data + + self._task.update(SITE_EVIDENCE_KEY, update) + + def read_applied(self, subscribe) -> Optional[SiteAppliedMarker]: + """读取订阅站点扩集应用标记。""" + sid = str(getattr(subscribe, "id", "")) + row = (self._task.read(SITE_EVIDENCE_KEY) or {}).get(sid) or {} + if not _row_identity_matches(row, subscribe): + self._clear_lease_by_id(sid) + return None + marker = SiteAppliedMarker.from_dict(row.get("applied") or {}) + if marker and not _applied_marker_matches_subscribe(marker, subscribe): + self.clear_applied(subscribe) + return None + return marker + + def mark_applied(self, subscribe, applied_total: int, applied_base_total: int, reason: str, + now: Optional[datetime] = None) -> None: + """记录订阅目标已被站点证据向上扩展。""" + sid = str(getattr(subscribe, "id", "")) + + def update(data: dict) -> dict: + row = data.get(sid) or {} + if row and not _row_identity_matches(row, subscribe): + row = {} + current = SiteAppliedMarker.from_dict(row.get("applied") or {}) + if current and _safe_int(current.applied_total) == _safe_int(applied_total): + row["identity"] = _identity(subscribe) + data[sid] = row + return data + marker = SiteAppliedMarker.now(applied_total, applied_base_total, reason, now=now) + row["identity"] = _identity(subscribe) + row["applied"] = marker.to_dict() + data[sid] = row + return data + + self._task.update(SITE_EVIDENCE_KEY, update) + + def clear_applied(self, subscribe) -> None: + """清除订阅站点扩集应用标记,保留当前证据快照。""" + self._clear_applied_by_id(str(getattr(subscribe, "id", ""))) + + def clear_lease(self, subscribe) -> None: + """终止订阅站点扩集租约并移除旧证据,避免状态切回后复活。""" + self._clear_lease_by_id(str(getattr(subscribe, "id", ""))) + + def _clear_applied_by_id(self, sid: str) -> None: + """按订阅 ID 清除站点扩集应用标记。""" + def update(data: dict) -> dict: + row = data.get(sid) or {} + row.pop("applied", None) + if row: + data[sid] = row + return data + + self._task.update(SITE_EVIDENCE_KEY, update) + + def _clear_lease_by_id(self, sid: str) -> None: + """按订阅 ID 同时移除应用标记与证据快照。""" + def update(data: dict) -> dict: + row = data.get(sid) or {} + row.pop("applied", None) + row.pop("snapshot", None) + if row: + data[sid] = row + else: + data.pop(sid, None) + return data + + self._task.update(SITE_EVIDENCE_KEY, update) + + def clear_all_leases(self) -> None: + """批量终止全部站点扩集租约及其证据,防止重新开启后复活。""" + def update(data: dict) -> dict: + for sid, stored in list(data.items()): + row = stored if isinstance(stored, dict) else {} + row.pop("applied", None) + row.pop("snapshot", None) + if row: + data[sid] = row + else: + data.pop(sid, None) + return data + + self._task.update(SITE_EVIDENCE_KEY, update) + + +class SiteEpisodesRefreshHandler: + """在主程序发起集数刷新时消费当前站点证据,不直接写订阅表。""" + + def __init__(self, *, config, store: SiteEvidenceStore, subscribe_oper, + resolve_missing_fn: Optional[Callable] = None, + mediainfo_from_dict: Optional[Callable] = None, + now_fn: Optional[Callable[[], datetime]] = None): + self._config = config + self._store = store + self._subscribe_oper = subscribe_oper + self._resolve_missing_fn = resolve_missing_fn + self._mediainfo_from_dict = mediainfo_from_dict + self._now_fn = now_fn or (lambda: datetime.now(timezone.utc)) + + def handle_refresh(self, data: SubscribeEpisodesRefreshEventData) -> None: + """按订阅当前站点快照向上覆盖事件 total;跳过回落和完成写库。""" + if not getattr(self._config, "site_total_probe_enabled", False): + self._store.clear_all_leases() + return + subscribe = self._subscribe_oper.get(data.subscribe_id) if ( + self._subscribe_oper and data.subscribe_id + ) else None + if subscribe is None: + detail( + f"信号引擎(S):订阅不可用(id={data.subscribe_id}, scene={data.scene}, " + f"media={data.media_source}:{data.media_id}, season={data.season}),跳过站点证据消费" + ) + return + if not _event_identity_matches(data, subscribe): + detail(f"信号引擎(S):{format_subscribe(subscribe)} 集数刷新事件身份不匹配,跳过站点证据消费") + return + now = self._now_fn() + applied = self._store.read_applied(subscribe) + if not _eligible_site_evidence_subscribe(subscribe): + self._store.clear_lease(subscribe) + return + current_total = data.current_total_episode or 0 + live_total = _safe_int(getattr(subscribe, "total_episode", None)) or 0 + + if applied: + applied_total = _safe_int(applied.applied_total) or 0 + age = _applied_marker_age(applied, now) + if current_total >= applied_total: + self._store.clear_lease(subscribe) + return + if _has_high_confidence_tmdb_completion(data.mediainfo, subscribe, as_of=_as_date(now)): + self._store.clear_lease(subscribe) + return + evidence = self._store.read_snapshot(subscribe) + if evidence and not evidence.is_expired(now): + site_total = evidence.site_candidate_total or 0 + if evidence.kind == "site_total_ahead" and site_total > applied_total: + self._store.mark_applied( + subscribe, site_total, current_total, evidence.reason, now=now, + ) + self._apply_total(data, site_total, evidence.reason) + return + if evidence.complete_hint and evidence.match_level == "strict" and 0 < site_total < applied_total: + self._store.clear_lease(subscribe) + return + if age is None: + self._store.clear_lease(subscribe) + return + if age >= timedelta(hours=SITE_APPLIED_MAX_HOURS): + return + if age < timedelta(hours=SITE_APPLIED_MIN_HOURS): + self._apply_total(data, applied_total, applied.applied_reason) + return + if self._lease_target_satisfied(subscribe, applied_total, data.mediainfo): + return + self._apply_total(data, applied_total, applied.applied_reason) + return + + evidence = self._store.read_snapshot(subscribe) + if not evidence: + return + if evidence.is_expired(now): + self._log_diagnostic(subscribe, evidence, "站点证据已过期") + return + + site_total = evidence.site_candidate_total or 0 + if current_total > site_total: + self._log_diagnostic(subscribe, evidence, "主程序本次识别到的 TMDB 当前季总集数已大于站点证据") + return + if live_total > site_total: + self._log_diagnostic(subscribe, evidence, "站点证据低于订阅当前目标") + return + if _has_high_confidence_tmdb_completion(data.mediainfo, subscribe, as_of=_as_date(now)): + self._log_diagnostic(subscribe, evidence, "事件携带的 TMDB 完结信号成立,停止使用站点证据向上覆盖") + self._store.clear_lease(subscribe) + return + if evidence.kind not in ("site_total_ahead", "site_complete_total"): + self._log_diagnostic(subscribe, evidence, "站点证据不是可消费集数信号") + return + if site_total <= current_total: + return + self._apply_total(data, site_total, evidence.reason) + if site_total > live_total: + self._store.mark_applied( + subscribe, + applied_total=site_total, + applied_base_total=current_total, + reason=evidence.reason, + now=now, + ) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据扩展主程序本次识别到的 TMDB 当前季总集数 " + f"{current_total} -> {site_total},原因={evidence.reason}" + ) + + @staticmethod + def _apply_total(data: SubscribeEpisodesRefreshEventData, total_episode: int, reason: str) -> None: + """把有效租约目标写入当前刷新事件,不直接修改订阅表。""" + data.updated = True + data.total_episode = total_episode + data.source = "站点集数探测" + data.reason = reason + + def _lease_target_satisfied(self, subscribe, applied_total: int, mediainfo) -> bool: + """按主程序公共缺集口径判断租约目标是否已经产生下载或入库事实。""" + if not self._resolve_missing_fn or mediainfo is None: + return False + try: + if isinstance(mediainfo, dict): + if not self._mediainfo_from_dict: + return False + mediainfo = self._mediainfo_from_dict(mediainfo) + if mediainfo is None: + return False + snapshot = copy.copy(subscribe) + snapshot.total_episode = applied_total + satisfied, _ = self._resolve_missing_fn( + subscribe=snapshot, + mediainfo=mediainfo, + best_version_accept_downloaded=bool(getattr(subscribe, "best_version", False)), + ) + return bool(satisfied) + except Exception as err: + logger.warning(f"信号引擎(S):{format_subscribe(subscribe)} 租约消费事实查询失败:{err}") + return False + + @staticmethod + def _log_diagnostic(subscribe, evidence: SiteEvidence, message: str) -> None: + detail( + f"信号引擎(S):{format_subscribe(subscribe)} {message}," + f"证据={evidence.kind} 站点候选总集数={evidence.site_candidate_total} " + f"原因={evidence.reason or '无'}" + ) + + +class SiteEvidenceScanner: + """周期扫描主程序只读缓存候选并保存当前订阅的站点证据快照。""" + + def __init__(self, *, config, store: SiteEvidenceStore, candidate_provider: Callable, + now_fn: Optional[Callable[[], datetime]] = None): + self._config = config + self._store = store + self._candidate_provider = candidate_provider + self._now_fn = now_fn or (lambda: datetime.now(timezone.utc)) + + def refresh_subscribe(self, subscribe) -> Optional[SiteEvidence]: + """刷新单个订阅的站点证据;候选来源只读,不触发站点刷新或缓存写入。""" + if not _site_evidence_scan_enabled(self._config): + return None + if not _eligible_site_evidence_subscribe(subscribe): + return None + try: + contexts = self._candidate_provider(subscribe, allow_title_match=True) or [] + except Exception as err: + logger.warning(f"信号引擎(S):{format_subscribe(subscribe)} 读取站点缓存候选失败:{err}") + return None + + evidence = classify_site_contexts(subscribe, list(contexts), now=self._now_fn()) + self._store.save_snapshot(subscribe, evidence) + detail( + f"信号引擎(S):{format_subscribe(subscribe)} 站点证据扫描完成," + f"结果={evidence.kind} 候选总集数={evidence.site_candidate_total} 原因={evidence.reason}" + ) + return evidence + + +def _classify_context(subscribe, context, target_total: int, now: datetime) -> SiteEvidence: + if getattr(context, "match_source", "") == "title" or getattr(context, "media_info_is_target", False): + return _build_evidence( + subscribe, context, now, + kind="site_conflict", + confidence="none", + match_level="title", + reason="标题兜底候选只记录诊断", + current_target_total=target_total, + ) + + if not _identity_matches(subscribe, context): + return _build_evidence( + subscribe, context, now, + kind="site_conflict", + confidence="none", + match_level="identity_conflict", + reason="站点候选身份缺失或不匹配", + current_target_total=target_total, + ) + + season_level, season_reason = _season_match_level(subscribe, context) + if season_level != "strict": + return _build_evidence( + subscribe, context, now, + kind="site_conflict", + confidence="none", + match_level=season_level, + reason=season_reason, + current_target_total=target_total, + ) + + max_episode = _max_episode(context) + site_total = _site_total(context) + site_candidate_total = max(max_episode, site_total) + complete_hint = _complete_hint(context) + if site_total and site_total < target_total: + return _build_evidence( + subscribe, context, now, + kind="site_conflict", + confidence="none", + match_level="strict", + reason=f"站点候选总集数 {site_total} 小于当前目标 {target_total}", + current_target_total=target_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=complete_hint, + ) + if site_candidate_total > target_total: + return _build_evidence( + subscribe, context, now, + kind="site_total_ahead", + confidence="medium", + match_level="strict", + reason=f"站点候选最大集数 {site_candidate_total} 大于当前目标 {target_total}", + current_target_total=target_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=complete_hint, + ) + if site_candidate_total and site_candidate_total < target_total: + return _build_evidence( + subscribe, context, now, + kind="site_conflict", + confidence="none", + match_level="strict", + reason=f"站点候选总集数 {site_candidate_total} 小于当前目标 {target_total}", + current_target_total=target_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=complete_hint, + ) + if site_candidate_total == target_total and site_total: + return _build_evidence( + subscribe, context, now, + kind="site_complete_total", + confidence="medium", + match_level="strict", + reason=f"站点候选总集数等于当前目标 {target_total}", + current_target_total=target_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=complete_hint, + ) + if complete_hint: + return _build_evidence( + subscribe, context, now, + kind="site_complete_pack", + confidence="low", + match_level="strict", + reason="站点标题包含完结提示但缺少可靠总集数", + current_target_total=target_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=True, + ) + return SiteEvidence.no_evidence(subscribe, now) + + +def _build_evidence(subscribe, context, now: datetime, *, kind: str, confidence: str, + match_level: str, reason: str, current_target_total: int, + max_episode: int = 0, site_total: int = 0, + complete_hint: bool = False) -> SiteEvidence: + site_candidate_total = max(max_episode, site_total) + return SiteEvidence( + kind=kind, + confidence=confidence, + media_source=str(subscribe.media_source or ""), + media_id=str(subscribe.media_id or ""), + season=_safe_int(getattr(subscribe, "season", None)), + episode_group=_normalize_text(getattr(subscribe, "episode_group", None)), + type=str(getattr(subscribe, "type", "") or ""), + site_candidate_total=site_candidate_total, + max_episode=max_episode, + site_total=site_total, + complete_hint=complete_hint, + current_target_total=current_target_total, + match_level=match_level, + source=getattr(context, "resource_source", None) or "unknown", + sample_titles=_sample_titles(context), + scanned_at=_iso(now), + expires_at=_iso(now + timedelta(hours=SITE_EVIDENCE_TTL_HOURS)), + reason=reason, + ) + + +def _identity_matches(subscribe, context) -> bool: + meta_info = getattr(context, "meta_info", None) + media_info = getattr(context, "media_info", None) + media_source, media_id = subscribe_media_identity(subscribe) + if not media_source or not media_id: + return False + context_pairs = { + (str(source or ""), str(identifier or "")) + for source, identifier in ( + (getattr(meta_info, "media_source", None), getattr(meta_info, "media_id", None)), + (getattr(media_info, "media_source", None), getattr(media_info, "media_id", None)), + ) + if source and identifier + } + if context_pairs: + return (media_source, media_id) in context_pairs + context_tmdbids = _identity_values( + getattr(meta_info, "tmdbid", None), + getattr(meta_info, "tmdb_id", None), + getattr(media_info, "tmdb_id", None), + ) + context_doubanids = _identity_values( + getattr(meta_info, "doubanid", None), + getattr(meta_info, "douban_id", None), + getattr(media_info, "douban_id", None), + ) + if media_source == MediaSource.TMDB.value: + return media_id in context_tmdbids + if media_source == MediaSource.Douban.value: + return media_id in context_doubanids + return False + + +def _season_match_level(subscribe, context) -> tuple[str, str]: + target = _safe_int(getattr(subscribe, "season", None)) + if target is None: + return "season_missing", "订阅缺少季信息" + + meta_info = getattr(context, "meta_info", None) + begin_season = _safe_int(getattr(meta_info, "begin_season", None)) + end_season = _safe_int(getattr(meta_info, "end_season", None)) + if begin_season is not None and end_season is not None and begin_season != end_season: + return "multi_season", "站点候选为多季或跨季资源" + + candidate_season = begin_season if begin_season is not None else end_season + if candidate_season is None: + media_info = getattr(context, "media_info", None) + candidate_season = _safe_int(getattr(media_info, "season", None)) + if candidate_season is None: + return "season_missing", "站点候选缺少季信息" + if candidate_season != target: + return "season_conflict", "站点候选季信息与订阅不一致" + return "strict", "" + + +def _max_episode(context) -> int: + meta_info = getattr(context, "meta_info", None) + values = [] + for episode in getattr(meta_info, "episode_list", None) or []: + parsed = _safe_int(episode) + if parsed: + values.append(parsed) + for key in ("begin_episode", "end_episode"): + parsed = _safe_int(getattr(meta_info, key, None)) + if parsed: + values.append(parsed) + return max(values or [0]) + + +def _site_total(context) -> int: + meta_info = getattr(context, "meta_info", None) + return _safe_int(getattr(meta_info, "total_episode", None)) or 0 + + +def _complete_hint(context) -> bool: + meta_info = getattr(context, "meta_info", None) + torrent_info = getattr(context, "torrent_info", None) + text = " ".join( + str(value or "") + for value in ( + getattr(torrent_info, "title", None), + getattr(torrent_info, "description", None), + getattr(meta_info, "title", None), + getattr(meta_info, "subtitle", None), + ) + ) + return bool(_COMPLETE_HINT_RE.search(text)) + + +def _sample_titles(context) -> list[str]: + meta_info = getattr(context, "meta_info", None) + torrent_info = getattr(context, "torrent_info", None) + titles = [] + for value in (getattr(torrent_info, "title", None), getattr(meta_info, "title", None)): + if value and value not in titles: + titles.append(str(value)[:160]) + return titles[:3] + + +def _identity(subscribe) -> dict: + return { + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, + "episode_group": _normalize_text(subscribe.episode_group), + "type": subscribe.type, + } + + +def _row_identity_matches(row: dict, subscribe) -> bool: + identity = row.get("identity") + if not isinstance(identity, dict): + return False + expected_episode_group = _normalize_text(subscribe.episode_group) + return ( + str(identity.get("media_source") or "") == str(subscribe.media_source or "") + and str(identity.get("media_id") or "") == str(subscribe.media_id or "") + and _safe_int(identity.get("season")) == _safe_int(subscribe.season) + and _normalize_text(identity.get("episode_group")) == expected_episode_group + and str(identity.get("type") or "") == str(subscribe.type or "") + ) + + +def _applied_marker_matches_subscribe(marker: SiteAppliedMarker, subscribe) -> bool: + """应用标记必须包含有效的正数目标,人工接管由 handler 统一终止整条租约。""" + return bool(_safe_int(marker.applied_total)) + + +def _applied_marker_age(marker: SiteAppliedMarker, now: datetime) -> Optional[timedelta]: + """解析 UTC 租约年龄;非法或未来时间不视为有效租约。""" + try: + applied_at = datetime.fromisoformat(marker.applied_at) + except (TypeError, ValueError): + return None + if applied_at.tzinfo is None: + applied_at = applied_at.replace(tzinfo=timezone.utc) + normalized_now = now if now.tzinfo else now.replace(tzinfo=timezone.utc) + age = normalized_now.astimezone(timezone.utc) - applied_at.astimezone(timezone.utc) + return age if age >= timedelta(0) else None + + +def _site_evidence_scan_enabled(config) -> bool: + return bool( + getattr(config, "site_total_probe_enabled", False) + or getattr(config, "site_completion_evidence_enabled", False) + ) + + +def _eligible_site_evidence_subscribe(subscribe) -> bool: + """站点证据只适用于 P/R 剧集普通订阅和分集洗版,手动总集数保持人工优先。""" + if not subscribe: + return False + if getattr(subscribe, "state", None) not in ("P", "R"): + return False + if resolve_subscribe_media_type(subscribe) != MediaType.TV: + return False + if is_full_best_version_subscribe(subscribe): + return False + if bool(getattr(subscribe, "manual_total_episode", False)): + return False + return True + + +def _event_identity_matches(data: SubscribeEpisodesRefreshEventData, subscribe) -> bool: + if data.season is not None and _safe_int(data.season) != _safe_int(getattr(subscribe, "season", None)): + return False + if data.media_source is not None and str(data.media_source) != str(subscribe.media_source): + return False + if data.media_id is not None and _normalize_id(data.media_id) != _normalize_id(subscribe.media_id): + return False + return True + + +def _has_high_confidence_tmdb_completion(mediainfo, subscribe, as_of: Optional[date] = None) -> bool: + """识别事件携带的 TMDB 高置信完结状态;缺少 scope 时仅使用剧级状态。""" + tmdb_info = _field_value(mediainfo, "tmdb_info", None) or {} + status = _field_value(tmdb_info, "status", None) or _field_value(mediainfo, "status", "") + if status in ("Ended", "Canceled"): + return True + scope = _scope_from_mediainfo(subscribe, mediainfo) + if not scope: + return False + signal = check_e_signal(_mediainfo_for_signal(mediainfo, tmdb_info), scope, as_of=as_of) + return bool(signal and signal.completed and signal.confidence == "high") + + +def _scope_from_mediainfo(subscribe, mediainfo) -> Optional[SeasonScope]: + """按事件携带的媒体信息构造当前季 scope,不额外触发 TMDB 请求。""" + seasons = _field_value(mediainfo, "seasons", None) or {} + season = _safe_int(getattr(subscribe, "season", None)) + if season is None: + return None + episodes = [] + if isinstance(seasons, dict): + episodes = seasons.get(season) or seasons.get(str(season)) or [] + if not episodes: + return None + return SeasonScope( + tmdbid=subscribe_tmdb_id(subscribe) or 0, + season=season, + episode_group_id=getattr(subscribe, "episode_group", None), + episodes=list(episodes), + total=len(episodes), + ) + + +def _mediainfo_for_signal(mediainfo, tmdb_info): + """把 dict 形态的事件媒体信息收敛为 E 信号可读取的对象。""" + if isinstance(mediainfo, dict): + return SimpleNamespace(tmdb_info=tmdb_info or {}) + if getattr(mediainfo, "tmdb_info", None) is None: + return SimpleNamespace(tmdb_info=tmdb_info or {}) + return mediainfo + + +def _as_date(value) -> Optional[date]: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return None + + +def _field_value(data, name: str, default=None): + if isinstance(data, dict): + return data.get(name, default) + return getattr(data, name, default) + + +def _safe_int(value) -> Optional[int]: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_id(value) -> Optional[str]: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _identity_values(*values) -> list[str]: + normalized = [] + for value in values: + parsed = _normalize_id(value) + if parsed and parsed not in normalized: + normalized.append(parsed) + return normalized + + +def _normalize_text(value) -> str: + if value is None: + return "" + return str(value).strip() + + +def _iso(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() diff --git a/plugins.v3/subscribeassistantenhanced/engine/types.py b/plugins.v3/subscribeassistantenhanced/engine/types.py new file mode 100644 index 00000000..a6bc778e --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/types.py @@ -0,0 +1,135 @@ +"""完成证据流水线数据类型与跨模块协议。""" +from dataclasses import dataclass, field +from typing import Protocol, Optional, runtime_checkable + + +@dataclass +class CompletionSignal: + """完成证据中的单一信号,描述当前 SeasonScope 的播出完成状态。""" + completed: bool = False # 是否判定为已完结 + confidence: str = "none" # 置信度档位:none/low/medium/high + stable: bool = True # F 信号:total_episode 近窗口内是否稳定(不稳定则否决完成) + cadence_expired: bool = False # G 信号:按播出节奏是否已超期 + signals: list = field(default_factory=list) # 命中的信号标识,如 ["E:ended"] + reason: str = "" # 人类可读的判定理由 + scope_total: int = 0 # 本轮 SeasonScope 的 TMDB 目标总集数,用于观察期增集判断 + scope_high_risk: bool = False # 当前目标范围是否属于 absolute-season 等高风险范围 + volatility_direction: Optional[str] = None # F 信号窗口内最近一次 total 变化方向:up/down + volatility_detail: Optional[str] = None # F 信号窗口内最近一次 total 变化明细:旧集数 -> 新集数 + + +@dataclass +class CompletionEvidence: + """完成观察裁决的输入证据,聚合各类完结信号与本地阻断信息。""" + scope_total: int = 0 # 本轮 SeasonScope 的目标总集数 + scope_high_risk: bool = False # 当前目标范围是否属于高风险范围 + primary_signal: CompletionSignal = field(default_factory=lambda: CompletionSignal( + completed=False, + stable=True, + signals=["none"], + reason="无信号确认当前目标范围已播完", + )) # 主完结信号,缺省为无完成证据 + hard_veto: Optional[CompletionSignal] = None # 不可被目标完成证据直接覆盖的否决信号 + unstable_signal: Optional[CompletionSignal] = None # F 不稳定信号 + high_completion: Optional[CompletionSignal] = None # 高置信完结信号 + i_signal: Optional[CompletionSignal] = None # I 类播出完成信号 + i_low_signal: Optional[CompletionSignal] = None # 低置信 I 信号 + local_signal: Optional[CompletionSignal] = None # L 本地目标满足信号 + site_signal: Optional[CompletionSignal] = None # S 站点资源佐证的目标完成信号 + site_conflict: Optional[CompletionSignal] = None # S 站点证据诊断冲突,不直接裁决完成 + site_total_ahead_veto: Optional[CompletionSignal] = None # S 站点证据显示目标集数仍需扩展 + target_complete_signal: Optional[CompletionSignal] = None # 当前目标范围完成信号 + cadence_expired: bool = False # G 信号是否已达到播出节奏超期 + observation_kind: str = "none" # 观察策略类别,供超时管理区分释放口径 + local_blocked_reason: str = "" # L 信号未命中时的可诊断原因 + + +@dataclass +class CompletionObservationDecision: + """完成前观察的裁决结果,描述是否退出待定以及是否写入释放令牌。""" + action: str = "hold" # 裁决动作:hold/release_guard/release_with_token/allow_complete + reason: str = "" # 裁决原因,用于日志和状态说明 + exit_pending: bool = False # 是否解除当前 guard_veto 待定状态 + write_release_token: bool = False # 是否写入一次性完成释放令牌 + + @classmethod + def hold(cls, reason: str = ""): + """保持观察状态。""" + return cls(action="hold", reason=reason) + + @classmethod + def release_guard(cls, reason: str = ""): + """释放守卫待定状态,但不写完成释放令牌。""" + return cls(action="release_guard", reason=reason, exit_pending=True) + + @classmethod + def release_with_token(cls, reason: str = ""): + """释放守卫待定状态,并写入一次性完成释放令牌。""" + return cls( + action="release_with_token", + reason=reason, + exit_pending=True, + write_release_token=True, + ) + + @classmethod + def allow_complete(cls, reason: str = ""): + """允许当前完成检查继续通过。""" + return cls(action="allow_complete", reason=reason, exit_pending=True) + + +@dataclass +class SeasonScope: + """当前订阅的逻辑季范围,供完成证据、待定和完成后验证统一使用。""" + tmdbid: int = 0 # TMDB 媒体 ID + season: int = 0 # 订阅季号 + episode_group_id: Optional[str] = None # 剧集组 ID,非空表示按 episode_group 取集 + episodes: list = field(default_factory=list) # SeasonScope 内的 TMDB 集对象列表 + total: int = 0 # SeasonScope 目标总集数 + source: str = "main_season" # 集来源:main_season=主季 / episode_group=剧集组 + high_risk: bool = False # 是否为高风险绝对季范围,影响 I-3/I-4 放行 + + +@dataclass +class PauseRecord: + """暂停原因记录,区分暂停来源。""" + # 暂停来源:pre_air(上映/开播前)/airing_gap(播出间隔)/no_download(无下载超期)/auto_user(按用户名自动暂停)。 + # 其中 no_download/auto_user 为标记暂停:state=S 时元数据巡检直接跳过, + # 不被上映检查自动恢复;pre_air/airing_gap 为上映类暂停,条件解除时双向自动恢复。 + reason: str = "" + since: float = 0.0 # 暂停起始时间戳 + detail: str = "" # 暂停明细描述 + + +@runtime_checkable +class CompletionVerifierProtocol(Protocol): + """完成快照与增集复查接口,供完成守卫依赖而不耦合具体实现。""" + def snapshot(self, subscribe, mediainfo, scope: SeasonScope) -> None: ... + + +@runtime_checkable +class PendingTimeoutManagerProtocol(Protocol): + """完成前观察释放的协议接口,供守门/待定判定依赖而不耦合具体实现。""" + def record_observation(self, subscribe_or_id, + signal: Optional[CompletionSignal] = None, + total_episode: Optional[int] = None) -> None: ... + def clear_observation(self, subscribe_id: int) -> None: ... + def consume_release_token(self, subscribe_or_id, + signal: CompletionSignal, + total_episode: Optional[int] = None) -> bool: ... + def clear_release_token(self, subscribe_or_id) -> None: ... + def check_observation(self, subscribe_or_id, + evidence: CompletionEvidence, + mode: str) -> CompletionObservationDecision: ... + + +@runtime_checkable +class PriorityManagerProtocol(Protocol): + """订阅事实与洗版优先级协议接口,供下载删除清理依赖而不耦合具体实现。""" + def capture_baseline(self, subscribe, torrent_priority) -> dict: ... + def update_on_download(self, subscribe, episodes, new_priority) -> None: ... + def rollback(self, subscribe, baseline) -> None: ... + def rollback_torrent(self, subscribe, torrent_id) -> None: ... + def can_backfill(self, subscribe) -> bool: ... + def backfill_existing(self, subscribe, existing_episodes, scene: str = "plugin_backfill") -> bool: ... + def mark_full_best_version_complete(self, subscribe) -> None: ... diff --git a/plugins.v3/subscribeassistantenhanced/engine/volatility.py b/plugins.v3/subscribeassistantenhanced/engine/volatility.py new file mode 100644 index 00000000..c5515866 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/engine/volatility.py @@ -0,0 +1,285 @@ +"""F:变更速率追踪,检测 total_episode 是否在窗口期内变动过。""" +import time +from typing import Optional + +from ..shared.config import DEFAULT_VOLATILITY_WINDOW_DAYS +from ..shared.task import TaskDataManager +from ..shared.subscribe import identity_matches, subscribe_identity + +VOLATILITY_KEY = "volatility" +# 只限制保留的诊断采样数量;稳定窗口由 unstable_until 持久化,不能依赖采样条数。 +MAX_SAMPLE_HISTORY_SIZE = 20 + + +class VolatilityTracker: + """记录 TMDB 原始 total_episode 值,检测数据稳定性。""" + + def __init__(self, task_manager: TaskDataManager, + window_days: int = DEFAULT_VOLATILITY_WINDOW_DAYS): + self._task = task_manager + self._window_seconds = window_days * 86400 + + def record(self, total: int, subscribe_id: Optional[int] = None, + subscribe=None): + """记录 total_episode;提供订阅对象时同时校验媒体身份。""" + if subscribe is not None: + subscribe_id = subscribe.id + if subscribe_id is None: + return + sid = str(subscribe_id) + now = time.time() + + def updater(data: dict) -> dict: + entry = data.get(sid) + if subscribe is not None: + if isinstance(entry, list): + entry = _new_entry(subscribe, records=entry) + elif isinstance(entry, dict): + identity = entry.get("identity") + if identity is None: + entry["identity"] = subscribe_identity(subscribe) + elif not identity_matches(identity, subscribe): + entry = _new_entry(subscribe) + else: + entry = _new_entry(subscribe) + else: + if isinstance(entry, list): + entry = {"records": entry} + elif not isinstance(entry, dict): + entry = {"records": []} + buf = _records_from_entry(entry) + entry["records"] = buf + _ensure_change_state(entry, self._window_seconds) + _sync_unstable_until(entry, self._window_seconds) + last_total = entry.get("last_total") + if last_total is None and buf: + last_total = buf[-1].get("total") + if last_total is not None and last_total != total: + entry["last_total_changed_at"] = now + entry["unstable_until"] = now + self._window_seconds + entry["last_total_before_change"] = last_total + entry["last_total_after_change"] = total + entry["last_total_change_direction"] = "down" if total < last_total else "up" + entry["last_total"] = total + buf.append({"total": total, "ts": now}) + if len(buf) > MAX_SAMPLE_HISTORY_SIZE: + buf = buf[-MAX_SAMPLE_HISTORY_SIZE:] + entry["records"] = buf + data[sid] = entry + return data + + self._task.update(VOLATILITY_KEY, updater) + + def is_stable(self, subscribe_id: Optional[int] = None, subscribe=None) -> bool: + """检查窗口期内 total 是否无变动;身份不符按新订阅处理。""" + if subscribe is not None: + subscribe_id = subscribe.id + if subscribe_id is None: + return True + sid = str(subscribe_id) + data = self._task.read(VOLATILITY_KEY) + entry = data.get(sid) + if subscribe is not None: + if isinstance(entry, list): + buf = _records_from_entry(entry) + elif isinstance(entry, dict): + identity = entry.get("identity") + if identity is not None and not identity_matches(identity, subscribe): + self._task.update( + VOLATILITY_KEY, + lambda current: _drop_key(current, sid), + ) + return True + buf = _records_from_entry(entry) + else: + self._task.update( + VOLATILITY_KEY, + lambda current: _drop_key(current, sid), + ) + return True + else: + if isinstance(entry, list): + buf = _records_from_entry(entry) + elif isinstance(entry, dict): + buf = _records_from_entry(entry) + else: + buf = [] + if isinstance(entry, dict): + unstable_until = _effective_unstable_until(entry, self._window_seconds) + if unstable_until and unstable_until >= time.time(): + return False + if len(buf) <= 1: + return True + cutoff = time.time() - self._window_seconds + recent = [r for r in buf if r["ts"] >= cutoff] + if len(recent) <= 1: + return True + totals = {r["total"] for r in recent} + return len(totals) == 1 + + def recent_change_direction(self, subscribe_id: Optional[int] = None, + subscribe=None) -> Optional[str]: + """返回窗口内最近一次 total 变化方向,用于区分缩小导致的低估风险。""" + if subscribe is not None: + subscribe_id = subscribe.id + if subscribe_id is None: + return None + sid = str(subscribe_id) + entry = self._task.read(VOLATILITY_KEY).get(sid) + if subscribe is not None and isinstance(entry, dict): + identity = entry.get("identity") + if identity is not None and not identity_matches(identity, subscribe): + return None + direction = _recent_change_direction(entry, self._window_seconds) + return direction + + def recent_change_detail(self, subscribe_id: Optional[int] = None, + subscribe=None) -> Optional[str]: + """返回窗口内最近一次 total 变化明细,格式为“旧集数 -> 新集数”。""" + if subscribe is not None: + subscribe_id = subscribe.id + if subscribe_id is None: + return None + sid = str(subscribe_id) + entry = self._task.read(VOLATILITY_KEY).get(sid) + if subscribe is not None and isinstance(entry, dict): + identity = entry.get("identity") + if identity is not None and not identity_matches(identity, subscribe): + return None + return _recent_change_detail(entry, self._window_seconds) + + +def _drop_key(data: dict, sid: str) -> dict: + """删除失配订阅 ID 的旧记录。""" + data.pop(sid, None) + return data + + +def _new_entry(subscribe, records: Optional[list] = None) -> dict: + """创建带订阅身份的 volatility 记录。""" + return { + "identity": subscribe_identity(subscribe), + "records": _records_from_entry(records), + "last_total": None, + "last_total_changed_at": None, + "unstable_until": None, + "last_total_before_change": None, + "last_total_after_change": None, + "last_total_change_direction": None, + } + + +def _records_from_entry(entry) -> list[dict]: + """读取可用采样列表;损坏采样按空列表处理,避免阻断订阅刷新。""" + if isinstance(entry, dict): + records = entry.get("records") or [] + elif isinstance(entry, list): + records = entry + else: + return [] + if not isinstance(records, list): + return [] + return [record for record in records if isinstance(record, dict)] + + +def _effective_unstable_until(entry: dict, window_seconds: int) -> Optional[float]: + """按当前配置窗口计算有效截止时间,避免旧配置写入的截止时间继续延长观察。""" + changed_at = entry.get("last_total_changed_at") + if changed_at: + return changed_at + window_seconds + return entry.get("unstable_until") + + +def _sync_unstable_until(entry: dict, window_seconds: int): + """把持久化截止时间同步到当前窗口,配置缩短后下一次记录会自动截断。""" + unstable_until = _effective_unstable_until(entry, window_seconds) + if unstable_until is not None: + entry["unstable_until"] = unstable_until + + +def _ensure_change_state(entry: dict, window_seconds: int): + """从历史采样补齐变化窗口状态,兼容旧 list 记录升级后的首次写入。""" + if entry.get("last_total") is not None: + return + records = _records_from_entry(entry) + entry["records"] = records + if not records: + return + entry["last_total"] = records[-1].get("total") + last_changed_at = None + previous_total = records[0].get("total") + before_change = None + after_change = None + for record in records[1:]: + current_total = record.get("total") + if current_total != previous_total: + last_changed_at = record.get("ts") + before_change = previous_total + after_change = current_total + previous_total = current_total + if last_changed_at is not None: + entry["last_total_changed_at"] = last_changed_at + entry["unstable_until"] = last_changed_at + window_seconds + entry["last_total_before_change"] = before_change + entry["last_total_after_change"] = after_change + entry["last_total_change_direction"] = _recent_change_direction(records, window_seconds) + + +def _recent_change_direction(entry, window_seconds: int) -> Optional[str]: + """从持久化 entry 或旧采样列表读取窗口内最近一次 total 变化方向。""" + now = time.time() + if isinstance(entry, dict): + changed_at = entry.get("last_total_changed_at") + direction = entry.get("last_total_change_direction") + unstable_until = _effective_unstable_until(entry, window_seconds) + if direction and unstable_until is not None and unstable_until >= now: + return direction + records = _records_from_entry(entry) + elif isinstance(entry, list): + records = _records_from_entry(entry) + else: + return None + cutoff = now - window_seconds + previous_total = None + direction = None + for record in records: + if record.get("ts", 0) < cutoff: + continue + current_total = record.get("total") + if previous_total is not None and current_total != previous_total: + direction = "down" if current_total < previous_total else "up" + previous_total = current_total + return direction + + +def _recent_change_detail(entry, window_seconds: int) -> Optional[str]: + """从持久化 entry 或旧采样列表读取窗口内最近一次 total 变化明细。""" + now = time.time() + if isinstance(entry, dict): + before = entry.get("last_total_before_change") + after = entry.get("last_total_after_change") + if after is None: + after = entry.get("last_total") + if ( + before is not None + and after is not None + and (unstable_until := _effective_unstable_until(entry, window_seconds)) is not None + and unstable_until >= now + ): + return f"{before} -> {after}" + records = _records_from_entry(entry) + elif isinstance(entry, list): + records = _records_from_entry(entry) + else: + return None + cutoff = now - window_seconds + previous_total = None + detail = None + for record in records: + if record.get("ts", 0) < cutoff: + continue + current_total = record.get("total") + if previous_total is not None and current_total != previous_total: + detail = f"{previous_total} -> {current_total}" + previous_total = current_total + return detail diff --git a/plugins.v3/subscribeassistantenhanced/events.py b/plugins.v3/subscribeassistantenhanced/events.py new file mode 100644 index 00000000..c1272620 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/events.py @@ -0,0 +1,742 @@ +"""事件薄代理——按域 enabled 开关按需注册,委托到对应模块。 + +12 个事件处理器: +- SubscribeCompletionCheck → guard +- SubscribeEpisodesRefresh → volatility.record (先) + site refresh consumer + pending observer (后) +- SubscribeAdded → best_version + priority.backfill + pause.auto_pause_user +- SubscribeDeleted → task_manager.cleanup +- SubscribeModified → 暂停状态归属维护 + task_manager.reset_on_modify +- SubscribeComplete → verifier.snapshot + best_version +- TransferIntercept → subscription_cleanup.history_clear +- ResourceSelection → 洗版串行 + 识别增强 + 删除指纹过滤 +- ResourceDownload → subscription_cleanup + monitor.mark_pending +- DownloadAdded → monitor.on_download + 暂停订阅下载命中恢复 +- TransferComplete → 清下载待定 + 移动模式清理 + 分集转全集补偿 +- PluginAction → toggle_subscribe_state +""" +from types import SimpleNamespace + +from app.log import logger +from app.core.context import MediaInfo +from app.schemas.event import SubscribeEpisodesRefreshEventData +from app.schemas.types import MediaType + +from .shared.log import detail +from .shared.subscribe import ( + format_subscribe, + format_subscribe_label, + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, + subscribe_from_source, +) + + +def _event_data(event): + """取事件 payload(主程序固定放在 event.event_data);链式事件的业务字段不从 wrapper 直读。""" + return event.event_data + + +class EventProxy: + """事件代理,持有各域模块引用,按 enabled 注册。""" + + def __init__(self, skip_deletion=True, backfill_enabled=True, + pending_download_enabled=True, download_monitor_enabled=True, + plugin_name="订阅助手(增强版)", **modules): + """保存事件处理依赖;删除指纹过滤和洗版回填默认开启以兼容直接构造场景。""" + modules["skip_deletion"] = skip_deletion + modules["backfill_enabled"] = backfill_enabled + modules["pending_download_enabled"] = pending_download_enabled + modules["download_monitor_enabled"] = download_monitor_enabled + modules["plugin_name"] = plugin_name + self._modules = modules + self._reset_backfilling_ids = set() + + def get(self, name): + return self._modules.get(name) + + def _format_backfill_scene(self, scene: str) -> str: + """为插件侧 backfill 调用补充插件名,向主程序保留可追踪来源。""" + if scene.endswith(">") and "<" in scene: + return scene + return f"{scene}<{self.get('plugin_name')}>" + + def _format_subscribe_label(self, subscribe_id, subscribe_info=None): + """按订阅 ID 生成日志标签;事件快照或查库成功时带名称、季号和 ID。""" + if subscribe_id is None: + return "未知订阅" + if isinstance(subscribe_info, dict) and subscribe_info: + return format_subscribe_label(SimpleNamespace(**subscribe_info), subscribe_id) + subscribe_oper = self.get("subscribe_oper") + subscribe = subscribe_oper.get(subscribe_id) if subscribe_oper else None + return format_subscribe_label(subscribe, subscribe_id) + + def _schedule_initial_pending_search(self, subscribe): + """新增态写入 P 前安排单订阅搜索,避免常规定时服务按 N 态过滤时漏掉首轮搜索。""" + schedule_search = self.get("schedule_initial_pending_search_fn") + if schedule_search: + schedule_search(subscribe) + + @staticmethod + def _supports_subscribe(subscribe) -> bool: + """仅电影和电视剧订阅进入增强生命周期与资源处理链路。""" + return resolve_subscribe_media_type(subscribe) in (MediaType.MOVIE, MediaType.TV) + + @staticmethod + def _format_episodes_refresh_label(data: SubscribeEpisodesRefreshEventData) -> str | None: + """格式化集数刷新事件来源;订阅不可查或创建场景用媒体信息兜底。""" + subscribe_id = data.subscribe_id + parts = [] + mediainfo = data.mediainfo + media_source = data.media_source + media_id = data.media_id + if isinstance(mediainfo, MediaInfo): + if mediainfo.title_year: + parts.append(mediainfo.title_year) + if media_source is None: + media_source = mediainfo.media_source + if media_id is None: + media_id = mediainfo.media_id + elif isinstance(mediainfo, dict): + title = mediainfo.get("title") + year = mediainfo.get("year") + if title: + label = f"{title} ({year})" if year else title + parts.append(label) + if media_source is None: + media_source = mediainfo.get("media_source") + if media_id is None: + media_id = mediainfo.get("media_id") + season = data.season + if season is not None: + parts.append(f"S{season}") + markers = [] + if subscribe_id: + markers.append(f"id={subscribe_id}") + if media_source and media_id: + markers.append(f"media={media_source}:{media_id}") + scene = data.scene + if scene: + markers.append(f"scene={scene}") + if markers: + marker_text = f"({', '.join(markers)})" + if parts: + parts[-1] = f"{parts[-1]}{marker_text}" + else: + parts.append(marker_text) + return " ".join(parts) if parts else "未知订阅" + + def on_completion_check(self, event): + """CompletionCheck → guard。""" + guard = self.get("guard") + if not guard: + return + data = _event_data(event) + if data is None or not self._supports_subscribe(data.subscribe): + return + guard.handle(event) + + def on_episodes_refresh(self, event): + """EpisodesRefresh → F record (先) + 站点证据消费 + pending observer (后)。 + + 链式事件:业务字段在 event.event_data(主程序只回读该数据类),wrapper 上没有这些字段。 + """ + data: SubscribeEpisodesRefreshEventData = _event_data(event) + if data is None: + return + subscribe_oper = self.get("subscribe_oper") + subscribe = subscribe_oper.get(data.subscribe_id) if ( + subscribe_oper and data.subscribe_id + ) else None + if subscribe is not None and not self._supports_subscribe(subscribe): + return + label = None + if data.subscribe_id is not None: + label = self._format_subscribe_label(data.subscribe_id) + if label == f"订阅 {data.subscribe_id}": + label = self._format_episodes_refresh_label(data) or label + else: + label = self._format_episodes_refresh_label(data) + label = label or "未知订阅" + detail(f"集数刷新事件:{label} 当前总集数 {data.current_total_episode}") + volatility = self.get("volatility") + if volatility: + record_kwargs = { + "total": data.current_total_episode, + "subscribe_id": data.subscribe_id, + } + if subscribe is not None: + record_kwargs["subscribe"] = subscribe + volatility.record(**record_kwargs) + site_refresh = self.get("site_refresh") + if site_refresh: + site_refresh.handle_refresh(data) + pending_refresh = self.get("pending_refresh") + if pending_refresh: + pending_refresh.handle_refresh(data) + + def on_subscribe_added(self, event): + """SubscribeAdded → 回填下载事实后委托 lifecycle 处理新增态状态流转。""" + data = event.event_data + if not isinstance(data, dict): + return + subscribe_id = data.get("subscribe_id") + if not subscribe_id: + return + subscribe_oper = self.get("subscribe_oper") + subscribe = subscribe_oper.get(subscribe_id) if subscribe_oper else None + if not subscribe or not self._supports_subscribe(subscribe): + return + detail(f"订阅新增事件:{format_subscribe(subscribe)}(id={subscribe_id})") + + priority = self.get("priority_manager") + if ( + subscribe.best_version + and self.get("backfill_enabled") + and priority + and priority.can_backfill(subscribe) + ): + detect = self.get("detect_backfill_episodes_fn") or self.get("detect_existing_episodes_fn") + if detect: + episodes = detect(subscribe) + if episodes: + logger.info(f"订阅新增:{format_subscribe(subscribe)} 分集洗版订阅回填已下载集 {episodes}") + priority.backfill_existing( + subscribe, episodes, scene=self._format_backfill_scene("plugin_backfill") + ) + + mediainfo_from_dict = self.get("mediainfo_from_dict") + mediainfo = mediainfo_from_dict(data.get("mediainfo")) if mediainfo_from_dict else None + if not mediainfo: + detail(f"订阅新增:{format_subscribe(subscribe)} 媒体信息缺失,跳过播出暂停/待定") + return + + lifecycle = self.get("lifecycle") + if lifecycle: + lifecycle.handle_subscribe_added(subscribe, mediainfo) + + def on_subscribe_deleted(self, event): + """SubscribeDeleted → 清理该订阅关联的全部任务数据(订阅任务 + 名下种子任务)。""" + data = event.event_data + subscribe_id = data.get("subscribe_id") if isinstance(data, dict) else None + subscribe_info = data.get("subscribe_info") if isinstance(data, dict) else None + task_manager = self.get("task_manager") + if subscribe_id and task_manager: + detail(f"订阅删除事件:清理 {self._format_subscribe_label(subscribe_id, subscribe_info)} 关联任务数据") + task_manager.clear_tasks(subscribe_id) + + def on_subscribe_modified(self, event): + """SubscribeModified → 状态变更维护暂停归属 + 分集洗版下载事实回填。 + + state 变化时维护插件侧暂停记录;普通订阅首次切成洗版时, + 把媒体库已有集交给主程序 backfill 合同,避免已在库的集被重新洗版。 + """ + data = event.event_data + if not isinstance(data, dict): + return + subscribe_id = data.get("subscribe_id") + subscribe_info = data.get("subscribe_info") or {} + old_info = data.get("old_subscribe_info") or {} + if not subscribe_id: + return + fields = data.get("fields") + different_keys = set(fields) if isinstance(fields, list) else { + key for key in subscribe_info.keys() & old_info.keys() + if subscribe_info[key] != old_info[key] + } + subscribe_oper = self.get("subscribe_oper") + subscribe = subscribe_oper.get(subscribe_id) if subscribe_oper else None + if not subscribe or not self._supports_subscribe(subscribe): + return + + if "state" in different_keys: + lifecycle = self.get("lifecycle") + if lifecycle: + old_state = old_info.get("state") + new_state = subscribe_info.get("state", subscribe.state) + lifecycle.handle_subscribe_modified_state_change( + subscribe, old_state=old_state, new_state=new_state + ) + + # 只在普通订阅首次切成洗版时回填;插件后续写进度字段也会触发修改事件,不能重复回填。 + if ("best_version" in different_keys + and subscribe_info.get("best_version") + and not old_info.get("best_version") + and self.get("backfill_enabled")): + priority = self.get("priority_manager") + detect = self.get("detect_backfill_episodes_fn") or self.get("detect_existing_episodes_fn") + if priority and detect and priority.can_backfill(subscribe): + episodes = detect(subscribe) + if episodes: + logger.info(f"订阅修改:{format_subscribe(subscribe)} 普通转洗版,回填已下载集 {episodes}") + priority.backfill_existing( + subscribe, episodes, scene=self._format_backfill_scene("plugin_backfill") + ) + + reset_fields = {"note", "lack_episode", "current_priority", "episode_priority", "state"} + if ( + data.get("scene") == "reset" + and different_keys & reset_fields + and subscribe_id not in self._reset_backfilling_ids + and self.get("backfill_enabled")): + priority = self.get("priority_manager") + detect = self.get("detect_backfill_episodes_fn") or self.get("detect_existing_episodes_fn") + if priority and detect and priority.can_backfill(subscribe): + episodes = detect(subscribe) + if episodes: + logger.info(f"订阅重置:{format_subscribe(subscribe)} 分集洗版订阅回填已下载集 {episodes}") + self._reset_backfilling_ids.add(subscribe_id) + try: + priority.backfill_existing( + subscribe, episodes, scene=self._format_backfill_scene("reset_backfill") + ) + finally: + self._reset_backfilling_ids.discard(subscribe_id) + + def on_subscribe_complete(self, event): + """SubscribeComplete → 清理任务数据 + H 快照 + 自动洗版创建。 + + 从 event.event_data(dict)取 subscribe_id / subscribe_info / mediainfo;快照所需的订阅对象优先查库, + 查不到(完成后已删等)退回用 subscribe_info 重建,避免把整个 event_data 误当订阅。 + 自动洗版创建由洗版编排在开关开启时新建洗版订阅。 + """ + data = event.event_data + if not isinstance(data, dict): + return + subscribe_id = data.get("subscribe_id") + subscribe_info = data.get("subscribe_info") or {} + subscribe_oper = self.get("subscribe_oper") + subscribe = subscribe_oper.get(subscribe_id) if (subscribe_oper and subscribe_id) else None + if subscribe is None and subscribe_info: + subscribe = SimpleNamespace(**subscribe_info) + if subscribe is not None and not self._supports_subscribe(subscribe): + return + detail(f"订阅完成事件:{format_subscribe_label(subscribe, subscribe_id)}") + + mediainfo_from_dict = self.get("mediainfo_from_dict") + mediainfo = None + if mediainfo_from_dict: + try: + mediainfo = mediainfo_from_dict(data.get("mediainfo")) + except Exception: + logger.warning( + f"订阅完成事件:{format_subscribe_label(subscribe, subscribe_id)} " + "媒体信息解析失败,按无媒体信息继续保存快照并清理任务" + ) + + verifier = self.get("verifier") + if subscribe and verifier: + verifier.snapshot(subscribe=subscribe, mediainfo=mediainfo, scope=None) + + task_manager = self.get("task_manager") + if subscribe_id and task_manager: + task_manager.clear_tasks(subscribe_id) + + if not subscribe: + return + + # 自动洗版创建(按开关;mediainfo 由事件重建,洗版编排判断是否新建洗版订阅) + orchestrator = self.get("orchestrator") + if orchestrator and mediainfo: + detail(f"订阅完成:{format_subscribe(subscribe)} 检查是否需要自动创建洗版订阅") + orchestrator.start_best_version(subscribe, mediainfo) + + def on_transfer_intercept(self, event): + """TransferIntercept → 订阅清理目标文件删除。""" + subscription_cleanup = self.get("subscription_cleanup") + if subscription_cleanup and subscription_cleanup.handle_history_clear(event): + detail("整理拦截事件:已完成订阅清理记录处理") + + def on_resource_selection(self, event): + """ResourceSelection → 洗版下载串行控制 + 识别增强 + 剔除近期删除资源防重选。 + + 洗版存在下载待定时不再选择其他资源;分集洗版只挡住覆盖待定集的候选, + 待定集未知时保守全挡,避免同集多版本并发下载产生覆盖竞态。 + """ + data = _event_data(event) + if not data: + return + contexts = data.contexts or [] + if not contexts: + return + subscribe_oper = self.get("subscribe_oper") + _, subscribe = subscribe_from_source(getattr(data, "origin", None), subscribe_oper) + if subscribe is not None and not self._supports_subscribe(subscribe): + return + if data.updated and data.updated_contexts is not None: + base = list(data.updated_contexts) + else: + base = list(contexts) + kept = base + changed = False + + serial_input_count = len(kept) + serial = self._filter_pending_serial(data, kept) + if serial is not None and len(serial) != len(kept): + kept, changed = serial, True + stage_counts = [{"stage": "wash_serial", "input": serial_input_count, "output": len(kept)}] + + recognition_guard = self.get("recognition_guard") + recognition_ran = False + if recognition_guard: + if subscribe: + guarded = recognition_guard.filter( + kept, + subscribe=subscribe, + event_data=data, + selection_original_count=len(contexts), + stage_counts=stage_counts, + ) + recognition_ran = True + if guarded is not None: + if guarded != kept: + kept, changed = guarded, True + else: + kept = guarded + + delete_input_count = len(kept) + if self.get("skip_deletion"): + deletes_store = self.get("deletes_store") + if deletes_store: + deduped = [ctx for ctx in kept if not self._is_deleted_resource(ctx, deletes_store)] + if len(deduped) != len(kept): + kept, changed = deduped, True + stage_counts.append({"stage": "delete_fingerprint", "input": delete_input_count, "output": len(kept)}) + if recognition_ran: + recognition_guard.finalize_batch(final_count=len(kept), stage_counts=stage_counts) + notify_fn = self.get("notify_fn") + payload = recognition_guard.notification_payload(subscribe) if notify_fn else None + if payload: + title, text = payload + notify_fn(title, text=text, diagnostic=True) + + if changed: + detail( + f"ResourceSelection:候选从 {len(base)} 个减少到 {len(kept)} 个" + "(洗版下载串行控制 + 识别增强 + 删除指纹防重)" + ) + data.updated = True + data.updated_contexts = kept + data.source = "订阅助手(增强版)" + + def _filter_pending_serial(self, data, contexts): + """洗版订阅有下载待定时,根据洗版模式过滤后续候选。 + + 洗版整体串行,分集洗版按集串行;非洗版、无待定或无任务管理器时返回 None。 + """ + task_manager = self.get("task_manager") + if not self.get("pending_download_enabled"): + return None + if not task_manager: + return None + subscribe_oper = self.get("subscribe_oper") + _, subscribe = subscribe_from_source(data.origin, subscribe_oper) + if not subscribe or not subscribe.best_version: + return None + sid = subscribe.id + pending = (task_manager.read("subscribes") or {}).get(str(sid), {}).get("download_pending", {}) + if not pending: + return None + if is_full_best_version_subscribe(subscribe): + return [] + torrents = task_manager.read("torrents") or {} + pending_eps, unknown = set(), False + for torrent_hash in pending: + eps = (torrents.get(torrent_hash) or {}).get("episodes") + if eps: + pending_eps.update(self._normalize_episodes(eps)) + else: + unknown = True + if unknown: + return [] # 待定集无法确定时保守全挡,避免同集多版本绕过串行造成覆盖竞态。 + kept = [] + for ctx in contexts: + ctx_eps = set(self._context_episodes(ctx)) + if ctx_eps and (ctx_eps & pending_eps): + continue + kept.append(ctx) + return kept + + @staticmethod + def _is_deleted_resource(ctx, deletes_store) -> bool: + """判断候选资源是否命中删除指纹(enclosure/page_url)。""" + torrent_info = getattr(ctx, "torrent_info", None) + if not torrent_info: + return False + return deletes_store.match( + enclosure=getattr(torrent_info, "enclosure", None), + page_url=getattr(torrent_info, "page_url", None)) + + def on_resource_download(self, event): + """ResourceDownload → 订阅清理 + 下载待定 + 按种子记录优先级基线。 + + ResourceDownload 阶段尚无 hash,先写无 hash 待定以覆盖 DownloadAdded 前的完成检查空窗; + 洗版优先级基线按 enclosure 归属,便于删种后按集回滚并隔离并行洗版。 + """ + data = _event_data(event) + if not data or data.cancel: + return + subscribe_oper = self.get("subscribe_oper") + _, subscribe = subscribe_from_source(data.origin, subscribe_oper) + if not subscribe or not self._supports_subscribe(subscribe): + return + # context.torrent_info 来自主程序事件,可能为空或对象结构不完整。 + torrent_info = getattr(data.context, "torrent_info", None) + monitor = self.get("download_monitor") + if monitor and self.get("pending_download_enabled") and torrent_info: + detail( + f"ResourceDownload:{format_subscribe(subscribe)} 写入无 hash 下载待定," + "等待 DownloadAdded 确认" + ) + monitor.mark_download_started( + subscribe, + episodes=self._normalize_episodes(data.episodes), + downloader=getattr(data, "downloader", None), + enclosure=getattr(torrent_info, "enclosure", None), + page_url=getattr(torrent_info, "page_url", None), + title=getattr(torrent_info, "title", None), + description=getattr(torrent_info, "description", None), + ) + + subscription_cleanup = self.get("subscription_cleanup") + if subscription_cleanup: + detail( + f"ResourceDownload:{format_subscribe(subscribe)} 执行订阅清理前置检查" + ) + subscription_cleanup.handle_resource_download_history_clear( + subscribe, + context=data.context, + episodes=data.episodes, + ) + priority = self.get("priority_manager") + if priority and subscribe.best_version and torrent_info: + enclosure = getattr(torrent_info, "enclosure", None) + if enclosure: + detail( + f"ResourceDownload:{format_subscribe(subscribe)} " + f"{self._best_version_mode_label(subscribe)}记录按种子优先级基线" + ) + priority.capture_torrent_baseline( + subscribe, enclosure, + self._normalize_episodes(data.episodes), + contributed_priority=getattr(torrent_info, "pri_order", 0), + target_episodes=self._subscribe_target_episodes(subscribe), + ) + + def on_download_added(self, event): + """DownloadAdded → 登记种子监控数据,并在暂停订阅命中下载时恢复订阅。""" + data = event.event_data + if not isinstance(data, dict): + return + subscribe_oper = self.get("subscribe_oper") + _, subscribe = subscribe_from_source(data.get("source"), subscribe_oper) + if not subscribe or not self._supports_subscribe(subscribe): + return + monitor = self.get("download_monitor") + if monitor and (self.get("pending_download_enabled") or self.get("download_monitor_enabled")): + detail(f"DownloadAdded:{format_subscribe(subscribe)} 登记种子监控 hash={data.get('hash')}") + torrent_info = getattr(data.get("context"), "torrent_info", None) + monitor.on_download( + subscribe.id, + data.get("hash"), + episodes=data.get("episodes"), + downloader=data.get("downloader"), + enclosure=getattr(torrent_info, "enclosure", None), + page_url=getattr(torrent_info, "page_url", None), + title=getattr(torrent_info, "title", None), + description=getattr(torrent_info, "description", None), + ) + lifecycle = self.get("lifecycle") + result = lifecycle.handle_download_added_for_subscribe(subscribe) if lifecycle else None + if result and result.changed: + self._notify_download_resume(subscribe, result.reason) + + def _notify_download_resume(self, subscribe, reason: str): + """发送下载命中恢复暂停订阅通知;实际推送仍由全局通知开关控制。""" + notify_fn = self.get("notify_fn") + if not notify_fn: + return + external = reason == "external" + title = ( + f"{format_subscribe(subscribe)} 检测到下载任务,已恢复外部暂停订阅" + if external else + f"{format_subscribe(subscribe)} 检测到下载任务,已恢复暂停订阅" + ) + notification_image_fn = self.get("notification_image_fn") + notify_fn( + title, + reason=self._pause_reason_label(reason), + action="已恢复为订阅中", + follow_up=( + "用户再次手动暂停仍会立即生效" + if external else + "48小时内不会因同一原因再次自动暂停" + ), + image=notification_image_fn(subscribe) if notification_image_fn else None, + ) + + @staticmethod + def _pause_reason_label(reason: str) -> str: + """把内部暂停原因转换为通知可读文本。""" + return { + "no_download": "无下载暂停", + "pre_air": "上映/开播暂停", + "airing_gap": "播出间隔暂停", + "auto_user": "用户名暂停", + "external": "外部暂停", + }.get(reason, f"{reason} 暂停") + + def on_transfer_complete(self, event): + """TransferComplete → 清下载待定 + 移动模式同步清理种子任务记录。 + + 订阅归属需在清理 torrents 任务前反查;移动模式下源已转走,只同步清理插件任务记录, + 不在此处调用下载器删除 API。 + """ + data = event.event_data + if not isinstance(data, dict): + return + download_hash = data.get("download_hash") + if not download_hash: + return + task_manager = self.get("task_manager") + + # 先查下载任务归属,再清下载待定,避免任务记录删掉后找不到订阅。 + subscribe_id = None + if task_manager: + torrent_task = (task_manager.read("torrents") or {}).get(download_hash) + if torrent_task: + subscribe_id = torrent_task.get("subscribe_id") + monitor = self.get("download_monitor") + if monitor and subscribe_id: + detail( + f"TransferComplete:{self._format_subscribe_label(subscribe_id)} " + f"下载已入库,解除 hash={download_hash} 的下载待定" + ) + monitor.clear_download_pending(subscribe_id, download_hash) + + # 移动模式下下载源已转走,插件不再继续检查该下载任务。 + transfer_info = data.get("transferinfo") + if transfer_info and transfer_info.transfer_type == "move" and task_manager: + detail(f"TransferComplete:移动模式清理已完成下载任务 hash={download_hash}") + task_manager.clean_torrent_tasks(download_hash) + lifecycle = self.get("lifecycle") + if lifecycle: + lifecycle.handle_library_updated(subscribe_id) + self._convert_episode_best_version_to_full_if_ready(subscribe_id) + + def _convert_episode_best_version_to_full_if_ready(self, subscribe_id): + """整理完成后委托共享 readiness 入口补偿检查分集转全集。""" + convert = self.get("convert_episode_best_version_to_full_fn") + if subscribe_id and convert: + convert(subscribe_id, trigger="TransferComplete") + + @staticmethod + def _best_version_mode_label(subscribe) -> str: + """按订阅实际洗版形态返回日志标签。""" + if is_full_best_version_subscribe(subscribe): + return "洗版" + if is_tv_episode_best_version_subscribe(subscribe): + return "分集洗版" + return "" + + def on_plugin_action(self, event): + """PluginAction → /subscribe_toggle 切换订阅启用(R)/禁用(S)状态。 + + 关键字为纯数字按订阅 id 匹配、否则按名称匹配;命中唯一则切换并通知,命中多个则回列表让用户带 id 重试。 + """ + data = event.event_data + if not isinstance(data, dict) or data.get("action") != "subscribe_toggle": + return + subscribe_oper = self.get("subscribe_oper") + if not subscribe_oper: + return + post_message = self.get("post_message") + channel, userid, source = data.get("channel"), data.get("user"), data.get("source") + + def notify(title, text=None): + if post_message: + post_message(channel=channel, title=title, text=text, userid=userid, source=source) + + keyword = (data.get("arg_str") or "").strip() + if not keyword: + notify("未能获取到订阅信息") + return + subscribes = subscribe_oper.list() or [] + if keyword.isdigit(): + matched = [s for s in subscribes if s.id == int(keyword)] + else: + matched = [s for s in subscribes if s.name == keyword] + if not matched: + notify("没有找到符合要求的订阅") + return + if len(matched) == 1: + subscribe = matched[0] + lifecycle = self.get("lifecycle") + if not lifecycle: + logger.warning(f"订阅切换命令:{format_subscribe(subscribe)} 生命周期未就绪,跳过状态切换") + notify("订阅生命周期未就绪,无法切换订阅状态") + return + result = lifecycle.toggle_subscribe_by_user_command(subscribe) + new_state = result.state or ("S" if subscribe.state != "S" else "R") + logger.info(f"订阅切换命令:{format_subscribe(subscribe)} 状态切换为 {new_state}") + notify(f"{format_subscribe(subscribe)} 已{'禁用' if new_state == 'S' else '启用'}") + else: + lines = [f"{s.id}. {s.name}" for s in matched] + notify("回复对应指令切换订阅状态:/subscribe_toggle [id]", text="\n".join(lines)) + + @staticmethod + def _normalize_episodes(episodes): + """规整集数为 int 列表,过滤无法转 int 的值。""" + result = [] + for ep in episodes or []: + try: + result.append(int(ep)) + except (TypeError, ValueError): + continue + return result + + @classmethod + def _context_episodes(cls, context): + """从 ResourceSelection 候选读取集数,兼容主程序 Context 与轻量测试替身。""" + direct = cls._normalize_episodes(getattr(context, "episodes", None)) + if direct: + return direct + torrent = getattr(context, "torrent_info", None) + torrent_eps = cls._normalize_episodes(getattr(torrent, "episode_list", None)) + if torrent_eps: + return torrent_eps + meta = getattr(context, "meta_info", None) + meta_eps = cls._normalize_episodes(getattr(meta, "episode_list", None)) + if meta_eps: + return meta_eps + begin = getattr(meta, "begin_episode", None) + end = getattr(meta, "end_episode", None) + try: + begin = int(begin) if begin is not None else None + end = int(end) if end is not None else begin + except (TypeError, ValueError): + return [] + if begin is not None and end is not None and end >= begin: + return list(range(begin, end + 1)) + return [] + + @staticmethod + def _subscribe_target_episodes(subscribe): + """订阅目标集范围 start_episode..total_episode,供整季包 episodes 为空时的基线回退。""" + total = subscribe.total_episode or 0 + if not total: + return [] + start = subscribe.start_episode or 1 + return list(range(start, total + 1)) + + def _get_subscribe(self, event): + """从事件取 subscribe:优先 wrapper 直属,回退 event_data.subscribe。""" + return getattr(event, "subscribe", None) or getattr(event.event_data, "subscribe", None) + + def _get_subscribe_from_event_data(self, event): + """取 event_data 作 subscribe:dict 形态包成 SimpleNamespace 以统一属性访问。""" + event_data = event.event_data + if isinstance(event_data, dict): + from types import SimpleNamespace + return SimpleNamespace(**event_data) + return event_data diff --git a/plugins.v3/subscribeassistantenhanced/form/__init__.py b/plugins.v3/subscribeassistantenhanced/form/__init__.py new file mode 100644 index 00000000..7210d0ca --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/form/__init__.py @@ -0,0 +1,413 @@ +"""配置表单(vuetify 模式):顶部开关行 + 周期行 + 7 个 Tab 分页 + 底部提示。 + +设计:表单字段名 == PluginConfig 配置键,model 默认值由 PluginConfig.defaults() 派生,避免保存配置与运行时键漂移。 +conf 结构:[switch_row, period_row, VTabs, VWindow, *footer],VTabs/VWindow 共用 model "_tab" 联动当前页; +每个字段挂常驻 hint(LABELS/HINTS 双表维护),Tab 内按行列布局排列,底部三条 VAlert 给出指引与风险提示。 +""" +from ..shared.config import PluginConfig +from .components import (ace_editor_field, alert_row, cron_field, field_for, multi_select_field, + select_field, switch_col, tabs) + +# 各配置键的中文显示名(与 README 配置项名保持一致) +LABELS = { + # 全局开关与运行 + "enabled": "启用插件", + "notify": "发送通知", + "reset_task": "重置数据", + "onlyonce": "立即运行一次", + # 周期 + "download_check_interval_minutes": "下载检查周期(分钟)", + "meta_check_interval_hours": "元数据检查周期(小时)", + "best_version_cron": "洗版检查周期", + # 订阅清理 + "download_monitor_enabled": "下载超时自动删除", + "manual_delete_listen": "监听手动删除种子", + "tracker_response_listen": "监听Tracker响应关键字", + "auto_search_when_delete": "删除后触发搜索补全", + "skip_deletion": "跳过近期删除资源", + "download_timeout_minutes": "下载超时时间(分钟)", + "download_progress_threshold": "下载超时进度阈值", + "download_queue_grace_multiplier": "下载排队宽限倍数", + "download_retry_limit": "下载连续超时重试次数", + "delete_record_retention_hours": "删除记录保留(小时)", + "delete_exclude_tags": "排除标签", + "default_tracker_response": "Tracker响应关键字", + "auto_check_interval_minutes": "通用巡检周期(分钟)", + "subscription_cleanup_history_type": "清理整理记录范围", + "subscription_cleanup_history_scenes": "清理整理记录场景", + # 识别增强 + "recognition_guard_mode": "识别增强模式", + "recognition_guard_notify": "识别增强通知", + "recognition_guard_notify_interval": "识别增强通知限频(秒)", + "recognition_guard_tmdb_recheck_mode": "识别增强二次识别", + "recognition_guard_cache_maxsize": "识别增强缓存大小", + "recognition_guard_custom_config": "自定义识别规则", + # 订阅待定 + "pending_enhanced_enabled": "自动待定剧集订阅", + "pending_download_enabled": "自动待定下载中订阅", + "auto_tv_pending_days": "剧集待定天数", + "auto_tv_pending_episodes": "剧集待定集数", + "pending_use_volatility": "待定参考变更速率", + # 订阅暂停 + "pause_enhanced_enabled": "自动暂停订阅", + "auto_pause_users": "自动暂停新增订阅的用户(逗号分隔)", + "airing_pause_days": "即将播出暂停天数", + "tv_air_pause_days": "剧集上映暂停天数", + "movie_air_pause_days": "电影上映暂停天数", + "tv_no_download_days": "剧集无下载处理天数", + "movie_no_download_days": "电影无下载处理天数", + "no_download_actions": "无下载处理策略", + # 订阅补全 + "paused_probe_reasons": "暂停订阅补搜场景", + "paused_probe_min_pause_days": "暂停满N天后补搜", + "paused_probe_interval_hours": "补搜间隔(小时)", + "site_total_probe_enabled": "站点集数探测", + # 订阅洗版 + "best_version_type": "洗版类型", + "best_version_episode_to_full": "分集转全集", + "best_version_backfill_enabled": "回填已存在集", + "backfill_best_version_now": "立即扫描存量并回填", + "best_version_movie_remaining_days": "电影洗版时限(天)", + "best_version_tv_remaining_days": "剧集洗版时限(天)", + # 完结信号 + "completion_guard_mode": "完结守卫模式", + "site_completion_evidence_enabled": "站点完结信号", + "volatility_enabled": "变更速率信号", + "volatility_window_days": "变更速率窗口(天)", + "cadence_enabled": "播出节奏信号", + "cadence_multiplier": "节奏窗口系数", + "cadence_min_window_days": "节奏窗口下限(天)", + "cadence_min_episodes": "节奏参与最少集数", + "season_cooldown_days": "季冷却期(天)", + "verify_enabled": "自动纠错", + "verify_interval_hours": "自动纠错间隔(小时)", + "verify_retention_days": "快照保留(天)", + "timeout_release_days": "完成前观察天数", + "timeout_cadence_acceleration": "按节奏加速释放", +} + +# 各配置键的常驻说明,缺省键不显示说明行。 +HINTS = { + # 全局开关与运行 + "enabled": "开启后插件将处于激活状态", + "notify": "是否在特定事件发生时发送通知", + "reset_task": "保存后将重置所有待定/暂停/监控等任务数据,执行后自动复位", + "onlyonce": "保存后立即运行一次全量巡检,执行后自动复位", + # 周期 + "auto_check_interval_minutes": "站点采样、待定释放、无下载处理和清理周期", + "download_check_interval_minutes": "下载检查的周期,定时检查下载任务状态", + "meta_check_interval_hours": "元数据检查的周期,定时复核订阅元数据状态", + "best_version_cron": "洗版检查的周期,如 0 15 * * *", + # 订阅清理 + "download_monitor_enabled": "订阅下载超时将自动删除种子", + "manual_delete_listen": "监听用户手动删除的种子记录", + "tracker_response_listen": "命中Tracker响应关键字时将自动删除种子", + "auto_search_when_delete": "删种后将自动触发搜索补全", + "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": "需要排除的标签,多个标签用逗号分隔", + "default_tracker_response": "每一行一个关键字,忽略大小写,支持正则表达式匹配", + "subscription_cleanup_history_type": "订阅下载前清理旧整理记录、源文件和入库前目标文件的媒体类型范围(破坏性)", + "subscription_cleanup_history_scenes": "选择普通订阅、洗版订阅或分集洗版下载时触发订阅清理", + # 识别增强 + "recognition_guard_mode": "在自动下载前复核订阅候选是否像当前订阅目标", + "recognition_guard_notify": "控制识别增强消息推送,不影响审计日志", + "recognition_guard_notify_interval": "同订阅同动作同原因的通知限频秒数", + "recognition_guard_tmdb_recheck_mode": "控制二次识别触发范围", + "recognition_guard_cache_maxsize": "缓存二次识别结果,避免重复识别", + "recognition_guard_custom_config": "仅在内置规则无法满足时编辑,留空则继承当前模式", + # 订阅待定 + "pending_enhanced_enabled": "自动标记订阅剧集为待定状态,避免提前完成订阅", + "pending_download_enabled": "存在进行中下载时自动标记待定,避免提前完成订阅", + "auto_tv_pending_days": "当前日期小于上映日期加N天,则视为待定,为0时不处理", + "auto_tv_pending_episodes": "剧集数小于等于设置的集数,则视为待定,为0时不处理", + "pending_use_volatility": "接近完结且总集数变化时提前待定", + # 订阅暂停 + "pause_enhanced_enabled": "自动标记订阅为暂停状态,避免无意义的请求", + "auto_pause_users": "名单内用户新增订阅时将自动暂停,多个用户用逗号分隔,为空时不启用", + "airing_pause_days": "已存在最新播出集,且下集距当前日期大于N天,则视为暂停,为0时不处理", + "tv_air_pause_days": "当前日期小于开播日期减N天,则视为暂停,为0时不处理", + "movie_air_pause_days": "当前日期小于上映日期减N天,则视为暂停,为0时不处理", + "tv_no_download_days": "剧集上映后N天内无新的订阅下载,则按策略处理,为0时不处理", + "movie_no_download_days": "电影上映后N天内无新的订阅下载,则按策略处理,为0时不处理", + "no_download_actions": "选择无下载时的处理策略", + # 订阅补全 + "paused_probe_reasons": "选择允许低频补搜的暂停原因", + "paused_probe_min_pause_days": "暂停达到天数后开始补搜,0 表示不处理", + "paused_probe_interval_hours": "同一订阅两次补搜的最小间隔", + "site_total_probe_enabled": "用站点缓存资源辅助发现目标集数不足", + # 订阅洗版 + "best_version_type": "选择需要自动洗版的类型,关闭时不自动创建和巡检洗版订阅", + "best_version_episode_to_full": "订阅目标集数满足时,从分集洗版切换为全集洗版", + "best_version_backfill_enabled": "新建或转分集洗版时回填媒体库已有集,避免重复下载", + "backfill_best_version_now": "保存后对存量分集洗版订阅执行一次回填,执行后自动复位", + "best_version_movie_remaining_days": "电影洗版订阅达到指定天数后自动终止,有下载则按最新时间计算,为0时不限", + "best_version_tv_remaining_days": "剧集洗版订阅达到指定天数后自动终止,有下载则按最新时间计算,为0时不限", + # 完结信号 + "completion_guard_mode": "选择完成前复核强度,默认使用平衡策略", + "site_completion_evidence_enabled": "使用站点资源标题佐证完结信号", + "volatility_enabled": "总集数近期变化时视为不稳定", + "volatility_window_days": "统计总集数变化的天数,越长越保守", + "cadence_enabled": "按已播间隔判断等待期,不会直接判定完结", + "cadence_multiplier": "放大预计等待时间,数值越大等待越久", + "cadence_min_window_days": "预计等待时间不得少于设置天数", + "cadence_min_episodes": "已播集数达到设置值后才计算播出间隔", + "season_cooldown_days": "最后一集播出后继续观察的天数", + "verify_enabled": "完成后检查集数,增加时自动重建订阅", + "verify_interval_hours": "完成后重新检查集数的间隔", + "verify_retention_days": "完成快照按设置天数保留并自动清理,默认180天", + "timeout_release_days": "完成前观察允许保留的最长天数", + "timeout_cadence_acceleration": "等待期结束时缩短观察期限", +} + +TOP_SWITCHES = ["enabled", "notify", "reset_task", "onlyonce"] + +PERIODS = [ + "auto_check_interval_minutes", + "download_check_interval_minutes", + "meta_check_interval_hours", + "best_version_cron", +] + +# 各 Tab 的字段布局:标题 → 行列表,每行从左到右为该行字段键; +# 默认每列 md=4(一行三列),需特殊列宽时写成 (字段键, md)。 +TABS = [ + ("订阅清理", [ + ["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_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)], + [("auto_tv_pending_days", 6), ("auto_tv_pending_episodes", 6)], + ]), + ("订阅暂停", [ + [("pause_enhanced_enabled", 4), ("auto_pause_users", 8)], + ["movie_air_pause_days", "tv_air_pause_days", "airing_pause_days"], + ["movie_no_download_days", "tv_no_download_days", "no_download_actions"], + ]), + ("订阅补全", [ + [("site_total_probe_enabled", 12)], + ["paused_probe_reasons", "paused_probe_min_pause_days", "paused_probe_interval_hours"], + ]), + ("订阅洗版", [ + ["best_version_type", "best_version_movie_remaining_days", "best_version_tv_remaining_days"], + ["best_version_episode_to_full", "best_version_backfill_enabled", "backfill_best_version_now"], + ]), + ("完结信号", [ + ["verify_enabled", "site_completion_evidence_enabled", "volatility_enabled"], + ["cadence_enabled", "timeout_cadence_acceleration", "completion_guard_mode"], + ["volatility_window_days", "cadence_multiplier", "cadence_min_window_days"], + ["cadence_min_episodes", "season_cooldown_days", "verify_interval_hours"], + ["verify_retention_days", "timeout_release_days"], + ]), + ("识别增强", [ + ["recognition_guard_mode", "recognition_guard_notify", "recognition_guard_notify_interval"], + ["recognition_guard_tmdb_recheck_mode", "recognition_guard_cache_maxsize"], + [("recognition_guard_custom_config", 12)], + ]), +] + +# 下载检查需要分钟级响应,保留 5 分钟起步的高频选项。 +_MINUTE_INTERVAL_ITEMS = [ + {"title": "5分钟", "value": 5}, + {"title": "10分钟", "value": 10}, + {"title": "15分钟", "value": 15}, + {"title": "30分钟", "value": 30}, + {"title": "60分钟", "value": 60}, + {"title": "120分钟", "value": 120}, +] + +# 通用巡检承载站点证据采样与本地生命周期巡检,保留高频选项给追更敏感场景按需调整。 +_COMMON_INTERVAL_ITEMS = [ + {"title": "10分钟", "value": 10}, + {"title": "20分钟", "value": 20}, + {"title": "30分钟", "value": 30}, + {"title": "60分钟", "value": 60}, + {"title": "120分钟", "value": 120}, + {"title": "240分钟", "value": 240}, +] + +# 固定枚举选择项(非布尔/字符串/数值,需限定候选集的字段) +SELECT_ITEMS = { + "completion_guard_mode": [ + {"title": "关闭", "value": "off"}, + {"title": "严格", "value": "strict"}, + {"title": "平衡", "value": "balanced"}, + {"title": "宽松", "value": "loose"}, + ], + "download_check_interval_minutes": _MINUTE_INTERVAL_ITEMS, + "auto_check_interval_minutes": _COMMON_INTERVAL_ITEMS, + "meta_check_interval_hours": [ + {"title": "1小时", "value": 1}, + {"title": "3小时", "value": 3}, + {"title": "6小时", "value": 6}, + {"title": "12小时", "value": 12}, + {"title": "24小时", "value": 24}, + ], + "paused_probe_interval_hours": [ + {"title": "24", "value": 24}, + {"title": "48", "value": 48}, + {"title": "72", "value": 72}, + {"title": "96", "value": 96}, + {"title": "120", "value": 120}, + {"title": "144", "value": 144}, + ], + "best_version_type": [ + {"title": "关闭", "value": "no"}, + {"title": "全部", "value": "all"}, + {"title": "电影", "value": "movie"}, + {"title": "剧集", "value": "tv"}, + {"title": "剧集(分集下载)", "value": "tv_episode"}, + ], + "subscription_cleanup_history_type": [ + {"title": "关闭", "value": "no"}, + {"title": "全部", "value": "all"}, + {"title": "电影", "value": "movie"}, + {"title": "剧集", "value": "tv"}, + ], + "recognition_guard_mode": [ + {"title": "关闭", "value": "off"}, + {"title": "审计", "value": "audit"}, + {"title": "宽松", "value": "loose"}, + {"title": "平衡", "value": "balanced"}, + {"title": "严格", "value": "strict"}, + ], + "recognition_guard_notify": [ + {"title": "关闭", "value": "off"}, + {"title": "摘要", "value": "summary"}, + {"title": "明细", "value": "detail"}, + {"title": "全部", "value": "all"}, + ], + "recognition_guard_tmdb_recheck_mode": [ + {"title": "关闭", "value": "off"}, + {"title": "全部", "value": "all"}, + {"title": "严格", "value": "strict"}, + {"title": "平衡和严格", "value": "balanced_strict"}, + ], +} + +# 多选枚举字段(chips 展示) +MULTI_ITEMS = { + "subscription_cleanup_history_scenes": [ + {"title": "普通订阅", "value": "normal"}, + {"title": "洗版订阅", "value": "best_version"}, + {"title": "分集洗版", "value": "best_version_episode"}, + ], + "no_download_actions": [ + {"title": "暂停电影订阅", "value": "pause_movie"}, + {"title": "暂停剧集订阅", "value": "pause_tv"}, + {"title": "完成电影订阅", "value": "complete_movie"}, + {"title": "完成剧集订阅", "value": "complete_tv"}, + {"title": "删除电影订阅", "value": "delete_movie"}, + {"title": "删除剧集订阅", "value": "delete_tv"}, + ], + "paused_probe_reasons": [ + {"title": "无下载", "value": "no_download"}, + {"title": "上映/开播", "value": "pre_air"}, + {"title": "播出间隔", "value": "airing_gap"}, + {"title": "用户名", "value": "auto_user"}, + {"title": "外部暂停", "value": "external"}, + {"title": "全部", "value": "all"}, + ], +} + + +# 插件 README(底部「详细说明」指引指向插件市场仓库内的独立文档) +README_URL = ("https://github.com/InfinityPacer/MoviePilot-Plugins/" + "blob/main/plugins.v3/subscribeassistantenhanced/README.md") +# Tab 内字段默认列宽:md=4 即一行三列 +FIELD_MD = 4 + +# 按 cron 表达式调度的字段,表单用 VCronField 而非数值框 +CRON_FIELDS = {"best_version_cron"} + + +def _field(key: str, defaults: dict, md: int = FIELD_MD) -> dict: + """按字段类型选择控件:cron 字段走 cron_field,多选枚举走 multi_select_field,固定枚举走 select_field,其余交 field_for 按默认值类型分发。 + + field_for 按默认值类型出控件(bool→开关、str→文本、数值→数值框),故同一行可混排开关与输入;md 为该列宽度。 + """ + label = LABELS.get(key, key) + hint = HINTS.get(key, "") + if key in CRON_FIELDS: + return cron_field(key, label, hint, md) + if key in MULTI_ITEMS: + return multi_select_field(key, label, MULTI_ITEMS[key], hint, md) + if key in SELECT_ITEMS: + return select_field(key, label, SELECT_ITEMS[key], hint, md) + if key == "recognition_guard_custom_config": + return ace_editor_field(key, label, hint, md) + return field_for(key, label, defaults.get(key), hint, md) + + +def _row(cols: list) -> dict: + """把若干字段列包成一个 VRow。""" + return {"component": "VRow", "content": cols} + + +def _tab_windows(defaults: dict) -> list: + """按 TABS 的行布局构建各 Tab 页:每个 Tab 是若干 VRow,行内列顺序即字段顺序。 + + 行内每项为字段键,或 (字段键, md) 指定该列宽度,缺省 md 为 FIELD_MD。 + """ + windows = [] + for title, rows in TABS: + win_rows = [] + for row in rows: + cols = [] + for item in row: + key, md = item if isinstance(item, tuple) else (item, FIELD_MD) + cols.append(_field(key, defaults, md)) + win_rows.append(_row(cols)) + windows.append(win_rows) + return windows + + +def _footer() -> list: + """底部提示区:README 指引、数据源说明与破坏性风险警告。""" + return [ + alert_row("success", text="注意:详细使用说明与配置释义请参考:", content=[ + {"component": "a", + "props": {"href": README_URL, "target": "_blank"}, + "content": [{"component": "u", "text": "README"}]}, + ], margin_top="12px"), + alert_row("info", text="注意:本插件仅支持 TMDB 数据源,订阅状态相关说明请查阅 ", content=[ + {"component": "a", + "props": {"href": "https://github.com/jxxghp/MoviePilot/pull/3330", "target": "_blank"}, + "content": [{"component": "u", "text": "#3330"}]}, + {"component": "span", "text": "、"}, + {"component": "a", + "props": {"href": "https://github.com/jxxghp/MoviePilot-Frontend/pull/477", "target": "_blank"}, + "content": [{"component": "u", "text": "#477"}]}, + {"component": "span", "text": "、"}, + {"component": "a", + "props": {"href": "https://github.com/jxxghp/MoviePilot/pull/6015", "target": "_blank"}, + "content": [{"component": "u", "text": "#6015"}]}, + ]), + alert_row("error", text="注意:本插件可能导致订阅数据异常、媒体文件丢失,相关风险请自行评估与承担"), + ] + + +def build_form(): + """聚合表单:顶部开关行 + 周期行 + 7 个 Tab + 底部提示;model 为全部配置键默认值。""" + defaults = PluginConfig.defaults() + beta_alert = alert_row( + "warning", + text="BETA 版本提示:本插件仍处于测试阶段,可能调整订阅状态、洗版记录、下载任务和媒体文件。" + ) + # 顶部一行:4 个全局开关(一行铺满 4 列) + switch_row = _row([switch_col(k, LABELS.get(k, k), HINTS.get(k, ""), md=3) + for k in TOP_SWITCHES]) + # 第二行:4 个公共周期配置(下载/元数据/通用巡检用下拉,洗版用 cron) + period_row = _row([_field(k, defaults, md=3) for k in PERIODS]) + titles = [t for t, _ in TABS] + windows = _tab_windows(defaults) + conf = [beta_alert, switch_row, period_row, *tabs(titles, windows), *_footer()] + return conf, dict(defaults) diff --git a/plugins.v3/subscribeassistantenhanced/form/components.py b/plugins.v3/subscribeassistantenhanced/form/components.py new file mode 100644 index 00000000..23ca9c5d --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/form/components.py @@ -0,0 +1,165 @@ +"""vuetify 表单控件工厂:收敛重复样板,按字段类型生成对应控件,单字段一列。 + +控件均为后端返回、前端通用渲染器(FormRender)识别的 Vuetify schema 字典; +``model`` 即配置键,须与 PluginConfig 的 @property 同名,保证 WebUI 存的配置能被运行时读到。 +所有控件统一支持 ``hint``:传入后挂 ``persistent-hint``,让字段说明在表单中常驻显示。 +""" +from typing import Any, List, Optional + + +def _with_hint(props: dict, hint: str) -> dict: + """给控件 props 注入常驻 hint:hint 为空则不挂,避免渲染出空白说明行。""" + if hint: + props["hint"] = hint + props["persistent-hint"] = True + return props + + +def switch_col(model: str, label: str, hint: str = "", md: int = 6) -> dict: + """开关控件(布尔配置),可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VSwitch", + "props": _with_hint({"model": model, "label": label}, hint)}], + } + + +def number_field(model: str, label: str, hint: str = "", md: int = 6) -> dict: + """数值输入控件(int/float 配置),可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VTextField", + "props": _with_hint({"model": model, "label": label, "type": "number"}, hint)}], + } + + +def text_field(model: str, label: str, hint: str = "", md: int = 12) -> dict: + """文本输入控件(字符串配置),可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VTextField", + "props": _with_hint({"model": model, "label": label}, hint)}], + } + + +def select_field(model: str, label: str, items: list, hint: str = "", md: int = 6) -> dict: + """固定枚举选择控件,避免保存运行时无法识别的自由文本,可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{ + "component": "VSelect", + "props": _with_hint({"model": model, "label": label, "items": items}, hint), + }], + } + + +def cron_field(model: str, label: str, hint: str = "", md: int = 6) -> dict: + """cron 表达式输入控件(VCronField),用于按 cron 调度的周期配置,可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VCronField", + "props": _with_hint({"model": model, "label": label}, hint)}], + } + + +def textarea_field(model: str, label: str, hint: str = "", md: int = 12, rows: int = 10) -> dict: + """多行文本输入控件(VTextarea),用于每行一项的关键字/列表类配置,可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VTextarea", + "props": _with_hint({"model": model, "label": label, "rows": rows}, hint)}], + } + + +def ace_editor_field(model: str, label: str, hint: str = "", md: int = 12) -> dict: + """YAML 编辑器控件,用于缩进敏感的策略配置。""" + props = { + "modelvalue": model, + "label": label, + "lang": "yaml", + "theme": "monokai", + "style": "height: 30rem", + } + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{"component": "VAceEditor", "props": _with_hint(props, hint)}], + } + + +def field_for(model: str, label: str, default: Any, hint: str = "", md: int = 6) -> dict: + """按默认值类型选择控件:bool→开关、str→文本、其余(int/float)→数值。 + + 必须先判 bool 再判 int——Python 中 ``isinstance(True, int)`` 为真,顺序写反会把开关误渲成数值框。 + """ + if isinstance(default, bool): + return switch_col(model, label, hint, md) + if isinstance(default, str): + return text_field(model, label, hint, md) + return number_field(model, label, hint, md) + + +def multi_select_field(model: str, label: str, items: list, hint: str = "", md: int = 4) -> dict: + """多选枚举控件(chips 展示),用于无下载处理策略等多值配置,可附常驻 hint。""" + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [{ + "component": "VSelect", + "props": _with_hint({"model": model, "label": label, "items": items, + "multiple": True, "chips": True, "clearable": True}, hint), + }], + } + + +def tabs(tab_titles: List[str], windows: List[List[dict]]) -> List[dict]: + """渲染 VTabs + VWindow:tab_titles 与 windows 一一对应,构成分页表单。 + + 返回 [VTabs, VWindow] 两个顶层组件;二者用同一 model "_tab" 联动当前页索引。 + ``stacked`` + ``fixed-tabs`` 让分页标题等宽铺满并居中,避免多页签时宽度抖动。 + ``windows[i]`` 为该页的组件列表(通常是若干 VRow),整体作为该页 VWindowItem 的内容; + VWindow 顶部留出浮动 label 空间,避免首行输入框标题被 tab 分隔线裁切。 + """ + tab_items = [{"component": "VTab", "props": {"value": i}, "text": t} + for i, t in enumerate(tab_titles)] + win_items = [{"component": "VWindowItem", "props": {"value": i}, "content": w} + for i, w in enumerate(windows)] + return [ + {"component": "VTabs", + "props": {"model": "_tab", "stacked": True, "fixed-tabs": True, + "style": {"margin-top": "8px", "margin-bottom": "8px"}}, + "content": tab_items}, + {"component": "VWindow", + "props": {"model": "_tab", "style": {"padding-top": "24px"}}, + "content": win_items}, + ] + + +def alert_row(alert_type: str, text: str = "", content: Optional[List[dict]] = None, + margin_top: str = "0") -> dict: + """底部整宽提示行(VAlert,tonal 样式):用于 README 指引、数据源说明与风险警告。 + + ``text`` 为纯文本提示;``content`` 为富文本子节点(如内嵌链接),二者可同时使用, + 富文本拼在 ``text`` 之后由前端 DashboardRender/FormRender 顺序渲染。 + """ + alert_props = {"type": alert_type, "variant": "tonal"} + if text: + alert_props["text"] = text + alert = {"component": "VAlert", "props": alert_props} + if content: + alert["content"] = content + return { + "component": "VRow", + "props": {"style": {"margin-top": margin_top}}, + "content": [{ + "component": "VCol", + "props": {"cols": 12}, + "content": [alert], + }], + } diff --git a/plugins.v3/subscribeassistantenhanced/frontend/.prettierignore b/plugins.v3/subscribeassistantenhanced/frontend/.prettierignore new file mode 100644 index 00000000..007ea8a7 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +coverage diff --git a/plugins.v3/subscribeassistantenhanced/frontend/.prettierrc.json b/plugins.v3/subscribeassistantenhanced/frontend/.prettierrc.json new file mode 100644 index 00000000..a7f0d2d4 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/.prettierrc.json @@ -0,0 +1,29 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": false, + "bracketSpacing": true, + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxSingleQuote": true, + "printWidth": 120, + "proseWrap": "preserve", + "quoteProps": "preserve", + "requirePragma": false, + "semi": false, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false, + "vueIndentScriptAndStyle": false, + "overrides": [ + { + "files": ["src/config/defaults.ts", "src/config/fields.ts"], + "options": { + "singleQuote": false, + "trailingComma": "none" + } + } + ] +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js b/plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js new file mode 100644 index 00000000..6c19b5ca --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js @@ -0,0 +1,111 @@ +import js from '@eslint/js' +import sonarjs from 'eslint-plugin-sonarjs' +import pluginVue from 'eslint-plugin-vue' +import globals from 'globals' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +const managedFiles = ['**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}', '**/*.vue'] +const browserFiles = [ + 'plugins.v3/subscribeassistantenhanced/frontend/src/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,vue}', + 'tests/v3/subscribeassistantenhanced/frontend/**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,vue}', +] +const nodeFiles = ['plugins.v3/subscribeassistantenhanced/frontend/*.config.{js,mjs,cjs,ts,mts,cts}'] + +const browserGlobalNames = new Set(Object.keys(globals.browser)) +const nodeOnlyGlobalRestrictions = Object.keys(globals.node) + .filter(name => !browserGlobalNames.has(name)) + .sort() + .map(name => ({ + name, + message: `'${name}' is only available in Node.js code.`, + })) + +const sonarRules = { + 'sonarjs/array-callback-without-return': 'error', + 'sonarjs/code-eval': 'error', + 'sonarjs/empty-string-repetition': 'error', + 'sonarjs/no-all-duplicated-branches': 'error', + 'sonarjs/no-dead-store': 'error', + 'sonarjs/no-duplicated-branches': 'error', + 'sonarjs/no-element-overwrite': 'error', + 'sonarjs/no-hardcoded-passwords': 'error', + 'sonarjs/no-hardcoded-secrets': 'error', + 'sonarjs/no-identical-conditions': 'error', + 'sonarjs/no-identical-expressions': 'error', + 'sonarjs/no-ignored-exceptions': 'error', + 'sonarjs/no-unthrown-error': 'error', + 'sonarjs/no-use-of-empty-return-value': 'error', + 'sonarjs/reduce-initial-value': 'error', + 'sonarjs/slow-regex': 'error', + 'sonarjs/stateful-regex': 'error', + 'sonarjs/super-linear-regex': 'error', +} + +const typescriptConfigs = tseslint.configs.recommended.map(config => ({ + ...config, + files: ['**/*.{ts,tsx,mts,cts}', '**/*.vue'], +})) + +const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({ + ...config, + files: ['**/*.vue'], +})) + +export default defineConfig([ + globalIgnores(['**/node_modules/**', '**/dist/**', '**/coverage/**', '**/.worktrees/**', '**/*.d.ts']), + { + ...js.configs.recommended, + files: managedFiles, + }, + ...typescriptConfigs, + ...vueConfigs, + { + files: ['**/*.vue'], + rules: { + 'vue/multi-word-component-names': 'off', + 'vue/valid-v-slot': ['error', { allowModifiers: true }], + }, + }, + { + files: managedFiles, + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { + parser: tseslint.parser, + }, + sourceType: 'module', + }, + }, + { + files: browserFiles, + languageOptions: { + globals: globals.browser, + }, + rules: { + 'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions], + }, + }, + { + files: nodeFiles, + languageOptions: { + globals: globals.node, + }, + rules: { + 'no-restricted-globals': 'off', + }, + }, + { + files: managedFiles, + plugins: { + sonarjs, + }, + rules: sonarRules, + }, + { + files: managedFiles, + rules: { + 'no-debugger': 'error', + }, + }, +]) diff --git a/plugins.v3/subscribeassistantenhanced/frontend/package.json b/plugins.v3/subscribeassistantenhanced/frontend/package.json new file mode 100644 index 00000000..b5f55456 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "moviepilot-plugin-subscribeassistantenhanced", + "private": true, + "version": "0.5.13", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "format": "prettier --config .prettierrc.json --ignore-path .prettierignore . ../../../tests/v3/subscribeassistantenhanced/frontend --write", + "format:check": "prettier --config .prettierrc.json --ignore-path .prettierignore . ../../../tests/v3/subscribeassistantenhanced/frontend --check", + "lint": "cd ../../.. && eslint --config plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js plugins.v3/subscribeassistantenhanced/frontend/src plugins.v3/subscribeassistantenhanced/frontend/vite.config.ts tests/v3/subscribeassistantenhanced/frontend --max-warnings=0", + "lint:fix": "cd ../../.. && eslint --config plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js plugins.v3/subscribeassistantenhanced/frontend/eslint.config.js plugins.v3/subscribeassistantenhanced/frontend/src plugins.v3/subscribeassistantenhanced/frontend/vite.config.ts tests/v3/subscribeassistantenhanced/frontend --fix", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.5.13", + "vuetify": "3.7.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@originjs/vite-plugin-federation": "^1.4.1", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/user-event": "14.6.1", + "@testing-library/vue": "8.1.0", + "@vitejs/plugin-vue": "^5.0.4", + "@vitest/coverage-v8": "3.2.7", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "^3.5.13", + "@vue/test-utils": "2.4.11", + "eslint": "^10.7.0", + "eslint-plugin-sonarjs": "^4.2.0", + "eslint-plugin-vue": "^10.9.2", + "globals": "^17.3.0", + "jsdom": "26.1.0", + "msw": "2.15.0", + "prettier": "^3.9.5", + "typescript": "^5.0.4", + "typescript-eslint": "^8.64.0", + "vite": "^5.4.11", + "vitest": "3.2.7", + "vue-eslint-parser": "^10.4.1", + "vue-tsc": "^2.0.10" + }, + "packageManager": "yarn@1.22.18", + "engines": { + "node": ">=20.19" + } +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/assets/sae-logo.png b/plugins.v3/subscribeassistantenhanced/frontend/src/assets/sae-logo.png new file mode 100644 index 00000000..9343c87d Binary files /dev/null and b/plugins.v3/subscribeassistantenhanced/frontend/src/assets/sae-logo.png differ diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/components/Config.vue b/plugins.v3/subscribeassistantenhanced/frontend/src/components/Config.vue new file mode 100644 index 00000000..b4be73bd --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/components/Config.vue @@ -0,0 +1,1776 @@ + + + + + diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/api.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/api.ts new file mode 100644 index 00000000..de10df78 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/api.ts @@ -0,0 +1,34 @@ +/** `/summary` 返回的粗粒度运行概况,不包含配置明细或私密数据。 */ +export interface SummaryPayload { + /** 各业务域的启用状态或当前模式。 */ + domains: Record + /** 当前待定订阅数量。 */ + pending_count: number + /** 当前受监控种子数量。 */ + monitored_torrents: number +} + +/** MoviePilot 宿主注入给联邦组件的最小只读 API 契约。 */ +export interface PluginApi { + /** 使用宿主登录态发起 GET 请求。 */ + get(url: string, config?: unknown): Promise +} + +/** MoviePilot V3 的统一 API 响应信封。 */ +interface ApiResponse { + success: boolean + message: string + data: T | null +} + +/** 读取可选运行概况;宿主或请求不可用时配置页继续以本地草稿渲染。 */ +export async function loadSummary(api?: PluginApi): Promise { + if (!api) return null + try { + const response = await api.get>('plugin/SubscribeAssistantEnhanced/summary') + return response.data + } catch { + console.warn('[SubscribeAssistantEnhanced] summary unavailable') + return null + } +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/defaults.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/defaults.ts new file mode 100644 index 00000000..5318b633 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/defaults.ts @@ -0,0 +1,215 @@ +/** 与 PluginConfig.defaults() 对齐的 Vue 配置持久化契约。 */ +export interface SaeConfig { + /** 启用插件 */ + enabled: boolean + /** 发送通知 */ + notify: boolean + /** 立即运行一次 */ + onlyonce: boolean + /** 重置数据 */ + reset_task: boolean + /** 通用巡检周期(分钟) */ + auto_check_interval_minutes: number + /** 下载检查周期(分钟) */ + download_check_interval_minutes: number + /** 元数据检查周期(小时) */ + meta_check_interval_hours: number + /** 洗版检查周期 */ + best_version_cron: string + /** 下载超时自动删除 */ + download_monitor_enabled: boolean + /** 监听手动删除种子 */ + manual_delete_listen: boolean + /** 监听Tracker响应关键字 */ + tracker_response_listen: boolean + /** 删除后触发搜索补全 */ + auto_search_when_delete: boolean + /** 跳过近期删除资源 */ + skip_deletion: boolean + /** 下载超时时间(分钟) */ + download_timeout_minutes: number + /** 下载超时进度阈值 */ + download_progress_threshold: number + /** 下载排队宽限倍数 */ + download_queue_grace_multiplier: number + /** 下载连续超时重试次数 */ + download_retry_limit: number + /** 排除标签 */ + delete_exclude_tags: string + /** Tracker响应关键字 */ + default_tracker_response: string + /** 删除记录保留(小时) */ + delete_record_retention_hours: number + /** 清理整理记录范围 */ + subscription_cleanup_history_type: string + /** 清理整理记录场景 */ + subscription_cleanup_history_scenes: string[] + /** 识别增强模式 */ + recognition_guard_mode: string + /** 识别增强通知 */ + recognition_guard_notify: string + /** 识别增强通知限频(秒) */ + recognition_guard_notify_interval: number + /** 识别增强二次识别 */ + recognition_guard_tmdb_recheck_mode: string + /** 识别增强缓存大小 */ + recognition_guard_cache_maxsize: number + /** 自定义识别规则 */ + recognition_guard_custom_config: string + /** 自动待定剧集订阅 */ + pending_enhanced_enabled: boolean + /** 自动待定下载中订阅 */ + pending_download_enabled: boolean + /** 剧集待定天数 */ + auto_tv_pending_days: number + /** 剧集待定集数 */ + auto_tv_pending_episodes: number + /** 待定参考变更速率 */ + pending_use_volatility: boolean + /** 自动暂停订阅 */ + pause_enhanced_enabled: boolean + /** 自动暂停新增订阅的用户(逗号分隔) */ + auto_pause_users: string + /** 即将播出暂停天数 */ + airing_pause_days: number + /** 电影上映暂停天数 */ + movie_air_pause_days: number + /** 剧集上映暂停天数 */ + tv_air_pause_days: number + /** 电影无下载处理天数 */ + movie_no_download_days: number + /** 剧集无下载处理天数 */ + tv_no_download_days: number + /** 无下载处理策略 */ + no_download_actions: string[] + /** 站点集数探测 */ + site_total_probe_enabled: boolean + /** 暂停订阅补搜场景 */ + paused_probe_reasons: string[] + /** 暂停满N天后补搜 */ + paused_probe_min_pause_days: number + /** 补搜间隔(小时) */ + paused_probe_interval_hours: number + /** 洗版类型 */ + best_version_type: string + /** 电影洗版时限(天) */ + best_version_movie_remaining_days: number + /** 剧集洗版时限(天) */ + best_version_tv_remaining_days: number + /** 分集转全集 */ + best_version_episode_to_full: boolean + /** 回填已存在集 */ + best_version_backfill_enabled: boolean + /** 立即扫描存量并回填 */ + backfill_best_version_now: boolean + /** 完结守卫模式 */ + completion_guard_mode: string + /** 站点完结信号 */ + site_completion_evidence_enabled: boolean + /** 变更速率信号 */ + volatility_enabled: boolean + /** 变更速率窗口(天) */ + volatility_window_days: number + /** 播出节奏信号 */ + cadence_enabled: boolean + /** 节奏窗口系数 */ + cadence_multiplier: number + /** 节奏窗口下限(天) */ + cadence_min_window_days: number + /** 节奏参与最少集数 */ + cadence_min_episodes: number + /** 季冷却期(天) */ + season_cooldown_days: number + /** 自动纠错 */ + verify_enabled: boolean + /** 自动纠错间隔(小时) */ + verify_interval_hours: number + /** 快照保留(天) */ + verify_retention_days: number + /** 完成前观察天数 */ + timeout_release_days: number + /** 按节奏加速释放 */ + timeout_cadence_acceleration: boolean +} + +/** 所有可持久化配置键。 */ +export type ConfigKey = keyof SaeConfig + +/** 布尔配置键,用于约束开关类预览规则。 */ +export type BooleanConfigKey = { + [K in ConfigKey]: SaeConfig[K] extends boolean ? K : never +}[ConfigKey] + +/** 数值配置键,用于约束动态数值字段写回。 */ +export type NumberConfigKey = { + [K in ConfigKey]: SaeConfig[K] extends number ? K : never +}[ConfigKey] +/** 新配置与缺失字段回填使用的完整默认值。 */ +export const configDefaults: SaeConfig = { + "enabled": false, + "notify": true, + "onlyonce": false, + "reset_task": false, + "auto_check_interval_minutes": 30, + "download_check_interval_minutes": 10, + "meta_check_interval_hours": 3, + "best_version_cron": "0 15 * * *", + "download_monitor_enabled": true, + "manual_delete_listen": true, + "tracker_response_listen": true, + "auto_search_when_delete": true, + "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", + "delete_record_retention_hours": 24, + "subscription_cleanup_history_type": "no", + "subscription_cleanup_history_scenes": [], + "recognition_guard_mode": "off", + "recognition_guard_notify": "off", + "recognition_guard_notify_interval": 3600, + "recognition_guard_tmdb_recheck_mode": "balanced_strict", + "recognition_guard_cache_maxsize": 100000, + "recognition_guard_custom_config": + "####### 配置说明 BEGIN #######\n# 1. 本配置只控制识别增强的策略覆盖和关键词,不控制通知、二次识别触发或缓存大小。\n# 2. 未配置或保持注释的项目均继承 recognition_guard_mode 当前模板。\n# 3. actions 的值可选:inherit / observe / soft_block / block:\n# - inherit:继承当前 recognition_guard_mode 模板,不单独覆盖。\n# - observe:只记录审计和可选通知,不移除候选,下载选择不受影响。\n# - soft_block:先从候选池移除;如果整轮候选被清空,且 empty_pool 策略允许,该候选可降级为 observe 恢复。\n# - block:从候选池移除,集合级保护也不得恢复;用于用户明确不想下载的风险。\n# 4. allow 只能抵消非 hard veto 风险;不能覆盖显式 ID 错配、明确类型/形态互串、目标范围完全不覆盖等 hard veto。\n# 5. block 是普通黑名单风险,动作由 mode 或 actions.user_block 决定;hard_block 才是一律强拦截。\n# 6. 正则使用 Python re 语法;非法正则会跳过对应条目并记录配置告警,不影响其他规则。\n# 7. keywords 下的内置证据词分组如果取消注释配置,表示替换该分组;未配置的分组继续使用内置默认。\n####### 配置说明 END #######\n\nactions:\n # 候选缺少年份。多站点用户可改为 block,少站点用户建议 inherit 或 observe。\n # missing_year: block\n\n # 候选全集范围明显大于目标窗口,例如目标缺 E08-E19,候选是全 60 集。\n # target_range_oversized: block\n\n # 命中 keywords.block 时的动作。\n # user_block: soft_block\n\n # 二次识别结果与订阅目标不一致。\n # secondary_identity_conflict: block\n\nempty_pool:\n # 整轮候选被识别增强清空时的恢复策略:recover_soft_block / never_recover。\n # policy: recover_soft_block\n\n # 即使动作是 soft_block,也不允许因整轮候选清空而恢复的原因码。\n # non_recoverable_codes:\n # - target_range_oversized\n # - missing_year\n\nkeywords:\n # 白名单:只抵消非 hard veto 风险。\n # allow:\n # - 官方合集\n\n # 普通黑名单:动作由 mode 或 actions.user_block 决定。\n # block:\n # - 低可信风险词\n\n # 强黑名单:所有启用模式下 hard veto;audit 只记录 would block。\n # hard_block:\n # - 强制错误词\n\n # 以下是内置证据词分组;如需覆盖某一组,取消注释并完整写出该组。\n # live_action:\n # - 真人版\n # - 电视剧版\n # - 实拍版\n # - 真人剧\n # animation:\n # - 动画\n # - 动漫\n # - 国漫\n # - 番剧\n # movie:\n # - 电影版\n # - 剧场版\n # - 劇場版\n # - '\\bMovie\\b'\n # tv:\n # - '\\bS\\d{1,3}(?:E\\d{1,4})?\\b'\n # - '第\\s*\\d+\\s*[集季]'\n # - '全\\s*\\d+\\s*集'\n", + "pending_enhanced_enabled": true, + "pending_download_enabled": true, + "auto_tv_pending_days": 0, + "auto_tv_pending_episodes": 1, + "pending_use_volatility": false, + "pause_enhanced_enabled": false, + "auto_pause_users": "", + "airing_pause_days": 30, + "movie_air_pause_days": 7, + "tv_air_pause_days": 14, + "movie_no_download_days": 365, + "tv_no_download_days": 180, + "no_download_actions": [], + "site_total_probe_enabled": false, + "paused_probe_reasons": ["no_download"], + "paused_probe_min_pause_days": 14, + "paused_probe_interval_hours": 72, + "best_version_type": "no", + "best_version_movie_remaining_days": 0, + "best_version_tv_remaining_days": 0, + "best_version_episode_to_full": false, + "best_version_backfill_enabled": false, + "backfill_best_version_now": false, + "completion_guard_mode": "balanced", + "site_completion_evidence_enabled": true, + "volatility_enabled": true, + "volatility_window_days": 3, + "cadence_enabled": true, + "cadence_multiplier": 2.5, + "cadence_min_window_days": 7, + "cadence_min_episodes": 3, + "season_cooldown_days": 14, + "verify_enabled": false, + "verify_interval_hours": 12, + "verify_retention_days": 180, + "timeout_release_days": 7, + "timeout_cadence_acceleration": true +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/draft.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/draft.ts new file mode 100644 index 00000000..0b7e50fc --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/draft.ts @@ -0,0 +1,22 @@ +import { computed, reactive } from 'vue' + +import { type ConfigKey, type SaeConfig } from './defaults' +import { normalizeSaeConfig } from './values' + +/** 统一管理 Host 初始配置、界面修改计数与完整保存 payload。 */ +export function useConfigDraft(initialConfig: unknown) { + const initialSnapshot = normalizeSaeConfig(initialConfig) + const draft = reactive(structuredClone(initialSnapshot)) + const configKeys = Object.keys(initialSnapshot) as ConfigKey[] + const changedKeys = computed(() => + configKeys.filter(key => JSON.stringify(draft[key]) !== JSON.stringify(initialSnapshot[key])), + ) + const changedCount = computed(() => changedKeys.value.length) + + /** 按稳定键和默认类型重建 Host 需要的完整配置对象。 */ + function buildSavePayload(): SaeConfig { + return normalizeSaeConfig(draft) + } + + return { draft, changedCount, changedKeys, buildSavePayload } +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/fields.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/fields.ts new file mode 100644 index 00000000..1f93f320 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/fields.ts @@ -0,0 +1,873 @@ +import { type ConfigKey } from "./defaults" + +/** Vue 字段对应的 Vuetify 控件类别。 */ +export type FieldKind = "switch" | "number" | "text" | "select" | "multi-select" | "cron" | "textarea" +/** 配置项对订阅生命周期和数据的影响等级。 */ +export type RiskLevel = "none" | "notice" | "danger" + +/** 下拉或多选项的展示值与持久化值。 */ +export interface FieldOption { + /** 用户可见名称。 */ + title: string + /** 写入插件配置的稳定值。 */ + value: string | number +} + +/** 配置页业务分组键。 */ +export type GroupKey = + "global" | "cleanup" | "pending" | "pause" | "completion" | "bestVersion" | "guard" | "recognition" + +/** 单个配置字段的渲染与风险元数据。 */ +export interface FieldMeta { + /** 与 PluginConfig.defaults() 一致的持久化键。 */ + key: ConfigKey + /** 来自现有 Form/README 契约的中文名称。 */ + label: string + /** 字段所属配置分组。 */ + group: GroupKey + /** 字段使用的 Vuetify 控件类别。 */ + kind: FieldKind + /** 来自现有 Form 的简短说明。 */ + hint?: string + /** 在窄屏或默认视图中可折叠。 */ + advanced?: boolean + /** 用于影响提示的风险等级。 */ + risk?: RiskLevel + /** select 与 multi-select 的稳定候选值。 */ + options?: FieldOption[] + /** 仅保留在完整保存 payload 中,不渲染为 Vue 控件。 */ + legacyUiKey?: boolean + /** 只在专用弹窗中编辑,不进入普通字段列表。 */ + dialogOnly?: boolean +} + +/** 左侧业务分组导航与摘要元数据。 */ +export interface GroupMeta { + /** 与 FieldMeta.group 对齐的稳定分组键。 */ + key: GroupKey + /** 用户可见分组名。 */ + title: string + /** MoviePilot 已提供的 Material Design 图标名。 */ + icon: string + /** 分组涉及的业务范围摘要。 */ + summary: string + /** 分组是否包含明显破坏性配置。 */ + highRisk?: boolean +} + +export const groups: GroupMeta[] = [ + { key: "global", title: "全局运行", icon: "mdi-tune-variant", summary: "插件开关、通知、一次性动作与公共周期" }, + { + key: "cleanup", + title: "订阅清理", + icon: "mdi-delete-sweep-outline", + summary: "下载监控、删种、Tracker 与整理记录清理", + highRisk: true + }, + { key: "pending", title: "订阅待定", icon: "mdi-timer-sand", summary: "下载中与剧集目标未稳定时保持待定" }, + { + key: "pause", + title: "订阅暂停", + icon: "mdi-pause-circle-outline", + summary: "按用户、上映播出窗口和无下载策略暂停订阅" + }, + { key: "completion", title: "订阅补全", icon: "mdi-radar", summary: "站点集数探测与暂停订阅补搜" }, + { + key: "bestVersion", + title: "订阅洗版", + icon: "mdi-auto-fix", + summary: "洗版范围、时限、回填和分集转全集", + highRisk: true + }, + { + key: "guard", + title: "完结信号", + icon: "mdi-shield-check-outline", + summary: "完结守卫、站点证据、波动节奏和自动纠错" + }, + { + key: "recognition", + title: "识别增强", + icon: "mdi-account-search-outline", + summary: "候选准入、通知、二次识别和自定义策略" + } +] + +export const fields: FieldMeta[] = [ + { + "key": "enabled", + "label": "启用插件", + "group": "global", + "kind": "switch", + "hint": "开启后插件将处于激活状态" + }, + { + "key": "notify", + "label": "发送通知", + "group": "global", + "kind": "switch", + "hint": "是否在特定事件发生时发送通知" + }, + { + "key": "onlyonce", + "label": "立即运行一次", + "group": "global", + "kind": "switch", + "hint": "保存后立即运行一次全量巡检,执行后自动复位" + }, + { + "key": "reset_task", + "label": "重置数据", + "group": "global", + "kind": "switch", + "hint": "保存后将重置所有待定/暂停/监控等任务数据,执行后自动复位", + "risk": "danger" + }, + { + "key": "auto_check_interval_minutes", + "label": "通用巡检周期(分钟)", + "group": "global", + "kind": "select", + "hint": "站点采样、待定释放、无下载处理和清理周期", + "options": [ + { + "title": "10分钟", + "value": 10 + }, + { + "title": "20分钟", + "value": 20 + }, + { + "title": "30分钟", + "value": 30 + }, + { + "title": "60分钟", + "value": 60 + }, + { + "title": "120分钟", + "value": 120 + }, + { + "title": "240分钟", + "value": 240 + } + ], + "advanced": true + }, + { + "key": "download_check_interval_minutes", + "label": "下载检查周期(分钟)", + "group": "global", + "kind": "select", + "hint": "下载检查的周期,定时检查下载任务状态", + "options": [ + { + "title": "5分钟", + "value": 5 + }, + { + "title": "10分钟", + "value": 10 + }, + { + "title": "15分钟", + "value": 15 + }, + { + "title": "30分钟", + "value": 30 + }, + { + "title": "60分钟", + "value": 60 + }, + { + "title": "120分钟", + "value": 120 + } + ], + "advanced": true + }, + { + "key": "meta_check_interval_hours", + "label": "元数据检查周期(小时)", + "group": "global", + "kind": "select", + "hint": "元数据检查的周期,定时复核订阅元数据状态", + "options": [ + { + "title": "1小时", + "value": 1 + }, + { + "title": "3小时", + "value": 3 + }, + { + "title": "6小时", + "value": 6 + }, + { + "title": "12小时", + "value": 12 + }, + { + "title": "24小时", + "value": 24 + } + ], + "advanced": true + }, + { + "key": "best_version_cron", + "label": "洗版检查周期", + "group": "global", + "kind": "cron", + "hint": "洗版检查的周期,如 0 15 * * *" + }, + { + "key": "download_monitor_enabled", + "label": "下载超时自动删除", + "group": "cleanup", + "kind": "switch", + "hint": "订阅下载超时将自动删除种子", + "risk": "danger" + }, + { + "key": "manual_delete_listen", + "label": "监听手动删除种子", + "group": "cleanup", + "kind": "switch", + "hint": "监听用户手动删除的种子记录", + "risk": "danger" + }, + { + "key": "tracker_response_listen", + "label": "监听Tracker响应关键字", + "group": "cleanup", + "kind": "switch", + "hint": "命中Tracker响应关键字时将自动删除种子", + "risk": "danger" + }, + { + "key": "auto_search_when_delete", + "label": "删除后触发搜索补全", + "group": "cleanup", + "kind": "switch", + "hint": "删种后将自动触发搜索补全" + }, + { + "key": "skip_deletion", + "label": "跳过近期删除资源", + "group": "cleanup", + "kind": "switch", + "hint": "跳过最近删除的种子,避免再次下载" + }, + { + "key": "download_timeout_minutes", + "label": "下载超时时间(分钟)", + "group": "cleanup", + "kind": "number", + "hint": "作为下载进度观察窗口,窗口内进度增长低于阈值时视为超时", + "advanced": true + }, + { + "key": "download_progress_threshold", + "label": "下载超时进度阈值", + "group": "cleanup", + "kind": "number", + "hint": "超时窗口内下载进度增长低于N%时才删除", + "advanced": true + }, + { + "key": "download_queue_grace_multiplier", + "label": "下载排队宽限倍数", + "group": "cleanup", + "kind": "number", + "hint": "排队状态额外宽限N个超时窗口,0表示不宽限" + }, + { + "key": "download_retry_limit", + "label": "下载连续超时重试次数", + "group": "cleanup", + "kind": "number", + "hint": "连续低进度超时N次后保留种子并通知", + "advanced": true + }, + { + "key": "delete_exclude_tags", + "label": "排除标签", + "group": "cleanup", + "kind": "text", + "hint": "需要排除的标签,多个标签用逗号分隔" + }, + { + "key": "default_tracker_response", + "label": "Tracker响应关键字", + "group": "cleanup", + "kind": "textarea", + "hint": "每一行一个关键字,忽略大小写,支持正则表达式匹配", + "dialogOnly": true, + "advanced": true + }, + { + "key": "delete_record_retention_hours", + "label": "删除记录保留(小时)", + "group": "cleanup", + "kind": "number", + "hint": "定时清理N小时前的删除记录", + "advanced": true + }, + { + "key": "subscription_cleanup_history_type", + "label": "清理整理记录范围", + "group": "cleanup", + "kind": "select", + "hint": "订阅下载前清理旧整理记录、源文件和入库前目标文件的媒体类型范围(破坏性)", + "options": [ + { + "title": "关闭", + "value": "no" + }, + { + "title": "全部", + "value": "all" + }, + { + "title": "电影", + "value": "movie" + }, + { + "title": "剧集", + "value": "tv" + } + ], + "risk": "danger" + }, + { + "key": "subscription_cleanup_history_scenes", + "label": "清理整理记录场景", + "group": "cleanup", + "kind": "multi-select", + "hint": "选择普通订阅、洗版订阅或分集洗版下载时触发订阅清理", + "options": [ + { + "title": "普通订阅", + "value": "normal" + }, + { + "title": "洗版订阅", + "value": "best_version" + }, + { + "title": "分集洗版", + "value": "best_version_episode" + } + ], + "risk": "danger" + }, + { + "key": "recognition_guard_mode", + "label": "识别增强模式", + "group": "recognition", + "kind": "select", + "hint": "在自动下载前复核订阅候选是否像当前订阅目标", + "options": [ + { + "title": "关闭", + "value": "off" + }, + { + "title": "审计", + "value": "audit" + }, + { + "title": "宽松", + "value": "loose" + }, + { + "title": "平衡", + "value": "balanced" + }, + { + "title": "严格", + "value": "strict" + } + ], + "risk": "danger" + }, + { + "key": "recognition_guard_notify", + "label": "识别增强通知", + "group": "recognition", + "kind": "select", + "hint": "控制识别增强消息推送,不影响审计日志", + "options": [ + { + "title": "关闭", + "value": "off" + }, + { + "title": "摘要", + "value": "summary" + }, + { + "title": "明细", + "value": "detail" + }, + { + "title": "全部", + "value": "all" + } + ] + }, + { + "key": "recognition_guard_notify_interval", + "label": "识别增强通知限频(秒)", + "group": "recognition", + "kind": "number", + "hint": "同订阅同动作同原因的通知限频秒数", + "advanced": true + }, + { + "key": "recognition_guard_tmdb_recheck_mode", + "label": "识别增强二次识别", + "group": "recognition", + "kind": "select", + "hint": "控制二次识别触发范围", + "options": [ + { + "title": "关闭", + "value": "off" + }, + { + "title": "全部", + "value": "all" + }, + { + "title": "严格", + "value": "strict" + }, + { + "title": "平衡和严格", + "value": "balanced_strict" + } + ] + }, + { + "key": "recognition_guard_cache_maxsize", + "label": "识别增强缓存大小", + "group": "recognition", + "kind": "number", + "hint": "缓存二次识别结果,避免重复识别", + "advanced": true + }, + { + "key": "recognition_guard_custom_config", + "label": "自定义识别规则", + "group": "recognition", + "kind": "textarea", + "hint": "仅在内置规则无法满足时编辑,留空则继承当前模式", + "risk": "danger" + }, + { + "key": "pending_enhanced_enabled", + "label": "自动待定剧集订阅", + "group": "pending", + "kind": "switch", + "hint": "自动标记订阅剧集为待定状态,避免提前完成订阅" + }, + { + "key": "pending_download_enabled", + "label": "自动待定下载中订阅", + "group": "pending", + "kind": "switch", + "hint": "存在进行中下载时自动标记待定,避免提前完成订阅" + }, + { + "key": "auto_tv_pending_days", + "label": "剧集待定天数", + "group": "pending", + "kind": "number", + "hint": "当前日期小于上映日期加N天,则视为待定,为0时不处理", + "advanced": true + }, + { + "key": "auto_tv_pending_episodes", + "label": "剧集待定集数", + "group": "pending", + "kind": "number", + "hint": "剧集数小于等于设置的集数,则视为待定,为0时不处理" + }, + { + "key": "pending_use_volatility", + "label": "待定参考变更速率", + "group": "pending", + "kind": "switch", + "hint": "接近完结且总集数变化时提前待定" + }, + { + "key": "pause_enhanced_enabled", + "label": "自动暂停订阅", + "group": "pause", + "kind": "switch", + "hint": "自动标记订阅为暂停状态,避免无意义的请求" + }, + { + "key": "auto_pause_users", + "label": "自动暂停新增订阅的用户(逗号分隔)", + "group": "pause", + "kind": "text", + "hint": "名单内用户新增订阅时将自动暂停,多个用户用逗号分隔,为空时不启用" + }, + { + "key": "airing_pause_days", + "label": "即将播出暂停天数", + "group": "pause", + "kind": "number", + "hint": "已存在最新播出集,且下集距当前日期大于N天,则视为暂停,为0时不处理", + "advanced": true + }, + { + "key": "movie_air_pause_days", + "label": "电影上映暂停天数", + "group": "pause", + "kind": "number", + "hint": "当前日期小于上映日期减N天,则视为暂停,为0时不处理", + "advanced": true + }, + { + "key": "tv_air_pause_days", + "label": "剧集上映暂停天数", + "group": "pause", + "kind": "number", + "hint": "当前日期小于开播日期减N天,则视为暂停,为0时不处理", + "advanced": true + }, + { + "key": "movie_no_download_days", + "label": "电影无下载处理天数", + "group": "pause", + "kind": "number", + "hint": "电影上映后N天内无新的订阅下载,则按策略处理,为0时不处理", + "advanced": true + }, + { + "key": "tv_no_download_days", + "label": "剧集无下载处理天数", + "group": "pause", + "kind": "number", + "hint": "剧集上映后N天内无新的订阅下载,则按策略处理,为0时不处理", + "advanced": true + }, + { + "key": "no_download_actions", + "label": "无下载处理策略", + "group": "pause", + "kind": "multi-select", + "hint": "选择无下载时的处理策略", + "options": [ + { + "title": "暂停电影订阅", + "value": "pause_movie" + }, + { + "title": "暂停剧集订阅", + "value": "pause_tv" + }, + { + "title": "完成电影订阅", + "value": "complete_movie" + }, + { + "title": "完成剧集订阅", + "value": "complete_tv" + }, + { + "title": "删除电影订阅", + "value": "delete_movie" + }, + { + "title": "删除剧集订阅", + "value": "delete_tv" + } + ], + "risk": "danger" + }, + { + "key": "site_total_probe_enabled", + "label": "站点集数探测", + "group": "completion", + "kind": "switch", + "hint": "用站点缓存资源辅助发现目标集数不足" + }, + { + "key": "paused_probe_reasons", + "label": "暂停订阅补搜场景", + "group": "completion", + "kind": "multi-select", + "hint": "选择允许低频补搜的暂停原因", + "options": [ + { + "title": "无下载", + "value": "no_download" + }, + { + "title": "上映/开播", + "value": "pre_air" + }, + { + "title": "播出间隔", + "value": "airing_gap" + }, + { + "title": "用户名", + "value": "auto_user" + }, + { + "title": "外部暂停", + "value": "external" + }, + { + "title": "全部", + "value": "all" + } + ] + }, + { + "key": "paused_probe_min_pause_days", + "label": "暂停满N天后补搜", + "group": "completion", + "kind": "number", + "hint": "暂停达到天数后开始补搜,0 表示不处理", + "advanced": true + }, + { + "key": "paused_probe_interval_hours", + "label": "补搜间隔(小时)", + "group": "completion", + "kind": "select", + "hint": "同一订阅两次补搜的最小间隔", + "options": [ + { + "title": "24", + "value": 24 + }, + { + "title": "48", + "value": 48 + }, + { + "title": "72", + "value": 72 + }, + { + "title": "96", + "value": 96 + }, + { + "title": "120", + "value": 120 + }, + { + "title": "144", + "value": 144 + } + ], + "advanced": true + }, + { + "key": "best_version_type", + "label": "洗版类型", + "group": "bestVersion", + "kind": "select", + "hint": "选择需要自动洗版的类型,关闭时不自动创建和巡检洗版订阅", + "options": [ + { + "title": "关闭", + "value": "no" + }, + { + "title": "全部", + "value": "all" + }, + { + "title": "电影", + "value": "movie" + }, + { + "title": "剧集", + "value": "tv" + }, + { + "title": "剧集(分集下载)", + "value": "tv_episode" + } + ], + "risk": "danger" + }, + { + "key": "best_version_movie_remaining_days", + "label": "电影洗版时限(天)", + "group": "bestVersion", + "kind": "number", + "hint": "电影洗版订阅达到指定天数后自动终止,有下载则按最新时间计算,为0时不限", + "advanced": true + }, + { + "key": "best_version_tv_remaining_days", + "label": "剧集洗版时限(天)", + "group": "bestVersion", + "kind": "number", + "hint": "剧集洗版订阅达到指定天数后自动终止,有下载则按最新时间计算,为0时不限", + "advanced": true + }, + { + "key": "best_version_episode_to_full", + "label": "分集转全集", + "group": "bestVersion", + "kind": "switch", + "hint": "订阅目标集数满足时,从分集洗版切换为全集洗版", + "risk": "danger" + }, + { + "key": "best_version_backfill_enabled", + "label": "回填已存在集", + "group": "bestVersion", + "kind": "switch", + "hint": "新建或转分集洗版时回填媒体库已有集,避免重复下载" + }, + { + "key": "backfill_best_version_now", + "label": "立即扫描存量并回填", + "group": "bestVersion", + "kind": "switch", + "hint": "保存后对存量分集洗版订阅执行一次回填,执行后自动复位", + "risk": "danger" + }, + { + "key": "completion_guard_mode", + "label": "完结守卫模式", + "group": "guard", + "kind": "select", + "hint": "选择完成前复核强度,默认使用平衡策略", + "options": [ + { + "title": "关闭", + "value": "off" + }, + { + "title": "严格", + "value": "strict" + }, + { + "title": "平衡", + "value": "balanced" + }, + { + "title": "宽松", + "value": "loose" + } + ] + }, + { + "key": "site_completion_evidence_enabled", + "label": "站点完结信号", + "group": "guard", + "kind": "switch", + "hint": "使用站点资源标题佐证完结信号" + }, + { + "key": "volatility_enabled", + "label": "变更速率信号", + "group": "guard", + "kind": "switch", + "hint": "总集数近期变化时视为不稳定" + }, + { + "key": "volatility_window_days", + "label": "变更速率窗口(天)", + "group": "guard", + "kind": "number", + "hint": "统计总集数变化的天数,越长越保守", + "advanced": true + }, + { + "key": "cadence_enabled", + "label": "播出节奏信号", + "group": "guard", + "kind": "switch", + "hint": "按已播间隔判断等待期,不会直接判定完结" + }, + { + "key": "cadence_multiplier", + "label": "节奏窗口系数", + "group": "guard", + "kind": "number", + "hint": "放大预计等待时间,数值越大等待越久" + }, + { + "key": "cadence_min_window_days", + "label": "节奏窗口下限(天)", + "group": "guard", + "kind": "number", + "hint": "预计等待时间不得少于设置天数", + "advanced": true + }, + { + "key": "cadence_min_episodes", + "label": "节奏参与最少集数", + "group": "guard", + "kind": "number", + "hint": "已播集数达到设置值后才计算播出间隔" + }, + { + "key": "season_cooldown_days", + "label": "季冷却期(天)", + "group": "guard", + "kind": "number", + "hint": "最后一集播出后继续观察的天数", + "advanced": true + }, + { + "key": "verify_enabled", + "label": "自动纠错", + "group": "guard", + "kind": "switch", + "hint": "完成后检查集数,增加时自动重建订阅" + }, + { + "key": "verify_interval_hours", + "label": "自动纠错间隔(小时)", + "group": "guard", + "kind": "number", + "hint": "完成后重新检查集数的间隔", + "advanced": true + }, + { + "key": "verify_retention_days", + "label": "快照保留(天)", + "group": "guard", + "kind": "number", + "hint": "完成快照按设置天数保留并自动清理,默认180天", + "advanced": true + }, + { + "key": "timeout_release_days", + "label": "完成前观察天数", + "group": "guard", + "kind": "number", + "hint": "完成前观察允许保留的最长天数", + "advanced": true + }, + { + "key": "timeout_cadence_acceleration", + "label": "按节奏加速释放", + "group": "guard", + "kind": "switch", + "hint": "等待期结束时缩短观察期限" + } +] diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/i18n.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/i18n.ts new file mode 100644 index 00000000..44b0036d --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/i18n.ts @@ -0,0 +1,790 @@ +import { type ConfigKey } from './defaults' +import { fields, groups, type FieldMeta, type GroupKey, type GroupMeta } from './fields' + +/** MoviePilot Host 当前公开支持的语言。 */ +export type SupportedLocale = 'zh-CN' | 'zh-TW' | 'en-US' +export type LocaleSource = unknown | { value?: LocaleSource } +export type TranslationParams = Record + +const supportedLocales = new Set(['zh-CN', 'zh-TW', 'en-US']) + +/** 将 Host locale、ref 或嵌套 ref 规范化为插件支持的语言。 */ +export function normalizeLocale(source: LocaleSource): SupportedLocale { + let current = source + const visited = new Set() + while (current && typeof current === 'object' && 'value' in current) { + if (visited.has(current)) return 'zh-CN' + visited.add(current) + current = (current as { value?: LocaleSource }).value + } + if (typeof current !== 'string') return 'zh-CN' + const normalized = current.trim().replace('_', '-').toLowerCase() + const locale = + normalized === 'zh-cn' ? 'zh-CN' : normalized === 'zh-tw' ? 'zh-TW' : normalized === 'en-us' ? 'en-US' : 'zh-CN' + return supportedLocales.has(locale) ? locale : 'zh-CN' +} + +const messages: Record> = { + 'zh-CN': { + 'config.changedCount': '{count} 项待保存', + 'config.changes': '本次修改', + 'config.moreChanges': '另有 {count} 项', + 'config.runOnce': '运行一次', + 'config.save': '保存修改', + 'config.close': '关闭', + 'config.cadence': '运行节奏', + 'config.generalInspection': '通用巡检', + 'config.downloadInspection': '下载检查', + 'config.metadataInspection': '元数据检查', + 'config.bestVersionInspection': '洗版检查', + 'config.everyMinutes': '每 {value} 分钟', + 'config.everyHours': '每 {value} 小时', + 'config.notScheduled': '未设置', + 'config.activeDomains': '已启用能力', + 'config.help': '插件帮助', + 'config.aboutPlugin': '关于插件', + 'config.aboutDescription': '多场景管理订阅,实现订阅全生命周期管理', + 'config.viewDocs': '查看文档', + 'config.plugin': '插件', + 'config.settings': '插件设置', + 'config.selectGroup': '选择配置分组', + 'config.done': '完成', + 'config.edit': '编辑', + 'config.decrease': '减小{label}', + 'config.increase': '增大{label}', + 'config.editLabel': '编辑{label}', + 'config.yamlTitle': '自定义识别规则', + 'config.runtime': '运行概况', + 'config.runtimeLoading': '正在读取运行概况', + 'config.runtimeUnavailable': '运行概况暂不可用', + 'config.pendingCount': '待定订阅', + 'config.monitoredCount': '下载任务', + 'config.enabled': '启用', + 'config.off': '关闭', + 'config.cronPlaceholder': '5 位 CRON 表达式', + 'config.title': '订阅助手(增强版)', + 'domain.completionGuard': '完结守卫模式', + 'domain.pending': '待定增强', + 'domain.pause': '暂停优化', + 'domain.bestVersion': '自动洗版', + 'domain.download': '下载管理', + 'domain.verify': '完成后验证', + 'domain.siteTotal': '站点集数探测', + 'domain.siteCompletion': '站点完结信号', + 'domain.recognition': '识别增强', + 'section.running': '运行状态', + 'section.oneTime': '一次性动作', + 'section.schedule': '公共周期', + 'section.download': '下载任务处理', + 'section.timeout': '超时与重试', + 'section.cleanup': '订阅记录清理', + 'section.pending': '待定策略', + 'section.tvDecision': '剧集判定', + 'section.autoPause': '自动暂停', + 'section.airing': '上映与播出窗口', + 'section.noDownload': '无下载处理', + 'section.siteProbe': '站点集数探测', + 'section.pausedProbe': '暂停订阅补搜', + 'section.bestVersionScope': '洗版范围', + 'section.backfill': '转换与回填', + 'section.guard': '守卫信号', + 'section.cadence': '播出节奏', + 'section.correction': '纠错与释放', + 'section.recognition': '识别策略', + 'section.custom': '自定义规则', + }, + 'zh-TW': { + 'config.changedCount': '{count} 項待儲存', + 'config.changes': '本次修改', + 'config.moreChanges': '另有 {count} 項', + 'config.runOnce': '執行一次', + 'config.save': '儲存修改', + 'config.close': '關閉', + 'config.cadence': '執行節奏', + 'config.generalInspection': '通用巡檢', + 'config.downloadInspection': '下載檢查', + 'config.metadataInspection': '元資料檢查', + 'config.bestVersionInspection': '洗版檢查', + 'config.everyMinutes': '每 {value} 分鐘', + 'config.everyHours': '每 {value} 小時', + 'config.notScheduled': '未設定', + 'config.activeDomains': '已啟用能力', + 'config.help': '外掛說明', + 'config.aboutPlugin': '關於外掛', + 'config.aboutDescription': '多場景管理訂閱,實現訂閱全生命週期管理', + 'config.viewDocs': '查看文件', + 'config.plugin': '外掛', + 'config.settings': '外掛設定', + 'config.selectGroup': '選擇設定分組', + 'config.done': '完成', + 'config.edit': '編輯', + 'config.decrease': '減少{label}', + 'config.increase': '增加{label}', + 'config.editLabel': '編輯{label}', + 'config.yamlTitle': '自訂識別規則', + 'config.runtime': '執行概況', + 'config.runtimeLoading': '正在讀取執行概況', + 'config.runtimeUnavailable': '執行概況暫不可用', + 'config.pendingCount': '待定訂閱', + 'config.monitoredCount': '下載任務', + 'config.enabled': '啟用', + 'config.off': '關閉', + 'config.cronPlaceholder': '5 位 CRON 表示式', + 'config.title': '訂閱助手(增強版)', + 'domain.completionGuard': '完結守衛模式', + 'domain.pending': '待定增強', + 'domain.pause': '暫停最佳化', + 'domain.bestVersion': '自動洗版', + 'domain.download': '下載管理', + 'domain.verify': '完成後驗證', + 'domain.siteTotal': '站點集數探測', + 'domain.siteCompletion': '站點完結訊號', + 'domain.recognition': '識別增強', + 'section.running': '執行狀態', + 'section.oneTime': '單次操作', + 'section.schedule': '共用週期', + 'section.download': '下載任務處理', + 'section.timeout': '逾時與重試', + 'section.cleanup': '訂閱記錄清理', + 'section.pending': '待定策略', + 'section.tvDecision': '影集判定', + 'section.autoPause': '自動暫停', + 'section.airing': '上映與播出窗口', + 'section.noDownload': '無下載處理', + 'section.siteProbe': '站點集數探測', + 'section.pausedProbe': '暫停訂閱補搜', + 'section.bestVersionScope': '洗版範圍', + 'section.backfill': '轉換與回填', + 'section.guard': '守衛訊號', + 'section.cadence': '播出節奏', + 'section.correction': '修正與釋放', + 'section.recognition': '識別策略', + 'section.custom': '自訂規則', + }, + 'en-US': { + 'config.changedCount': '{count} to save', + 'config.changes': 'Changes', + 'config.moreChanges': '{count} more', + 'config.runOnce': 'Run once', + 'config.save': 'Save changes', + 'config.close': 'Close', + 'config.cadence': 'Run cadence', + 'config.generalInspection': 'General inspection', + 'config.downloadInspection': 'Download checks', + 'config.metadataInspection': 'Metadata checks', + 'config.bestVersionInspection': 'Best-version checks', + 'config.everyMinutes': 'Every {value} min', + 'config.everyHours': 'Every {value} hr', + 'config.notScheduled': 'Not set', + 'config.activeDomains': 'Active capabilities', + 'config.help': 'Plugin help', + 'config.aboutPlugin': 'About plugin', + 'config.aboutDescription': 'Manage subscriptions across scenarios and throughout their lifecycle', + 'config.viewDocs': 'View docs', + 'config.plugin': 'Plugins', + 'config.settings': 'Plugin settings', + 'config.selectGroup': 'Select settings group', + 'config.done': 'Done', + 'config.edit': 'Edit', + 'config.decrease': 'Decrease {label}', + 'config.increase': 'Increase {label}', + 'config.editLabel': 'Edit {label}', + 'config.yamlTitle': 'Custom recognition rules', + 'config.runtime': 'Runtime summary', + 'config.runtimeLoading': 'Loading runtime summary', + 'config.runtimeUnavailable': 'Runtime summary unavailable', + 'config.pendingCount': 'Pending subscriptions', + 'config.monitoredCount': 'Downloads', + 'config.enabled': 'Enabled', + 'config.off': 'Off', + 'config.cronPlaceholder': '5-field CRON expression', + 'config.title': 'Subscribe Assistant (Enhanced)', + 'domain.completionGuard': 'Completion guard mode', + 'domain.pending': 'Pending enhancement', + 'domain.pause': 'Pause optimization', + 'domain.bestVersion': 'Automatic upgrades', + 'domain.download': 'Download management', + 'domain.verify': 'Post-completion verification', + 'domain.siteTotal': 'Site episode probe', + 'domain.siteCompletion': 'Site completion signal', + 'domain.recognition': 'Recognition', + 'section.running': 'Runtime state', + 'section.oneTime': 'One-time actions', + 'section.schedule': 'Shared schedules', + 'section.download': 'Download handling', + 'section.timeout': 'Timeouts and retries', + 'section.cleanup': 'Subscription cleanup', + 'section.pending': 'Pending policy', + 'section.tvDecision': 'TV decisions', + 'section.autoPause': 'Automatic pause', + 'section.airing': 'Release and airing windows', + 'section.noDownload': 'No-download handling', + 'section.siteProbe': 'Site episode probe', + 'section.pausedProbe': 'Paused subscription search', + 'section.bestVersionScope': 'Best-version scope', + 'section.backfill': 'Conversion and backfill', + 'section.guard': 'Guard signals', + 'section.cadence': 'Airing cadence', + 'section.correction': 'Correction and release', + 'section.recognition': 'Recognition policy', + 'section.custom': 'Custom rules', + }, +} + +/** 使用稳定 UI key 翻译插件文案;缺键直接报错以阻止静默漏翻。 */ +export function t(localeSource: LocaleSource, key: string, params: TranslationParams = {}): string { + const locale = normalizeLocale(localeSource) + const template = messages[locale][key] ?? messages['zh-CN'][key] + if (!template) throw new Error(`Missing translation key: ${key}`) + return template.replace(/\{(\w+)\}/g, (match, name: string) => + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match, + ) +} + +const groupTranslations: Record = { + global: { + tw: ['全域執行', '外掛開關、通知、單次操作與共用週期'], + en: ['General', 'Plugin state, notifications, one-time actions, and shared schedules'], + }, + cleanup: { + tw: ['訂閱清理', '下載監控、刪除種子、Tracker 與整理記錄清理'], + en: ['Cleanup', 'Download monitoring, torrent removal, Tracker rules, and history cleanup'], + }, + pending: { + tw: ['訂閱待定', '下載中或集數目標尚未穩定時保持待定'], + en: ['Pending', 'Keep subscriptions pending while downloads or episode targets are unsettled'], + }, + pause: { + tw: ['訂閱暫停', '依使用者、播出窗口與無下載策略暫停訂閱'], + en: ['Pause', 'Pause subscriptions by user, release window, or no-download policy'], + }, + completion: { + tw: ['訂閱補全', '站點集數探測與暫停訂閱補搜'], + en: ['Completion', 'Site episode probes and paused subscription searches'], + }, + bestVersion: { + tw: ['訂閱洗版', '洗版範圍、時限、回填與分集轉全集'], + en: ['Best version', 'Upgrade scope, time limits, backfill, and episode-to-season conversion'], + }, + guard: { + tw: ['完結訊號', '完結守衛、站點證據、波動節奏與自動修正'], + en: ['Completion guard', 'Completion checks, site evidence, cadence, and automatic correction'], + }, + recognition: { + tw: ['識別增強', '候選准入、通知、二次識別與自訂策略'], + en: ['Recognition', 'Candidate checks, notifications, re-identification, and custom policies'], + }, +} + +type EnglishFieldText = readonly [label: string, hint: string] + +const englishFields: Record = { + enabled: ['Enable plugin', 'Activate the plugin and register its scheduled tasks'], + notify: ['Send notifications', 'Send notifications when relevant events occur'], + onlyonce: ['Run once now', 'Run a full inspection after saving, then reset automatically'], + reset_task: [ + 'Reset data', + 'Reset all pending, paused, and monitored task data after saving, then reset automatically', + ], + auto_check_interval_minutes: [ + 'General check interval (minutes)', + 'Interval for site sampling, pending release, no-download handling, and cleanup', + ], + download_check_interval_minutes: ['Download check interval (minutes)', 'How often download task status is checked'], + meta_check_interval_hours: ['Metadata check interval (hours)', 'How often subscription metadata is reviewed'], + best_version_cron: ['Best-version schedule', 'CRON schedule for best-version checks, for example 0 15 * * *'], + download_monitor_enabled: ['Remove stalled downloads', 'Automatically remove subscription torrents that time out'], + manual_delete_listen: ['Watch manual torrent removal', 'Record torrents manually removed by the user'], + tracker_response_listen: [ + 'Watch Tracker response keywords', + 'Remove torrents when a configured Tracker response keyword matches', + ], + auto_search_when_delete: ['Search after removal', 'Trigger a completion search after removing a torrent'], + 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', + ], + delete_record_retention_hours: [ + 'Removal history retention (hours)', + 'Periodically remove deletion records older than N hours', + ], + subscription_cleanup_history_type: [ + 'Cleanup media scope', + 'Media types whose old transfer records and files are removed before download', + ], + subscription_cleanup_history_scenes: [ + 'Cleanup trigger scenarios', + 'Choose which subscription download scenarios trigger cleanup', + ], + recognition_guard_mode: [ + 'Recognition mode', + 'Review whether a candidate matches the subscription target before automatic download', + ], + recognition_guard_notify: ['Recognition notifications', 'Control recognition messages without affecting audit logs'], + recognition_guard_notify_interval: [ + 'Notification rate limit (seconds)', + 'Minimum interval for the same subscription, action, and reason', + ], + recognition_guard_tmdb_recheck_mode: ['Secondary recognition', 'Control when secondary recognition is performed'], + recognition_guard_cache_maxsize: [ + 'Recognition cache size', + 'Cache secondary recognition results to avoid duplicate requests', + ], + recognition_guard_custom_config: [ + 'Custom recognition rules', + 'Edit only when built-in rules are insufficient; leave empty to inherit the current mode', + ], + pending_enhanced_enabled: [ + 'Automatically pend TV subscriptions', + 'Mark TV subscriptions pending to avoid completing them too early', + ], + pending_download_enabled: ['Pend active downloads', 'Keep subscriptions pending while downloads are in progress'], + auto_tv_pending_days: ['TV pending days', 'Keep pending before the release date plus N days; 0 disables this rule'], + auto_tv_pending_episodes: [ + 'TV pending episode count', + 'Keep pending when the episode count is at or below this value; 0 disables this rule', + ], + pending_use_volatility: [ + 'Use change rate for pending', + 'Pend early when the total episode count changes near completion', + ], + pause_enhanced_enabled: ['Automatically pause subscriptions', 'Pause subscriptions to avoid unnecessary requests'], + auto_pause_users: [ + 'Auto-pause users (comma-separated)', + 'Pause new subscriptions from listed users; leave empty to disable', + ], + airing_pause_days: [ + 'Upcoming episode pause days', + 'Pause when the next episode is more than N days away; 0 disables this rule', + ], + movie_air_pause_days: [ + 'Movie release pause days', + 'Pause until N days before the movie release date; 0 disables this rule', + ], + tv_air_pause_days: ['TV premiere pause days', 'Pause until N days before the TV premiere date; 0 disables this rule'], + movie_no_download_days: [ + 'Movie no-download days', + 'Apply the selected policy when no movie download occurs within N days; 0 disables it', + ], + tv_no_download_days: [ + 'TV no-download days', + 'Apply the selected policy when no TV download occurs within N days; 0 disables it', + ], + no_download_actions: ['No-download actions', 'Choose the actions to apply when no download is found'], + site_total_probe_enabled: [ + 'Probe site episode totals', + 'Use cached site releases to detect an incomplete episode target', + ], + paused_probe_reasons: ['Paused search scenarios', 'Choose pause reasons that allow low-frequency searches'], + paused_probe_min_pause_days: [ + 'Search after N paused days', + 'Start searching after this many paused days; 0 disables it', + ], + paused_probe_interval_hours: [ + 'Search interval (hours)', + 'Minimum interval between two searches for the same subscription', + ], + best_version_type: [ + 'Best-version type', + 'Select media types for automatic upgrades; Off disables creation and checks', + ], + best_version_movie_remaining_days: [ + 'Movie upgrade time limit (days)', + 'Stop movie upgrade subscriptions after this period; 0 means unlimited', + ], + best_version_tv_remaining_days: [ + 'TV upgrade time limit (days)', + 'Stop TV upgrade subscriptions after this period; 0 means unlimited', + ], + best_version_episode_to_full: [ + 'Convert episodes to full season', + 'Switch from episode upgrades to a full-season upgrade when the target is met', + ], + best_version_backfill_enabled: [ + 'Backfill existing episodes', + 'Backfill library episodes when creating or converting an episode upgrade', + ], + backfill_best_version_now: [ + 'Scan and backfill now', + 'Backfill existing episode-upgrade subscriptions after saving, then reset automatically', + ], + completion_guard_mode: [ + 'Completion guard mode', + 'Choose the review strength used before completion; Balanced is the default', + ], + site_completion_evidence_enabled: [ + 'Use site completion evidence', + 'Use site release titles as supporting completion evidence', + ], + volatility_enabled: ['Episode-count change signal', 'Treat recent total episode count changes as unstable'], + volatility_window_days: ['Change-rate window (days)', 'Number of days used to measure total episode count changes'], + cadence_enabled: [ + 'Airing cadence signal', + 'Estimate the waiting period from airing intervals without directly marking completion', + ], + cadence_multiplier: ['Cadence window multiplier', 'Increase the estimated waiting period; higher values wait longer'], + cadence_min_window_days: [ + 'Minimum cadence window (days)', + 'The estimated waiting period cannot be shorter than this value', + ], + cadence_min_episodes: [ + 'Minimum episodes for cadence', + 'Calculate airing intervals only after this many episodes have aired', + ], + season_cooldown_days: ['Season cooldown (days)', 'Continue observing for this many days after the last episode airs'], + verify_enabled: [ + 'Automatic correction', + 'Recheck completed episode counts and rebuild subscriptions when the count increases', + ], + verify_interval_hours: ['Correction interval (hours)', 'Interval for rechecking episode counts after completion'], + verify_retention_days: [ + 'Snapshot retention (days)', + 'Retain completion snapshots for this many days; default is 180', + ], + timeout_release_days: [ + 'Pre-completion observation days', + 'Maximum number of days allowed for pre-completion observation', + ], + timeout_cadence_acceleration: [ + 'Accelerate release by cadence', + 'Shorten the observation period after the cadence waiting window ends', + ], +} + +const traditionalPhrases: Array<[string, string]> = [ + ['插件', '外掛'], + ['启用', '啟用'], + ['发送', '傳送'], + ['通知', '通知'], + ['运行', '執行'], + ['重置', '重設'], + ['数据', '資料'], + ['检查', '檢查'], + ['周期', '週期'], + ['下载', '下載'], + ['订阅', '訂閱'], + ['删除', '刪除'], + ['记录', '記錄'], + ['监听', '監聽'], + ['关键字', '關鍵字'], + ['进度', '進度'], + ['连续', '連續'], + ['时', '時'], + ['分钟', '分鐘'], + ['小时', '小時'], + ['自动', '自動'], + ['状态', '狀態'], + ['配置', '設定'], + ['识别', '識別'], + ['增强', '增強'], + ['自定义', '自訂'], + ['剧集', '影集'], + ['电影', '電影'], + ['上映', '上映'], + ['暂停', '暫停'], + ['用户', '使用者'], + ['选择', '選擇'], + ['范围', '範圍'], + ['场景', '情境'], + ['关闭', '關閉'], + ['全部', '全部'], + ['严格', '嚴格'], + ['宽松', '寬鬆'], + ['平衡', '平衡'], + ['仅', '僅'], + ['完结', '完結'], + ['信号', '訊號'], + ['纠错', '修正'], + ['变更', '變更'], + ['节奏', '節奏'], + ['间隔', '間隔'], + ['默认', '預設'], + ['目标', '目標'], + ['满足', '符合'], + ['转换', '轉換'], + ['转', '轉'], + ['扫描', '掃描'], + ['存量', '既有'], + ['回填', '回填'], + ['媒体库', '媒體庫'], + ['整理', '整理'], + ['文件', '檔案'], + ['多个', '多個'], + ['为空', '留空'], + ['表示', '表示'], + ['开启', '開啟'], + ['发生', '發生'], + ['复核', '複核'], + ['触发', '觸發'], + ['清理', '清理'], + ['待定', '待定'], + ['完成', '完成'], + ['总集数', '總集數'], + ['集数', '集數'], + ['天数', '天數'], + ['策略', '策略'], + ['模式', '模式'], + ['缓存', '快取'], + ['大小', '大小'], + ['支持', '支援'], + ['处于激活状态', '處於啟用狀態'], + ['正则表达式', '正規表示式'], + ['媒体类型', '媒體類型'], + ['审计', '稽核'], + ['消息推送', '訊息推送'], + ['站点', '站點'], + ['搜索', '搜尋'], + ['补搜', '補搜'], + ['巡检', '巡檢'], + ['种子', '種子'], + ['任务', '任務'], + ['标签', '標籤'], + ['请求', '請求'], + ['名单', '名單'], + ['候选', '候選'], + ['标题', '標題'], + ['诊断', '診斷'], + ['类型', '類型'], + ['创建', '建立'], + ['终止', '終止'], + ['守卫', '守衛'], + ['统计', '統計'], + ['判断', '判斷'], + ['预计', '預計'], + ['参与', '參與'], + ['观察', '觀察'], + ['释放', '釋放'], + ['结果', '結果'], + ['动作', '動作'], + ['原因', '原因'], + ['频', '頻'], + ['秒数', '秒數'], + ['资源', '資源'], + ['辅助', '輔助'], + ['不足', '不足'], + ['允许', '允許'], + ['达到', '達到'], + ['两次', '兩次'], + ['轮数', '輪數'], + ['提醒', '提醒'], + ['强度', '強度'], + ['佐证', '佐證'], + ['稳定', '穩定'], + ['增加', '增加'], + ['重新', '重新'], + ['最后', '最後'], + ['继续', '繼續'], + ['结束', '結束'], + ['缩短', '縮短'], + ['保存', '儲存'], + ['采样', '取樣'], + ['补全', '補全'], + ['手动', '手動'], + ['跳过', '略過'], + ['作为', '作為'], + ['低于', '低於'], + ['视为', '視為'], + ['一个', '一個'], + ['大小写', '大小寫'], + ['精准', '精準'], + ['入库', '入庫'], + ['日志', '日誌'], + ['明细', '明細'], + ['覆盖', '覆蓋'], + ['进行', '進行'], + ['设置', '設定'], + ['等于', '等於'], + ['参考', '參考'], + ['意义', '意義'], + ['逗号', '逗號'], + ['探测', '探測'], + ['多少轮', '多少輪'], + ['计算', '計算'], + ['新建', '建立'], + ['切换', '切換'], + ['于', '於'], + ['视', '視'], + ['采', '採'], + ['补', '補'], + ['删', '刪'], + ['轮', '輪'], + ['算', '算'], + ['后', '後'], + ['会', '會'], + ['将', '將'], + ['处', '處'], + ['为', '為'], + ['与', '與'], + ['发', '發'], + ['过', '過'], + ['这', '這'], + ['则', '則'], + ['无', '無'], + ['设', '設'], + ['选', '選'], + ['线', '線'], + ['响', '響'], + ['应', '應'], + ['种', '種'], + ['从', '從'], + ['开', '開'], + ['进', '進'], + ['间', '間'], + ['数', '數'], + ['长', '長'], + ['现', '現'], + ['还', '還'], + ['较', '較'], + ['达', '達'], + ['实', '實'], + ['复', '複'], + ['对', '對'], + ['内', '內'], + ['样', '樣'], + ['并', '並'], + ['当', '當'], + ['监', '監'], + ['执', '執'], + ['检', '檢'], + ['动', '動'], + ['试', '試'], + ['阈', '閾'], + ['值', '值'], + ['写', '寫'], + ['号', '號'], + ['旧', '舊'], + ['库', '庫'], + ['坏', '壞'], + ['记', '記'], + ['覆', '覆'], + ['标', '標'], + ['变化', '變化'], + ['减', '減'], + ['满', '滿'], + ['少', '少'], + ['低', '低'], + ['冷却', '冷卻'], + ['换', '換'], + ['别', '別'], +] + +function toTraditional(text: string): string { + return traditionalPhrases.reduce((result, [source, target]) => result.replaceAll(source, target), text) +} + +const englishOptionTitles: Record = { + no: 'Off', + off: 'Off', + all: 'All', + movie: 'Movies', + tv: 'TV shows', + tv_episode: 'TV shows (individual episodes)', + normal: 'Standard subscriptions', + best_version: 'Best-version subscriptions', + best_version_episode: 'Episode upgrades', + audit: 'Audit', + loose: 'Relaxed', + balanced: 'Balanced', + strict: 'Strict', + summary: 'Summary', + detail: 'Details', + balanced_strict: 'Balanced and strict', + pause_movie: 'Pause movie subscriptions', + pause_tv: 'Pause TV subscriptions', + complete_movie: 'Complete movie subscriptions', + complete_tv: 'Complete TV subscriptions', + delete_movie: 'Delete movie subscriptions', + delete_tv: 'Delete TV subscriptions', + no_download: 'No downloads', + pre_air: 'Before release', + airing_gap: 'Airing gap', + auto_user: 'User rule', + external: 'External pause', + notify: 'Notify only', +} + +function localizedOptionTitle( + locale: SupportedLocale, + field: FieldMeta, + value: string | number, + source: string, +): string { + if (locale === 'zh-CN') return source + if (locale === 'zh-TW') return toTraditional(source) + if (typeof value === 'number') { + if (field.key === 'auto_check_interval_minutes' || field.key === 'download_check_interval_minutes') + return `${value} minutes` + if (field.key === 'meta_check_interval_hours') return `${value} hours` + return String(value) + } + const translated = englishOptionTitles[value] + if (!translated) throw new Error(`Missing option translation: ${field.key}.${value}`) + return translated +} + +/** 返回不修改源元数据的本地化分组副本。 */ +export function localizeGroups(localeSource: LocaleSource, source: readonly GroupMeta[] = groups): GroupMeta[] { + const locale = normalizeLocale(localeSource) + return source.map(group => { + const translation = groupTranslations[group.key] + if (!translation) throw new Error(`Missing group translation: ${group.key}`) + const [title, summary] = + locale === 'zh-CN' ? [group.title, group.summary] : locale === 'zh-TW' ? translation.tw : translation.en + return { ...group, title, summary } + }) +} + +/** 返回不修改源元数据的本地化字段与选项副本。 */ +export function localizeFields(localeSource: LocaleSource, source: readonly FieldMeta[] = fields): FieldMeta[] { + const locale = normalizeLocale(localeSource) + return source.map(field => { + const english = englishFields[field.key] + if (!english) throw new Error(`Missing field translation: ${field.key}`) + const label = locale === 'zh-CN' ? field.label : locale === 'zh-TW' ? toTraditional(field.label) : english[0] + const hint = field.hint + ? locale === 'zh-CN' + ? field.hint + : locale === 'zh-TW' + ? field.key === 'recognition_guard_custom_config' + ? '僅在內建規則無法滿足時編輯,留空則繼承目前模式' + : toTraditional(field.hint) + : english[1] + : undefined + if (!label.trim() || (field.hint && !hint?.trim())) throw new Error(`Empty field translation: ${field.key}`) + return { + ...field, + label, + hint, + options: field.options?.map(option => ({ + ...option, + title: localizedOptionTitle(locale, field, option.value, option.title), + })), + } + }) +} + +/** 在测试或启动期验证当前元数据不存在翻译缺口。 */ +export function assertTranslationCoverage(): void { + for (const locale of ['zh-CN', 'zh-TW', 'en-US'] as const) { + localizeGroups(locale) + localizeFields(locale) + } +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/presentation.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/presentation.ts new file mode 100644 index 00000000..bc5041b3 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/presentation.ts @@ -0,0 +1,78 @@ +import type { ConfigKey } from './defaults' +import type { FieldMeta } from './fields' +import type { SupportedLocale } from './i18n' + +const unitLabels: Record> = { + 'zh-CN': { + count: '次', + day: '天', + episode: '集', + hour: '小时', + item: '条', + minute: '分钟', + multiplier: '倍', + percent: '%', + round: '轮', + second: '秒', + }, + 'zh-TW': { + count: '次', + day: '天', + episode: '集', + hour: '小時', + item: '條', + minute: '分鐘', + multiplier: '倍', + percent: '%', + round: '輪', + second: '秒', + }, + 'en-US': { + count: 'x', + day: 'd', + episode: 'ep', + hour: 'hr', + item: 'items', + minute: 'min', + multiplier: 'x', + percent: '%', + round: 'rounds', + second: 'sec', + }, +} + +/** 返回数字步进器的紧凑单位,字段后缀承载通用时间语义。 */ +export function numberFieldUnit(key: ConfigKey, locale: SupportedLocale = 'zh-CN'): string | undefined { + const units = unitLabels[locale] + 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 + if (key.endsWith('_minutes')) return units.minute + if (key.endsWith('_hours')) return units.hour + if (key.endsWith('_days')) return units.day + if (key.endsWith('_episodes')) return units.episode + return undefined +} + +/** 数字字段的单位由步进器展示,标题只保留业务名称。 */ +export function displayFieldLabel(field: FieldMeta): string { + if (field.kind !== 'number') return field.label + let label = field.label + for (const [opening, closing] of [ + ['(', ')'], + ['(', ')'], + ] as const) { + let start = label.indexOf(opening) + while (start >= 0) { + const end = label.indexOf(closing, start + opening.length) + if (end < 0) break + label = `${label.slice(0, start)}${label.slice(end + closing.length)}` + start = label.indexOf(opening) + } + } + return label.trim() +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/config/values.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/config/values.ts new file mode 100644 index 00000000..5c9014eb --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/config/values.ts @@ -0,0 +1,69 @@ +import { configDefaults, type ConfigKey, type SaeConfig } from './defaults' + +/** 将动态数值输入归一化为有限 number;空值或非法值保留最近一次有效值。 */ +export function normalizeFiniteNumber(current: number, incoming: unknown): number { + if (incoming === null || incoming === undefined) return current + if (typeof incoming === 'string' && !incoming.trim()) return current + const parsed = typeof incoming === 'number' ? incoming : Number(incoming) + return Number.isFinite(parsed) ? parsed : current +} + +function normalizeBoolean(defaultValue: boolean, incoming: unknown): boolean { + if (incoming === null || incoming === undefined) return defaultValue + if (typeof incoming === 'boolean') return incoming + if (typeof incoming === 'string') { + return ['true', 'on', 'yes', '1', 'guard'].includes(incoming.trim().toLowerCase()) + } + if (typeof incoming === 'number') return incoming !== 0 + if (Array.isArray(incoming)) return incoming.length > 0 + if (typeof incoming === 'object') return Object.keys(incoming).length > 0 + return Boolean(incoming) +} + +function normalizeNumber(defaultValue: number, incoming: unknown): number { + if (incoming === null || incoming === undefined) return defaultValue + if (typeof incoming === 'string' && !incoming.trim()) return defaultValue + if (typeof incoming !== 'number' && typeof incoming !== 'string') return defaultValue + const parsed = Number(incoming) + return Number.isFinite(parsed) ? parsed : defaultValue +} + +function normalizeString(defaultValue: string, incoming: unknown): string { + return incoming === null || incoming === undefined ? defaultValue : String(incoming) +} + +function normalizeStringArray(defaultValue: string[], incoming: unknown): string[] { + if (Array.isArray(incoming)) { + return incoming.map(value => String(value).trim()).filter(Boolean) + } + if (typeof incoming === 'string') { + return incoming + .split(',') + .map(value => value.trim()) + .filter(Boolean) + } + return [...defaultValue] +} + +/** Host 配置来自动态 JSON;这里只接受稳定键并按默认值类型重建完整持久化契约。 */ +export function normalizeSaeConfig(input: unknown): SaeConfig { + const source = + input !== null && typeof input === 'object' && !Array.isArray(input) ? (input as Record) : {} + const entries = (Object.keys(configDefaults) as ConfigKey[]).map(key => { + const defaultValue = configDefaults[key] + const incoming = source[key] + + if (Array.isArray(defaultValue)) { + return [key, normalizeStringArray(defaultValue, incoming)] + } + if (typeof defaultValue === 'boolean') { + return [key, normalizeBoolean(defaultValue, incoming)] + } + if (typeof defaultValue === 'number') { + return [key, normalizeNumber(defaultValue, incoming)] + } + return [key, normalizeString(defaultValue, incoming)] + }) + + return Object.fromEntries(entries) as SaeConfig +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/src/main.ts b/plugins.v3/subscribeassistantenhanced/frontend/src/main.ts new file mode 100644 index 00000000..a512059d --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/src/main.ts @@ -0,0 +1 @@ +export { default as Config } from './components/Config.vue' diff --git a/plugins.v3/subscribeassistantenhanced/frontend/tsconfig.json b/plugins.v3/subscribeassistantenhanced/frontend/tsconfig.json new file mode 100644 index 00000000..546364fd --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@tests/*": ["../../../tests/v3/subscribeassistantenhanced/frontend/*"], + "@testing-library/*": ["node_modules/@testing-library/*"], + "msw/*": ["node_modules/msw/*"], + "vitest": ["node_modules/vitest"], + "vue": ["node_modules/vue"], + "vuetify": ["node_modules/vuetify"], + "vuetify/components": ["node_modules/vuetify/lib/components/index.d.mts"], + "vuetify/directives": ["node_modules/vuetify/lib/directives/index.d.mts"] + }, + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "vite.config.ts", + "../../../tests/v3/subscribeassistantenhanced/frontend/**/*.ts" + ] +} diff --git a/plugins.v3/subscribeassistantenhanced/frontend/vite.config.ts b/plugins.v3/subscribeassistantenhanced/frontend/vite.config.ts new file mode 100644 index 00000000..75596370 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/vite.config.ts @@ -0,0 +1,195 @@ +import { fileURLToPath } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import federation from '@originjs/vite-plugin-federation' +import { defineConfig, normalizePath, type Plugin } from 'vite' +import { configDefaults } from 'vitest/config' + +const TEST_ROOT = normalizePath( + fileURLToPath(new URL('../../../tests/v3/subscribeassistantenhanced/frontend', import.meta.url)), +) +const REPOSITORY_ROOT = normalizePath(fileURLToPath(new URL('../../..', import.meta.url))) + +const isTestMode = (mode: string): boolean => mode === 'test' || process.env.VITEST === 'true' + +function cleanFederationAssets(): Plugin { + return { + name: 'clean-federation-assets', + enforce: 'post', + generateBundle(_options, bundle) { + for (const fileName of Object.keys(bundle)) { + // Federation 在 generate:false 移除 JS fallback 后仍可能残留抽取出的 CSS。 + if (fileName.startsWith('assets/__federation_shared_vuetify/')) { + delete bundle[fileName] + } + } + + const remoteEntry = bundle['assets/remoteEntry.js'] + if (remoteEntry?.type === 'chunk') { + // 规范化生成器空白,确保重复构建通过仓库检查。 + remoteEntry.code = remoteEntry.code + .split('\n') + .map(line => line.trimEnd()) + .join('\n') + } + }, + } +} + +export default defineConfig(({ mode }) => { + const plugins: Plugin[] = [vue()] + if (!isTestMode(mode)) { + plugins.push( + federation({ + name: 'SubscribeAssistantEnhanced', + filename: 'remoteEntry.js', + exposes: { + './Config': './src/components/Config.vue', + }, + shared: { + vue: { + requiredVersion: false, + generate: false, + }, + vuetify: { + requiredVersion: false, + generate: false, + }, + 'vuetify/styles': { + requiredVersion: false, + generate: false, + }, + }, + }), + cleanFederationAssets(), + ) + } + + return { + plugins, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@tests': TEST_ROOT, + }, + dedupe: [ + '@testing-library/jest-dom', + '@testing-library/user-event', + '@testing-library/vue', + 'msw', + 'vitest', + 'vue', + 'vuetify', + ], + }, + build: { + target: 'esnext', + minify: false, + cssCodeSplit: true, + assetsInlineLimit(filePath) { + // 联邦组件由宿主动态加载,品牌图需内联以免静态资源按宿主根路径解析。 + if (filePath.endsWith('sae-logo.png')) return true + return undefined + }, + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: 'src/main.ts', + }, + }, + server: { + fs: { + allow: [REPOSITORY_ROOT], + }, + }, + test: { + clearMocks: true, + environment: 'jsdom', + environmentOptions: { + jsdom: { + pretendToBeVisual: true, + url: 'http://localhost/', + }, + }, + exclude: [...configDefaults.exclude, '**/.worktrees/**'], + include: [`${TEST_ROOT}/src/**/__tests__/**/*.spec.ts`], + restoreMocks: true, + server: { + deps: { + inline: ['vuetify'], + }, + }, + setupFiles: [`${TEST_ROOT}/setup.ts`], + unstubGlobals: true, + coverage: { + include: [ + 'src/components/Config.vue', + 'src/config/api.ts', + 'src/config/defaults.ts', + 'src/config/draft.ts', + 'src/config/fields.ts', + 'src/config/i18n.ts', + 'src/config/presentation.ts', + 'src/config/values.ts', + ], + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + reportsDirectory: '../../../coverage-reports/subscribeassistantenhanced-frontend', + thresholds: { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + 'src/components/Config.vue': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/api.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/defaults.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/draft.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/fields.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/i18n.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/presentation.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + 'src/config/values.ts': { + branches: 75, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, + }, + } +}) diff --git a/plugins.v3/subscribeassistantenhanced/frontend/yarn.lock b/plugins.v3/subscribeassistantenhanced/frontend/yarn.lock new file mode 100644 index 00000000..85b37059 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/frontend/yarn.lock @@ -0,0 +1,3173 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@adobe/css-tools@^4.4.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.5.0.tgz#b5b71a25a4d16afa2482592ddfa62fccc60bc7d1" + integrity sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== + +"@ampproject/remapping@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@asamuzakjp/css-color@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz#cc42f5b85c593f79f1fa4f25d2b9b321e61d1794" + integrity sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw== + dependencies: + "@csstools/css-calc" "^2.1.3" + "@csstools/css-color-parser" "^3.0.9" + "@csstools/css-parser-algorithms" "^3.0.4" + "@csstools/css-tokenizer" "^3.0.3" + lru-cache "^10.4.3" + +"@babel/code-frame@^7.10.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/parser@^7.25.4", "@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + +"@babel/runtime@^7.12.5", "@babel/runtime@^7.23.2": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + +"@babel/types@^7.25.4", "@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + +"@csstools/color-helpers@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz#106c54c808cabfd1ab4c602d8505ee584c2996ef" + integrity sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA== + +"@csstools/css-calc@^2.1.3", "@csstools/css-calc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.4.tgz#8473f63e2fcd6e459838dd412401d5948f224c65" + integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== + +"@csstools/css-color-parser@^3.0.9": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz#4e386af3a99dd36c46fef013cfe4c1c341eed6f0" + integrity sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA== + dependencies: + "@csstools/color-helpers" "^5.1.0" + "@csstools/css-calc" "^2.1.4" + +"@csstools/css-parser-algorithms@^3.0.4": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz#5755370a9a29abaec5515b43c8b3f2cf9c2e3076" + integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ== + +"@csstools/css-tokenizer@^3.0.3": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz#333fedabc3fd1a8e5d0100013731cf19e6a8c5d3" + integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2", "@eslint-community/regexpp@^4.8.0": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03" + integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/js@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729" + integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@inquirer/ansi@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-2.0.7.tgz#86de22810cac3ed406ec10f8d66016815b8226b4" + integrity sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q== + +"@inquirer/confirm@^6.0.11": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-6.1.1.tgz#9c6a7d79c6132b2af57fdb75747f056204e55356" + integrity sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ== + dependencies: + "@inquirer/core" "^11.2.1" + "@inquirer/type" "^4.0.7" + +"@inquirer/core@^11.2.1": + version "11.2.1" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-11.2.1.tgz#54ccd8f7d47852140b6066cbd77d63b2c2b168fd" + integrity sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA== + dependencies: + "@inquirer/ansi" "^2.0.7" + "@inquirer/figures" "^2.0.7" + "@inquirer/type" "^4.0.7" + cli-width "^4.1.0" + fast-wrap-ansi "^0.2.0" + mute-stream "^3.0.0" + signal-exit "^4.1.0" + +"@inquirer/figures@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-2.0.7.tgz#f5cc5843732a81304d06a0db4b53cc7dbda15541" + integrity sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw== + +"@inquirer/type@^4.0.7": + version "4.0.7" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-4.0.7.tgz#9c6f0d857fe6ad549a3a932343b64e76acb34b10" + integrity sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.6.tgz#8dc9afa2ac1506cb1a58f89940f1c124446c8df3" + integrity sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.31": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@mswjs/interceptors@^0.41.3": + version "0.41.9" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" + integrity sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/deferred-promise@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz#9725acc5afe8ecde690e9e198a094859fdbf2e45" + integrity sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + +"@originjs/vite-plugin-federation@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2" + integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ== + dependencies: + estree-walker "^3.0.2" + magic-string "^0.27.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@rollup/rollup-android-arm-eabi@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz#5e9849b661c2229cf967a08dbe2dbbe9e8c991e5" + integrity sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg== + +"@rollup/rollup-android-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz#5b0699ee5dd484b222c9ed74aff43c91ea8b17f8" + integrity sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw== + +"@rollup/rollup-darwin-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz#8bc52c9d7a3ce8d0533c351a9c935de781daa06f" + integrity sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A== + +"@rollup/rollup-darwin-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz#ba2ef3e8fb310f0af35588f270cfa5aa96e48764" + integrity sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA== + +"@rollup/rollup-freebsd-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz#93b10bdbfe8ada226b8bc0c02ef6b7f544474d96" + integrity sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw== + +"@rollup/rollup-freebsd-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz#3e8aa38ef3c9c300946871e3fdbb0c30e0a20f86" + integrity sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg== + +"@rollup/rollup-linux-arm-gnueabihf@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz#1d7994384bb0ad1bc41921b506e1642d4f9d7fc3" + integrity sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg== + +"@rollup/rollup-linux-arm-musleabihf@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz#a6540f47cf844a56b80ca9ff95d2acdfb2cef97b" + integrity sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA== + +"@rollup/rollup-linux-arm64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz#404f2045651840cbf48da91ba6d0f490f0bc2cbf" + integrity sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA== + +"@rollup/rollup-linux-arm64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz#a3404ffddf7b474b48c99b9c893b6247bb765ba5" + integrity sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ== + +"@rollup/rollup-linux-loong64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz#e8aac6d549b377945e349882f199b7c8eb75ca38" + integrity sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg== + +"@rollup/rollup-linux-loong64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz#6e2e44ea50310b3a582078a915e5feb879c820d4" + integrity sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ== + +"@rollup/rollup-linux-ppc64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz#6898302da6d77a0537cde64b2b4c6b60659bd110" + integrity sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A== + +"@rollup/rollup-linux-ppc64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz#333717c95dd5a66bef8f63e7ef8a9fd845fd18d0" + integrity sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w== + +"@rollup/rollup-linux-riscv64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz#81bc06ba380352004d01f4826eb7cdccefa05bad" + integrity sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg== + +"@rollup/rollup-linux-riscv64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz#95a7cd39de21389ad6788a5284eaaa738e29ca4c" + integrity sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q== + +"@rollup/rollup-linux-s390x-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz#06e6db2ec1bc48b5374c7923ef83c2eb024b2452" + integrity sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg== + +"@rollup/rollup-linux-x64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz#5dc818988285e09e88790c6462def72413df2da3" + integrity sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A== + +"@rollup/rollup-linux-x64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz#2080f4a93349e9afd34be6fc1a37e01fc8bfc80f" + integrity sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg== + +"@rollup/rollup-openbsd-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz#21d64a8acb66221724b923e51af5333df1af044b" + integrity sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg== + +"@rollup/rollup-openharmony-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz#8e0fcd9d02141e337b4c5b5cff576cb9a76b1ba0" + integrity sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA== + +"@rollup/rollup-win32-arm64-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz#bdb4cc4efd58efe808203347f0f5463f0ea16e52" + integrity sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg== + +"@rollup/rollup-win32-ia32-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz#dbaebde5afd24eae0eefe915d901632e7cb59860" + integrity sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q== + +"@rollup/rollup-win32-x64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz#84109e85fea5f8f1353499f96578fdc2a0e8b138" + integrity sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg== + +"@rollup/rollup-win32-x64-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz#3671ce3f9b928d5c01f879792d5c0b60ae14d4ad" + integrity sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA== + +"@testing-library/dom@9.3.4", "@testing-library/dom@^9.3.3": + version "9.3.4" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" + integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.1.3" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" + integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/user-event@14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + +"@testing-library/vue@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-8.1.0.tgz#a3ee1cc3c73120ae8981a54f082d239cd4e8ea24" + integrity sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA== + dependencies: + "@babel/runtime" "^7.23.2" + "@testing-library/dom" "^9.3.3" + "@vue/test-utils" "^2.4.1" + +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*": + version "26.1.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.1.1.tgz#bad758d601e97d6cf457d204ee76a35fce7bd119" + integrity sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw== + dependencies: + undici-types "~8.3.0" + +"@types/set-cookie-parser@^2.4.10": + version "2.4.10" + resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz#ad3a807d6d921db9720621ea3374c5d92020bcbc" + integrity sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw== + dependencies: + "@types/node" "*" + +"@types/statuses@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/statuses/-/statuses-2.0.6.tgz#66748315cc9a96d63403baa8671b2c124f8633aa" + integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA== + +"@typescript-eslint/eslint-plugin@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24" + integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.64.0" + "@typescript-eslint/type-utils" "8.64.0" + "@typescript-eslint/utils" "8.64.0" + "@typescript-eslint/visitor-keys" "8.64.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72" + integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw== + dependencies: + "@typescript-eslint/scope-manager" "8.64.0" + "@typescript-eslint/types" "8.64.0" + "@typescript-eslint/typescript-estree" "8.64.0" + "@typescript-eslint/visitor-keys" "8.64.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6" + integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.64.0" + "@typescript-eslint/types" "^8.64.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958" + integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w== + dependencies: + "@typescript-eslint/types" "8.64.0" + "@typescript-eslint/visitor-keys" "8.64.0" + +"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851" + integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw== + +"@typescript-eslint/type-utils@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3" + integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg== + dependencies: + "@typescript-eslint/types" "8.64.0" + "@typescript-eslint/typescript-estree" "8.64.0" + "@typescript-eslint/utils" "8.64.0" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b" + integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA== + +"@typescript-eslint/typescript-estree@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc" + integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA== + dependencies: + "@typescript-eslint/project-service" "8.64.0" + "@typescript-eslint/tsconfig-utils" "8.64.0" + "@typescript-eslint/types" "8.64.0" + "@typescript-eslint/visitor-keys" "8.64.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600" + integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.64.0" + "@typescript-eslint/types" "8.64.0" + "@typescript-eslint/typescript-estree" "8.64.0" + +"@typescript-eslint/visitor-keys@8.64.0": + version "8.64.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473" + integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw== + dependencies: + "@typescript-eslint/types" "8.64.0" + eslint-visitor-keys "^5.0.0" + +"@vitejs/plugin-vue@^5.0.4": + version "5.2.4" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8" + integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== + +"@vitest/coverage-v8@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz#2e9ce1103445c237aaa420a7f0058125fe4a7854" + integrity sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg== + dependencies: + "@ampproject/remapping" "^2.3.0" + "@bcoe/v8-coverage" "^1.0.2" + ast-v8-to-istanbul "^0.3.3" + debug "^4.4.1" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.17" + magicast "^0.3.5" + std-env "^3.9.0" + test-exclude "^7.0.1" + tinyrainbow "^2.0.0" + +"@vitest/expect@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" + integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" + integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== + dependencies: + "@vitest/spy" "3.2.7" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" + integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" + integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== + dependencies: + "@vitest/utils" "3.2.7" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" + integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== + dependencies: + "@vitest/pretty-format" "3.2.7" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" + integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" + integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== + dependencies: + "@vitest/pretty-format" "3.2.7" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +"@volar/language-core@2.4.15": + version "2.4.15" + resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.15.tgz#759d04cb4eab9920560b8bcfa4515d5b08a1b7ce" + integrity sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA== + dependencies: + "@volar/source-map" "2.4.15" + +"@volar/source-map@2.4.15": + version "2.4.15" + resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.15.tgz#18aba09994c0268e59a418f9d738e4a85302781d" + integrity sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg== + +"@volar/typescript@2.4.15": + version "2.4.15" + resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.15.tgz#1445d23f8e4f9ad821b6bfa58cf4a2b980dc5f97" + integrity sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg== + dependencies: + "@volar/language-core" "2.4.15" + path-browserify "^1.0.1" + vscode-uri "^3.0.8" + +"@vue/compiler-core@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz#3b6ea2b95f3c0ed4048efd87d71c468e4021951d" + integrity sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw== + dependencies: + "@babel/parser" "^7.29.7" + "@vue/shared" "3.5.39" + entities "^7.0.1" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + +"@vue/compiler-dom@3.5.39", "@vue/compiler-dom@^3.5.0": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz#d4261672233442762bee1709dcc34ceefc1ae873" + integrity sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg== + dependencies: + "@vue/compiler-core" "3.5.39" + "@vue/shared" "3.5.39" + +"@vue/compiler-sfc@3.5.39", "@vue/compiler-sfc@^3.5.13": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz#3eb13950e7442bc86eeebe73287ecbc6bf1678f5" + integrity sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg== + dependencies: + "@babel/parser" "^7.29.7" + "@vue/compiler-core" "3.5.39" + "@vue/compiler-dom" "3.5.39" + "@vue/compiler-ssr" "3.5.39" + "@vue/shared" "3.5.39" + estree-walker "^2.0.2" + magic-string "^0.30.21" + postcss "^8.5.15" + source-map-js "^1.2.1" + +"@vue/compiler-ssr@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz#50e44c9ec591e419e83ebc6d3bcedcbaa3f70805" + integrity sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw== + dependencies: + "@vue/compiler-dom" "3.5.39" + "@vue/shared" "3.5.39" + +"@vue/compiler-vue2@^2.7.16": + version "2.7.16" + resolved "https://registry.yarnpkg.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz#2ba837cbd3f1b33c2bc865fbe1a3b53fb611e249" + integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A== + dependencies: + de-indent "^1.0.2" + he "^1.2.0" + +"@vue/language-core@2.2.12": + version "2.2.12" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.2.12.tgz#d01f7e865f593f968cb65c12a13d8337e65641f0" + integrity sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA== + dependencies: + "@volar/language-core" "2.4.15" + "@vue/compiler-dom" "^3.5.0" + "@vue/compiler-vue2" "^2.7.16" + "@vue/shared" "^3.5.0" + alien-signals "^1.0.3" + minimatch "^9.0.3" + muggle-string "^0.4.1" + path-browserify "^1.0.1" + +"@vue/reactivity@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.39.tgz#e07513ae382cd8eb796bea06d046d3c34ddc12ef" + integrity sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog== + dependencies: + "@vue/shared" "3.5.39" + +"@vue/runtime-core@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz#ee70cb5c837112a93e477195fa2a2a0469373338" + integrity sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw== + dependencies: + "@vue/reactivity" "3.5.39" + "@vue/shared" "3.5.39" + +"@vue/runtime-dom@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz#44a5c147fe2c2687da9cc0c7382ad21873aa476d" + integrity sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww== + dependencies: + "@vue/reactivity" "3.5.39" + "@vue/runtime-core" "3.5.39" + "@vue/shared" "3.5.39" + csstype "^3.2.3" + +"@vue/server-renderer@3.5.39": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz#23978ecd5810b2422ca2f666e57c38fc1862b150" + integrity sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw== + dependencies: + "@vue/compiler-ssr" "3.5.39" + "@vue/shared" "3.5.39" + +"@vue/shared@3.5.39", "@vue/shared@^3.5.0": + version "3.5.39" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.39.tgz#694bdae9d47381c2fcfedc6d5274f2450068c1ec" + integrity sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA== + +"@vue/test-utils@2.4.11", "@vue/test-utils@^2.4.1": + version "2.4.11" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.4.11.tgz#448e56e07fb4c19cf0e57ec1d883f30f94ca71bd" + integrity sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA== + dependencies: + js-beautify "^1.14.9" + vue-component-type-helpers "^3.0.0" + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.16.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" + integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== + +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +alien-signals@^1.0.3: + version "1.0.13" + resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-1.0.13.tgz#8d6db73462f742ee6b89671fbd8c37d0b1727a7e" + integrity sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +aria-query@5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" + integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== + dependencies: + deep-equal "^2.0.5" + +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + +array-buffer-byte-length@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-v8-to-istanbul@^0.3.3: + version "0.3.12" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz#8eb1b7c86ef8499859be761b17ffd91406c0c36f" + integrity sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + estree-walker "^3.0.3" + js-tokens "^10.0.0" + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^2.0.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.2.tgz#0bba2271feb7d458b0d31ad13625aaa4754431e2" + integrity sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA== + dependencies: + balanced-match "^1.0.0" + +brace-expansion@^5.0.5: + version "5.0.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.7.tgz#1b0e46965b479dad65af737b4a02790a05498337" + integrity sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA== + dependencies: + balanced-match "^4.0.2" + +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + +bytes@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.7, call-bind@^1.0.8, call-bind@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7" + integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + get-intrinsic "^1.3.0" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + +cli-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" + integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +cookie@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +cssstyle@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.6.0.tgz#ea18007024e3167f4f105315f3ec2d982bf48ed9" + integrity sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg== + dependencies: + "@asamuzakjp/css-color" "^3.2.0" + rrweb-cssom "^0.8.0" + +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + +de-indent@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" + integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== + +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decimal.js@^10.5.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-equal@^2.0.5: + version "2.2.3" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" + integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA== + dependencies: + array-buffer-byte-length "^1.0.0" + call-bind "^1.0.5" + es-get-iterator "^1.1.3" + get-intrinsic "^1.2.2" + is-arguments "^1.1.1" + is-array-buffer "^3.0.2" + is-date-object "^1.0.5" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + isarray "^2.0.5" + object-is "^1.1.5" + object-keys "^1.1.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.5.1" + side-channel "^1.0.4" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.13" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +editorconfig@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.7.tgz#8d6e178aeb507c206d65e1804c1d7510d110d434" + integrity sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "^9.0.1" + semver "^7.5.3" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-get-iterator@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-plugin-sonarjs@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.0.tgz#34fb67be392c3f49875ce5405e02d62f2f860304" + integrity sha512-bqADfuNtTL7VK6RU29eoiFTtaaBKIpVPuX3bOl+rBpWSBa0zIBVZlqZNZQjfP6s4iXkAJokv5IsD8OsACkwApg== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + builtin-modules "^3.3.0" + bytes "^3.1.2" + functional-red-black-tree "^1.0.1" + globals "^17.7.0" + jsx-ast-utils-x "^0.1.0" + lodash.merge "^4.6.2" + minimatch "^10.2.5" + scslre "^0.3.0" + semver "^7.8.5" + ts-api-utils "^2.5.0" + typescript ">=5 <6.1.0" + yaml "^2.9.0" + +eslint-plugin-vue@^10.9.2: + version "10.10.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz#e957b56853dea54e738136d849d85929954bdf3d" + integrity sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + natural-compare "^1.4.0" + nth-check "^2.1.1" + postcss-selector-parser "^7.1.4" + semver "^7.8.5" + xml-name-validator "^5.0.0" + +"eslint-scope@^8.2.0 || ^9.0.0", eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +"eslint-visitor-keys@^4.2.0 || ^5.0.0", eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@^10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.7.0.tgz#cd3b8022f3b1e3b183760d90dfc58e9d3644106b" + integrity sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.6.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.4" + natural-compare "^1.4.0" + optionator "^0.9.3" + +"espree@^10.3.0 || ^11.0.0", espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.6.0, esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +estree-walker@^3.0.2, estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +expect-type@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-string-truncated-width@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz#23afe0da67d752ca0727538f1e6967759728ce49" + integrity sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g== + +fast-string-width@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/fast-string-width/-/fast-string-width-3.0.2.tgz#16dbabb491ce5585b5ecb675b65c165d71688eeb" + integrity sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg== + dependencies: + fast-string-truncated-width "^3.0.2" + +fast-wrap-ansi@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz#95e952a0145bce3f59ad56e179f84c48d4072935" + integrity sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q== + dependencies: + fast-string-width "^3.0.2" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.4.1, glob@^10.4.2: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +globals@^17.3.0, globals@^17.7.0: + version "17.7.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d" + integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg== + +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graphql@^16.13.2: + version "16.14.2" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.14.2.tgz#83faf25869e3df727cc855161db5da85b0e5b2c0" + integrity sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA== + +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +headers-polyfill@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-5.0.1.tgz#9554eb2892b666db1c7a3380a91b6cfd467a6b19" + integrity sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA== + dependencies: + "@types/set-cookie-parser" "^2.4.10" + set-cookie-parser "^3.0.1" + +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.6.tgz#6a57aaef4c90df27ac3590875d29e8f11988c88e" + integrity sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + +is-arguments@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-array-buffer@^3.0.2, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-date-object@^1.0.5: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.2, is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-regex@^1.1.4, is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.2, is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + +is-string@^1.0.7, is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + +is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + +istanbul-reports@^3.1.7: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.14.9: + version "1.15.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" + integrity sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.4.2" + js-cookie "^3.0.5" + nopt "^7.2.1" + +js-cookie@^3.0.5: + version "3.0.8" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" + integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== + +js-tokens@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-10.0.0.tgz#dffe7599b4a8bb7fe30aff8d0235234dffb79831" + integrity sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +jsdom@26.1.0: + version "26.1.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-26.1.0.tgz#ab5f1c1cafc04bd878725490974ea5e8bf0c72b3" + integrity sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg== + dependencies: + cssstyle "^4.2.1" + data-urls "^5.0.0" + decimal.js "^10.5.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.6" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.16" + parse5 "^7.2.1" + rrweb-cssom "^0.8.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^5.1.1" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.1.1" + ws "^8.18.0" + xml-name-validator "^5.0.0" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +jsx-ast-utils-x@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz#b0933d66a69e0aa1ae23f74fb87b079ec298652f" + integrity sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^10.2.0, lru-cache@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" + integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.13" + +magic-string@^0.30.17, magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== + dependencies: + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimatch@^9.0.1, minimatch@^9.0.3, minimatch@^9.0.4: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +msw@2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.15.0.tgz#4028ba3d887af8c166d45aa3bf37116f73f21cec" + integrity sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ== + dependencies: + "@inquirer/confirm" "^6.0.11" + "@mswjs/interceptors" "^0.41.3" + "@open-draft/deferred-promise" "^3.0.0" + "@types/statuses" "^2.0.6" + cookie "^1.1.1" + graphql "^16.13.2" + headers-polyfill "^5.0.1" + is-node-process "^1.2.0" + outvariant "^1.4.3" + path-to-regexp "^6.3.0" + picocolors "^1.1.1" + rettime "^0.11.11" + statuses "^2.0.2" + strict-event-emitter "^0.5.1" + tough-cookie "^6.0.1" + type-fest "^5.5.0" + until-async "^3.0.2" + yargs "^17.7.2" + +muggle-string@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" + integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ== + +mute-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-3.0.0.tgz#cd8014dd2acb72e1e91bb67c74f0019e620ba2d1" + integrity sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw== + +nanoid@^3.3.12: + version "3.3.15" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316" + integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +nopt@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +nth-check@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +nwsapi@^2.2.16: + version "2.2.24" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.24.tgz#f8927043d4c9b516abdebe804a32c8d1f9484d1f" + integrity sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parse5@^7.2.1: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" + integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2, picomatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + +postcss-selector-parser@^7.1.4: + version "7.1.4" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz#69dc7a526517572ff6b150e352b36a016017b485" + integrity sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss@^8.4.43, postcss@^8.5.15: + version "8.5.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.16.tgz#1230ce0b5df354c24c0ea45f99ce5f6a88279d28" + integrity sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg== + dependencies: + nanoid "^3.3.12" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^3.9.5: + version "3.9.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad" + integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg== + +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +punycode@^2.1.0, punycode@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +refa@^0.12.0, refa@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/refa/-/refa-0.12.1.tgz#dac13c4782dc22b6bae6cce81a2b863888ea39c6" + integrity sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + +regexp-ast-analysis@^0.7.0: + version "0.7.1" + resolved "https://registry.yarnpkg.com/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz#c0e24cb2a90f6eadd4cbaaba129317e29d29c482" + integrity sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + refa "^0.12.1" + +regexp.prototype.flags@^1.5.1: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +rettime@^0.11.11: + version "0.11.11" + resolved "https://registry.yarnpkg.com/rettime/-/rettime-0.11.11.tgz#fe8fb192e1877bb0080fc1a640cb08eededd7d12" + integrity sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ== + +rollup@^4.20.0: + version "4.62.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541" + integrity sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA== + dependencies: + "@types/estree" "1.0.9" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.62.2" + "@rollup/rollup-android-arm64" "4.62.2" + "@rollup/rollup-darwin-arm64" "4.62.2" + "@rollup/rollup-darwin-x64" "4.62.2" + "@rollup/rollup-freebsd-arm64" "4.62.2" + "@rollup/rollup-freebsd-x64" "4.62.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.62.2" + "@rollup/rollup-linux-arm-musleabihf" "4.62.2" + "@rollup/rollup-linux-arm64-gnu" "4.62.2" + "@rollup/rollup-linux-arm64-musl" "4.62.2" + "@rollup/rollup-linux-loong64-gnu" "4.62.2" + "@rollup/rollup-linux-loong64-musl" "4.62.2" + "@rollup/rollup-linux-ppc64-gnu" "4.62.2" + "@rollup/rollup-linux-ppc64-musl" "4.62.2" + "@rollup/rollup-linux-riscv64-gnu" "4.62.2" + "@rollup/rollup-linux-riscv64-musl" "4.62.2" + "@rollup/rollup-linux-s390x-gnu" "4.62.2" + "@rollup/rollup-linux-x64-gnu" "4.62.2" + "@rollup/rollup-linux-x64-musl" "4.62.2" + "@rollup/rollup-openbsd-x64" "4.62.2" + "@rollup/rollup-openharmony-arm64" "4.62.2" + "@rollup/rollup-win32-arm64-msvc" "4.62.2" + "@rollup/rollup-win32-ia32-msvc" "4.62.2" + "@rollup/rollup-win32-x64-gnu" "4.62.2" + "@rollup/rollup-win32-x64-msvc" "4.62.2" + fsevents "~2.3.2" + +rrweb-cssom@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz#3021d1b4352fbf3b614aaeed0bc0d5739abe0bc2" + integrity sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw== + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + +scslre@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/scslre/-/scslre-0.3.0.tgz#c3211e9bfc5547fc86b1eabaa34ed1a657060155" + integrity sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ== + dependencies: + "@eslint-community/regexpp" "^4.8.0" + refa "^0.12.0" + regexp-ast-analysis "^0.7.0" + +semver@^7.5.3, semver@^7.6.3, semver@^7.7.3, semver@^7.8.5: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +set-cookie-parser@^3.0.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz#f4e490298759d756a68eabcbcd0fc9261ad0fee0" + integrity sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw== + +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.0.4, side-channel@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^4.0.1, signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-js@^1.2.0, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +statuses@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +stop-iteration-iterator@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + +test-exclude@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.2.tgz#482392077630bc57d5630c13abe908bb910dfc65" + integrity sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^10.2.2" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +tldts-core@^6.1.86: + version "6.1.86" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8" + integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA== + +tldts-core@^7.4.9: + version "7.4.9" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.4.9.tgz#8c3e6fc36123b6001290860d2abda6546466980b" + integrity sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg== + +tldts@^6.1.32: + version "6.1.86" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7" + integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ== + dependencies: + tldts-core "^6.1.86" + +tldts@^7.0.5: + version "7.4.9" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.4.9.tgz#78e72ad3c68fc1ec0626e6528f259dd5307a2629" + integrity sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA== + dependencies: + tldts-core "^7.4.9" + +tough-cookie@^5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.2.tgz#66d774b4a1d9e12dc75089725af3ac75ec31bed7" + integrity sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A== + dependencies: + tldts "^6.1.32" + +tough-cookie@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.2.tgz#7b1f22fcf2daf06c4ff9d53ec1845f44c6627062" + integrity sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA== + dependencies: + tldts "^7.0.5" + +tr46@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca" + integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw== + dependencies: + punycode "^2.3.1" + +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^5.5.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.8.0.tgz#6d517998257c33159db4d4da6f18efa33bd47df3" + integrity sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA== + dependencies: + tagged-tag "^1.0.0" + +typescript-eslint@^8.64.0: + version "8.64.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.64.0.tgz#4984dae4de9dc8bf892acf5c394d0a2a5f08c3e1" + integrity sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ== + dependencies: + "@typescript-eslint/eslint-plugin" "8.64.0" + "@typescript-eslint/parser" "8.64.0" + "@typescript-eslint/typescript-estree" "8.64.0" + "@typescript-eslint/utils" "8.64.0" + +"typescript@>=5 <6.1.0": + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +typescript@^5.0.4: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + +until-async@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/until-async/-/until-async-3.0.2.tgz#447f1531fdd7bb2b4c7a98869bdb1a4c2a23865f" + integrity sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^5.4.11: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" + integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.7" + "@vitest/mocker" "3.2.7" + "@vitest/pretty-format" "^3.2.7" + "@vitest/runner" "3.2.7" + "@vitest/snapshot" "3.2.7" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +vscode-uri@^3.0.8: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + +vue-component-type-helpers@^3.0.0: + version "3.3.7" + resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.7.tgz#98f2a53901c3883a674bbc063f74c51a97eb7057" + integrity sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg== + +vue-eslint-parser@^10.4.1: + version "10.4.1" + resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz#3d0aabb6604dac3fcd94f893291cb9e754cc392c" + integrity sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA== + dependencies: + debug "^4.4.0" + eslint-scope "^8.2.0 || ^9.0.0" + eslint-visitor-keys "^4.2.0 || ^5.0.0" + espree "^10.3.0 || ^11.0.0" + esquery "^1.6.0" + semver "^7.6.3" + +vue-tsc@^2.0.10: + version "2.2.12" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.2.12.tgz#5f719b08ef7390a763c1a20169ca5c9d09d55688" + integrity sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw== + dependencies: + "@volar/typescript" "2.4.15" + "@vue/language-core" "2.2.12" + +vue@^3.5.13: + version "3.5.39" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.39.tgz#0bb8d63bf2a75860e282bc054d19e625f5834224" + integrity sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA== + dependencies: + "@vue/compiler-dom" "3.5.39" + "@vue/compiler-sfc" "3.5.39" + "@vue/runtime-dom" "3.5.39" + "@vue/server-renderer" "3.5.39" + "@vue/shared" "3.5.39" + +vuetify@3.7.3: + version "3.7.3" + resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.3.tgz#0e89f7f0298d452510bcbc01b0e9b53a5ce6e883" + integrity sha512-bpuvBpZl1/+nLlXDgdVXekvMNR6W/ciaoa8CYlpeAzAARbY8zUFSoBq05JlLhkIHI58AnzKVy4c09d0OtfYAPg== + +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-url@^14.0.0, whatwg-url@^14.1.1: + version "14.2.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663" + integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw== + dependencies: + tr46 "^5.1.0" + webidl-conversions "^7.0.0" + +which-boxed-primitive@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.13: + version "1.1.22" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.22.tgz#8f3cc78aefb40b437346dd40a1dbfa5d1da43fe9" + integrity sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.9" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +ws@^8.18.0: + version "8.21.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== + +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/plugins.v3/subscribeassistantenhanced/guard.py b/plugins.v3/subscribeassistantenhanced/guard.py new file mode 100644 index 00000000..fb0c3f1f --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/guard.py @@ -0,0 +1,254 @@ +"""完成守卫:处理 CompletionCheck 事件并按证据流水线裁决是否完成。""" +from typing import Callable + +from app.log import logger +from app.schemas.event import SubscribeCompletionCheckEventData +from app.schemas.types import MediaType + +from .engine.types import CompletionEvidence, CompletionSignal, PendingTimeoutManagerProtocol +from .shared.log import detail +from .shared.subscribe import ( + format_subscribe, + is_full_best_version_subscribe, + resolve_subscribe_media_type, +) + + +class CompletionGuard: + """完成守卫:下载待定检查与完成证据流水线裁决。""" + + def __init__(self, + evidence_pipeline, + has_active_downloads_fn: Callable, + mark_pending_fn: Callable, + timeout_manager: PendingTimeoutManagerProtocol, + mode: str = "balanced", + pending_download_enabled: bool = True, + resolve_missing_fn: Callable = None): + """保存完成守卫依赖与下载中待定开关。""" + self.evidence_pipeline = evidence_pipeline + self.has_active_downloads_fn = has_active_downloads_fn + self.mark_pending_fn = mark_pending_fn + self.timeout_manager = timeout_manager + self.mode = mode + self.pending_download_enabled = pending_download_enabled + self.resolve_missing_fn = resolve_missing_fn + + def handle(self, event): + """CompletionCheck 链式事件处理入口:主程序只读取 event.event_data 上的输出字段。 + + 输入(subscribe/mediainfo)与输出(cancel/source/reason)一律操作 event.event_data; + 每个否决分支都写 source,避免主程序日志打出 [未知来源]。 + """ + data: SubscribeCompletionCheckEventData = event.event_data + if data is None: + return + subscribe = data.subscribe + + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.UNKNOWN: + return + + detail(f"完成守卫:收到完成检查 {format_subscribe(subscribe)}") + + if self.pending_download_enabled and self.has_active_downloads_fn(subscribe): + logger.info(f"完成守卫:{format_subscribe(subscribe)} 存在进行中的下载,否决完成(等待下载转移入库)") + data.cancel = True + data.source = "subscribeassistantenhanced" + data.reason = "存在进行中的下载,等待下载完成并转移入库" + return + + if media_type != MediaType.TV or is_full_best_version_subscribe(subscribe): + detail(f"完成守卫:{format_subscribe(subscribe)} 非普通/分集剧集订阅,跳过") + return + + evidence: CompletionEvidence = self.evidence_pipeline.evaluate( + subscribe, + data.mediainfo, + resolve_missing_fn=self.resolve_missing_fn, + meta=getattr(data, "meta", None), + consume_site_evidence=True, + ) + + if evidence.hard_veto is not None: + self._record_observation(data, subscribe, evidence.hard_veto, evidence) + return + + if self._is_active_high_completion(evidence): + self.timeout_manager.clear_release_token(subscribe) + detail( + f"完成守卫:{format_subscribe(subscribe)} 高置信完结," + f"按 {self.mode} 模式放行" + ) + return + + if ( + evidence.unstable_signal is not None + and not self._allow_unstable_target_complete(evidence) + ): + self._record_observation(data, subscribe, evidence.unstable_signal, evidence) + return + + if evidence.unstable_signal is not None: + self.timeout_manager.clear_release_token(subscribe) + detail( + f"完成守卫:{format_subscribe(subscribe)} F 不稳定但命中当前目标完成证据," + f"信号={self._signal_tags(evidence.target_complete_signal)},按 {self.mode} 模式放行" + ) + return + + if evidence.target_complete_signal is not None: + signal = evidence.target_complete_signal + if self.mode in ("balanced", "loose"): + self.timeout_manager.clear_release_token(subscribe) + detail( + f"完成守卫:{format_subscribe(subscribe)} 命中当前目标完成证据," + f"信号={self._signal_tags(signal)},按 {self.mode} 模式放行" + ) + return + self._record_observation(data, subscribe, signal, evidence) + return + + if self._is_medium_i_completion(evidence.i_signal): + self.timeout_manager.clear_release_token(subscribe) + detail( + f"完成守卫:{format_subscribe(subscribe)} 中置信完结," + f"按 {self.mode} 模式放行" + ) + return + + low_signal = self._low_signal(evidence) + if low_signal is not None: + if self._allow_low_confidence(low_signal): + self.timeout_manager.clear_release_token(subscribe) + detail( + f"完成守卫:{format_subscribe(subscribe)} 低置信完结," + f"按 {self.mode} 模式放行" + ) + return + self._consume_or_observe(data, subscribe, low_signal, evidence) + return + + self._block_completion( + data, + subscribe, + evidence.primary_signal, + reason=self._completion_block_reason(evidence), + ) + + @staticmethod + def _completion_block_reason(evidence: CompletionEvidence) -> str: + """普通未完成否决优先使用 L 失败诊断,避免用户只看到泛化无信号。""" + signal = evidence.primary_signal + if ( + signal.signals == ["none"] + and signal.reason == "无信号确认当前目标范围已播完" + and evidence.local_blocked_reason + and evidence.local_blocked_reason != "未命中 L" + ): + return evidence.local_blocked_reason + return signal.reason + + def _allow_unstable_target_complete(self, evidence: CompletionEvidence) -> bool: + """F up/普通波动只允许由当前目标完成证据在宽松策略下覆盖。""" + if evidence.target_complete_signal is None: + return False + if evidence.unstable_signal.volatility_direction == "down": + return False + return self.mode in ("balanced", "loose") + + @staticmethod + def _is_active_high_completion(evidence: CompletionEvidence) -> bool: + """只接受流水线已选为主结论的高置信完成证据。""" + return ( + evidence.high_completion is not None + and evidence.primary_signal == evidence.high_completion + ) + + @staticmethod + def _is_medium_i_completion(signal: CompletionSignal) -> bool: + """识别独立的 medium I 完成证据,不把它当作 target_complete 组合证据。""" + return ( + signal is not None + and signal.completed + and signal.confidence == "medium" + ) + + @staticmethod + def _low_signal(evidence: CompletionEvidence) -> CompletionSignal: + """返回需要按低置信策略裁决的单一 I/L 完成信号。""" + for signal in ( + evidence.i_low_signal, + evidence.local_signal, + evidence.primary_signal, + ): + if ( + signal is not None + and signal.completed + and signal.confidence == "low" + ): + return signal + return None + + @staticmethod + def _signal_tags(signal: CompletionSignal) -> str: + """把完成信号来源压缩成日志可读的组合标签。""" + return " + ".join(signal.signals or ["none"]) if signal else "none" + + def _allow_low_confidence(self, signal: CompletionSignal) -> bool: + """按守卫模式判断低置信 I/L 是否可立即完成。""" + if "L:target_satisfied" in signal.signals and ( + signal.scope_total < 3 or signal.scope_high_risk + ): + return False + if self.mode == "loose": + return True + if self.mode == "balanced": + return signal.scope_total >= 3 and not signal.scope_high_risk + return False + + def _consume_or_observe(self, data, subscribe, signal: CompletionSignal, + evidence: CompletionEvidence): + """完成证据未获策略直接放行时,消费令牌或进入完成前观察。""" + total_episode = self._signal_total(signal, evidence, subscribe) + if self.timeout_manager.consume_release_token( + subscribe, signal, total_episode=total_episode + ): + detail(f"完成守卫:{format_subscribe(subscribe)} 完成前观察已释放,放行完成") + return + self._record_observation(data, subscribe, signal, evidence, total_episode=total_episode) + + def _record_observation(self, data, subscribe, signal: CompletionSignal, + evidence: CompletionEvidence, total_episode: int = None): + """写入 guard_veto 观察前清理旧释放令牌,避免过期令牌跨信号放行。""" + total_episode = total_episode or self._signal_total(signal, evidence, subscribe) + self.timeout_manager.clear_release_token(subscribe) + logger.info( + f"完成守卫:{format_subscribe(subscribe)} 完成证据需观察({signal.reason})," + "进入完成前观察" + ) + data.cancel = True + data.source = "subscribeassistantenhanced" + data.reason = signal.reason + self.mark_pending_fn(subscribe, source="guard_veto", reason=signal.reason) + self.timeout_manager.record_observation( + subscribe, signal=signal, total_episode=total_episode + ) + + @staticmethod + def _signal_total(signal: CompletionSignal, evidence: CompletionEvidence, subscribe) -> int: + """观察期增集判断优先使用证据流水线的 SeasonScope 目标集数。""" + return signal.scope_total or evidence.scope_total or subscribe.total_episode + + def _block_completion(self, data, subscribe, signal: CompletionSignal, reason: str = None): + """记录普通完成否决并进入待定观察。""" + block_reason = reason or signal.reason + logger.info( + f"完成守卫:{format_subscribe(subscribe)} 未完结({block_reason})," + "否决完成、进入待定(P)并开始超时计时" + ) + data.cancel = True + data.source = "subscribeassistantenhanced" + data.reason = block_reason + self.mark_pending_fn(subscribe, source="guard_veto", reason=block_reason) + self.timeout_manager.record_observation(subscribe) diff --git a/plugins.v3/subscribeassistantenhanced/lifecycle/__init__.py b/plugins.v3/subscribeassistantenhanced/lifecycle/__init__.py new file mode 100644 index 00000000..6b528408 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/lifecycle/__init__.py @@ -0,0 +1,13 @@ +"""订阅生命周期编排接口。""" + +from .coordinator import ( + DownloadPendingLifecycleAdapter, + LifecycleResult, + SubscribeLifecycleCoordinator, +) + +__all__ = [ + "DownloadPendingLifecycleAdapter", + "LifecycleResult", + "SubscribeLifecycleCoordinator", +] diff --git a/plugins.v3/subscribeassistantenhanced/lifecycle/coordinator.py b/plugins.v3/subscribeassistantenhanced/lifecycle/coordinator.py new file mode 100644 index 00000000..9d0f86f7 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/lifecycle/coordinator.py @@ -0,0 +1,570 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Optional + +from app.log import logger + +from ..engine.signals import last_aired_episode +from ..engine.types import PauseRecord +from ..shared.log import detail +from ..shared.subscribe import format_subscribe, is_full_best_version_subscribe, subscribe_tmdb_id + + +def _format_lifecycle_subscribe(subscribe) -> str: + try: + return format_subscribe(subscribe) + except AttributeError: + return f"订阅 {getattr(subscribe, 'id', '未知')}" + + +@dataclass +class LifecycleResult: + """订阅生命周期操作结果,供入口日志、命令回复和测试稳定读取。""" + + changed: bool = False + stopped: bool = False + state: str | None = None + reason: str = "" + message: str = "" + + +class DownloadPendingLifecycleAdapter: + """下载模块使用的窄适配器,保持 DownloadMonitor 不感知完整生命周期编排。""" + + def __init__(self, coordinator: "SubscribeLifecycleCoordinator"): + self._coordinator = coordinator + + def mark_active(self, subscribe, source: str, reason: str = "") -> bool: + if source != "download_pending": + return False + return self._coordinator.mark_download_pending(subscribe, reason=reason).changed + + def clear_active(self, subscribe, source: str, reason: str = "") -> bool: + if source != "download_pending": + return False + return self._coordinator.release_pending_source( + subscribe, source="download_pending", reason=reason + ).changed + + +class SubscribeLifecycleCoordinator: + """订阅生命周期编排层,统一协调状态归属和状态变化副作用。""" + + def __init__( + self, + *, + config, + subscribe_oper, + pause_manager, + pending_judge, + pending_state, + airing_checker=None, + tmdb_episodes_fn: Optional[Callable] = None, + recognize_mediainfo_fn: Optional[Callable] = None, + is_tv_fn: Optional[Callable] = None, + schedule_initial_pending_search_fn: Optional[Callable] = None, + has_active_downloads_fn: Optional[Callable] = None, + clear_orphan_completion_observation_fn: Optional[Callable] = None, + clear_tasks_for_pause_fn: Optional[Callable] = None, + ): + self._config = config + self._subscribe_oper = subscribe_oper + self._pause_manager = pause_manager + self._pending_judge = pending_judge + self._pending_state = pending_state + self._airing = airing_checker + self._tmdb_episodes = tmdb_episodes_fn + self._recognize_mediainfo = recognize_mediainfo_fn + self._is_tv = is_tv_fn + self._schedule_initial_pending_search = schedule_initial_pending_search_fn + self._has_active_downloads = has_active_downloads_fn + self._clear_orphan_completion_observation = clear_orphan_completion_observation_fn + self._clear_tasks_for_pause = clear_tasks_for_pause_fn + + def download_pending_adapter(self) -> DownloadPendingLifecycleAdapter: + """返回下载待定窄适配器,供下载模块只操作自身持有的待定来源。""" + return DownloadPendingLifecycleAdapter(self) + + def handle_subscribe_added(self, subscribe, mediainfo=None, episodes=None) -> LifecycleResult: + """处理订阅新增后的暂停、待定和按播出进度暂停状态流转。""" + if not subscribe: + return LifecycleResult() + + if self._pause_manager and self._pause_manager.check_auto_pause_for_user(subscribe) is True: + detail(f"订阅新增:{_format_lifecycle_subscribe(subscribe)} 已按用户名规则暂停,跳过后续新增态判定") + return LifecycleResult(changed=True, stopped=True, state="S", reason="auto_user") + + if not mediainfo: + return LifecycleResult() + + is_tv_media = True if self._is_tv is None else bool(self._is_tv(mediainfo)) + resolved_episodes = list(episodes or []) + tmdb_id = subscribe_tmdb_id(subscribe) + if episodes is None and is_tv_media and self._tmdb_episodes and tmdb_id is not None: + resolved_episodes = list(self._tmdb_episodes( + tmdb_id, + subscribe.season, + episode_group=subscribe.episode_group, + ) or []) + + # 上映前暂停同时适用于电影和剧集,必须先于剧集专属待定/播出间隔流程判定。 + if self._airing and self._pause_manager: + record = self._airing.check_pre_air(subscribe, mediainfo, episodes=resolved_episodes) + if record: + logger.info(f"订阅新增:{_format_lifecycle_subscribe(subscribe)} 满足上映前暂停条件,置为禁用") + self._pause_manager.pause(subscribe, record) + return LifecycleResult(changed=True, stopped=True, state="S", reason=record.reason) + + if is_full_best_version_subscribe(subscribe): + detail(f"订阅新增:{_format_lifecycle_subscribe(subscribe)} 为全集洗版,跳过按集播出暂停/待定") + return LifecycleResult(stopped=True, reason="full_best_version") + + if self._is_tv is not None and not is_tv_media: + return LifecycleResult() + + if self._pending_judge: + pending = self.enter_pending_from_judge(subscribe, mediainfo, resolved_episodes) + if pending.stopped: + return pending + + # N 态订阅尚未跑完首轮搜索,不做播出间隔暂停;下载整理入库后由后续入口即时复核。 + if getattr(subscribe, "state", None) == "N": + detail(f"订阅新增:{_format_lifecycle_subscribe(subscribe)} 仍为新增态,跳过播出间隔暂停") + return LifecycleResult(state="N") + + return self._pause_after_library_update( + subscribe, mediainfo, resolved_episodes, source="订阅新增" + ) + + def handle_meta_check_subscription(self, subscribe, mediainfo=None, episodes=None) -> LifecycleResult: + """复核单个订阅的上映暂停、待定归属和待定退出。""" + if not subscribe: + return LifecycleResult() + + state = getattr(subscribe, "state", None) + changed = False + + record = self._pause_manager.get_pause_record(subscribe) if self._pause_manager else None + reason = record.reason if record else None + flag_paused = reason in ("no_download", "auto_user") + if flag_paused and state == "S": + detail(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 标记暂停({reason})且为禁用态,本轮跳过") + return LifecycleResult(stopped=True, state="S", reason=reason) + if flag_paused and state != "S" and self._pause_manager: + logger.info(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 用户已重新启用,清除插件暂停标记({reason})") + self._pause_manager.clear_pause_record(subscribe) + changed = True + + restored = self._restore_orphan_pending_if_needed(subscribe) + if restored.changed: + return restored + + mediainfo = self._resolve_mediainfo(subscribe, mediainfo) + if not mediainfo: + return LifecycleResult(changed=changed, state=state if changed else None) + + if is_full_best_version_subscribe(subscribe): + result = self._handle_full_best_version_meta_check(subscribe, mediainfo, episodes, state) + result.changed = result.changed or changed + return result + + if self._pending_judge and state == "P": + if self._pending_judge.check_exit(subscribe, mediainfo, self._tmdb_episodes): + state_after_release = "P" if self._pending_state.has_active(subscribe.id) else "R" + return LifecycleResult( + changed=True, + stopped=True, + state=state_after_release, + reason="待定释放巡检", + ) + + result = self._handle_airing_pause(subscribe, mediainfo, episodes, state) + changed = changed or result.changed + if result.stopped: + result.changed = changed + return result + + if bool(getattr(self._config, "pending_enhanced_enabled", False)) and self._pending_judge: + resolved_episodes = self._resolve_episodes(subscribe, mediainfo, episodes) + pending = self.enter_pending_from_judge(subscribe, mediainfo, resolved_episodes) + pending.changed = pending.changed or changed + if pending.stopped or pending.changed: + return pending + + return LifecycleResult(changed=changed, state=state if changed else None) + + def handle_download_added_for_subscribe(self, subscribe, reason: str = "") -> LifecycleResult: + """下载事实命中暂停订阅时恢复订阅,并维护同原因防打回窗口。""" + if not (self._pause_manager and subscribe and getattr(subscribe, "state", None) == "S"): + return LifecycleResult() + + record = self._pause_manager.get_pause_record(subscribe) + if not record: + self._pause_manager.adopt_external(subscribe) + record = self._pause_manager.get_pause_record(subscribe) + if not record: + logger.info(f"DownloadAdded:{_format_lifecycle_subscribe(subscribe)} 暂停记录缺失,跳过下载命中恢复") + return LifecycleResult() + + pause_reason = record.reason + if not self._pause_manager.resume(subscribe, notify=False): + logger.info(f"DownloadAdded:{_format_lifecycle_subscribe(subscribe)} 暂停恢复未生效,原因={pause_reason}") + return LifecycleResult(state="S", reason=pause_reason) + + self._pause_manager.clear_probe_fields_for_resume(subscribe) + guard_written = False + if pause_reason != "external": + guard_written = bool(self._pause_manager.set_resume_guard(subscribe, pause_reason, hours=48)) + message = f"已因下载命中恢复暂停订阅,原暂停原因={pause_reason},写入防打回={guard_written}" + logger.info(f"DownloadAdded:{_format_lifecycle_subscribe(subscribe)} {message}") + return LifecycleResult(changed=True, state="R", reason=pause_reason, message=message) + + def handle_library_updated(self, subscribe_id: int | None = None, reason: str = "") -> LifecycleResult: + """整理入库后即时复核播出暂停,避免短窗口配置只能等周期巡检。""" + if not subscribe_id: + return LifecycleResult(reason=reason) + subscribe = self._get_subscribe(subscribe_id) + if not subscribe: + return LifecycleResult(reason=reason) + state = getattr(subscribe, "state", None) + if state != "R": + return LifecycleResult(state=state, reason=reason) + if is_full_best_version_subscribe(subscribe): + return LifecycleResult(reason="full_best_version") + + mediainfo = self._resolve_mediainfo(subscribe) + if not mediainfo or not self._is_tv_media(mediainfo): + return LifecycleResult(reason=reason) + episodes = [] + tmdb_id = subscribe_tmdb_id(subscribe) + if self._tmdb_episodes and tmdb_id is not None: + episodes = list(self._tmdb_episodes( + tmdb_id, + subscribe.season, + episode_group=subscribe.episode_group, + ) or []) + return self._pause_after_library_update( + subscribe, + mediainfo, + episodes, + source=reason or "TransferComplete", + ) + + def handle_subscribe_modified_state_change( + self, + subscribe, + old_state: str | None, + new_state: str | None = None, + ) -> LifecycleResult: + """接管订阅 S/R 外部状态变化的插件侧暂停记录归属。""" + new_state = new_state or getattr(subscribe, "state", None) + if old_state != "S" and new_state == "S": + changed = bool(self._pause_manager and self._pause_manager.adopt_external(subscribe)) + return LifecycleResult(changed=changed, state="S", reason="external") + if old_state == "S" and new_state != "S": + if self._pause_manager: + self._pause_manager.clear_pause_record(subscribe) + return LifecycleResult(changed=True, state=new_state, reason="external") + return LifecycleResult(state=new_state) + + def enter_pending_from_judge(self, subscribe, mediainfo, episodes, state: str | None = None) -> LifecycleResult: + """按待定判定结果进入 P,并保证新增态补搜在待定归属写入前调度。""" + should_pending, reason = self._pending_judge.should_enter_pending(subscribe, mediainfo, episodes) + if not should_pending: + return LifecycleResult(reason=reason or "") + + if getattr(subscribe, "state", None) == "N" and self._schedule_initial_pending_search: + self._schedule_initial_pending_search(subscribe) + + self._pending_judge.mark_pending(subscribe, source="pending_judge", reason=reason) + return LifecycleResult( + changed=True, + stopped=True, + state=state or "P", + reason=reason, + ) + + def enter_guard_pending(self, subscribe, reason: str) -> LifecycleResult: + """把完成守卫否决登记为独立待定来源。""" + self._pending_judge.mark_pending(subscribe, source="guard_veto", reason=reason) + return LifecycleResult(changed=True, stopped=True, state="P", reason=reason) + + def mark_download_pending(self, subscribe, reason: str) -> LifecycleResult: + """登记下载待定来源,由 PendingStateCoordinator 负责 P/R 仲裁。""" + changed = self._pending_state.mark_active(subscribe, source="download_pending", reason=reason) + return LifecycleResult(changed=changed, state="P" if changed else None, reason=reason) + + def clear_download_pending(self, subscribe_id: int, key: str = "", reason: str = "") -> LifecycleResult: + """按订阅 ID 释放下载待定来源;下载任务明细键由下载模块自身维护。""" + subscribe = self._get_subscribe(subscribe_id) + if not subscribe: + return LifecycleResult(reason=reason) + return self.release_pending_source(subscribe, source="download_pending", reason=reason) + + def release_pending_source(self, subscribe, source: str, reason: str) -> LifecycleResult: + """释放指定待定来源,并按剩余来源决定是否恢复启用态。""" + if source in ("pending_judge", "guard_veto") and self._pending_judge: + if self._has_active_downloads: + self._has_active_downloads(subscribe.id) + mediainfo = self._resolve_mediainfo(subscribe) + if not mediainfo: + return LifecycleResult(reason=reason) + changed = bool(self._pending_judge.check_exit( + subscribe, + mediainfo, + self._tmdb_episodes, + source=source, + )) + state = "P" if changed and self._pending_state.has_active(subscribe.id) else "R" + return LifecycleResult( + changed=changed, + stopped=changed, + state=state if changed else None, + reason=reason, + ) + changed = self._pending_state.clear_active(subscribe, source=source, reason=reason) + return LifecycleResult(changed=changed, state="R" if changed else None, reason=reason) + + def reconcile_pending(self, subscribe, reason: str) -> LifecycleResult: + """恢复缺少有效生命周期归属的待定残留。""" + if self._has_active_downloads and self._has_active_downloads(subscribe.id): + return LifecycleResult(reason=reason) + if self._pending_state.has_active(subscribe.id): + return LifecycleResult(reason=reason) + changed = self._pending_state.reconcile_orphaned(subscribe, reason=reason) + if changed and self._clear_orphan_completion_observation: + self._clear_orphan_completion_observation(subscribe) + return LifecycleResult(changed=changed, state="R" if changed else None, reason=reason) + + def toggle_subscribe_by_user_command(self, subscribe) -> LifecycleResult: + """用户命令切换订阅状态时,把 S 视为外部暂停并静默恢复。""" + if getattr(subscribe, "state", None) == "S": + if not self._pause_manager.get_pause_record(subscribe): + self._pause_manager.adopt_external(subscribe, detail="插件命令手动暂停") + changed = self._pause_manager.resume(subscribe, notify=False) + return LifecycleResult(changed=changed, state="R" if changed else None) + + record = PauseRecord(reason="external", detail="插件命令手动暂停") + changed = self._pause_manager.pause(subscribe, record, notify=False) + return LifecycleResult(changed=changed, state="S" if changed else None, reason=record.reason) + + def restore_owned_states_before_reset(self) -> LifecycleResult: + """重置前恢复增强版明确持有的待定状态,避免残留 P 状态失去来源。""" + reason = "插件任务重置" + recovered_pending = [] + recovered_paused = [] + for subscribe in self._list_subscribes(state="P"): + if self._pending_state.clear_all_owned(subscribe, reason=reason): + recovered_pending.append(_format_lifecycle_subscribe(subscribe)) + if self._pause_manager: + for subscribe in self._list_subscribes(state="S"): + record = self._pause_manager.get_pause_record(subscribe) + if record and record.reason in ("pre_air", "airing_gap"): + if self._pause_manager.resume(subscribe, notify=False): + recovered_paused.append(_format_lifecycle_subscribe(subscribe)) + changed = bool(recovered_pending or recovered_paused) + return LifecycleResult( + changed=changed, + state="R" if changed else None, + reason=reason, + message=self._format_reset_recovery_summary(recovered_pending, recovered_paused), + ) + + def pause_for_no_download(self, subscribe, reason: str) -> LifecycleResult: + """因长期无下载进入标记暂停,并清理暂停覆盖下不应继续执行的待定任务。""" + record = PauseRecord(reason="no_download", detail=reason) + changed = bool(self._pause_manager.pause(subscribe, record, notify=True)) + if changed and self._clear_tasks_for_pause: + self._clear_tasks_for_pause(subscribe.id) + return LifecycleResult(changed=changed, state="S" if changed else None, reason=reason) + + def _restore_orphan_pending_if_needed(self, subscribe) -> LifecycleResult: + """P 态缺少活跃归属时先恢复,避免后续暂停或待定复核接管残留状态。""" + if getattr(subscribe, "state", None) != "P" or not self._pending_state: + return LifecycleResult() + if self._has_active_downloads and self._has_active_downloads(subscribe.id): + return LifecycleResult() + if self._pending_state.has_active(subscribe.id): + return LifecycleResult() + reason = "无有效待定来源,状态恢复" + changed = self._pending_state.reconcile_orphaned(subscribe, reason=reason) + if changed and self._clear_orphan_completion_observation: + self._clear_orphan_completion_observation(subscribe) + return LifecycleResult( + changed=changed, + stopped=changed, + state="R" if changed else None, + reason=reason, + ) + + def _handle_full_best_version_meta_check(self, subscribe, mediainfo, episodes, state: str | None) -> LifecycleResult: + """全集洗版只参与上映前暂停复核,不进入按集待定或播出间隔流程。""" + if state != "N" and self._airing and self._pause_manager: + resolved_episodes = self._resolve_episodes(subscribe, mediainfo, episodes) + record_now = self._airing.check_pre_air(subscribe, mediainfo, episodes=resolved_episodes) + if record_now: + current_record = self._pause_manager.get_pause_record(subscribe) if state == "S" else None + if state != "S": + logger.info(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 满足{record_now.reason}暂停条件,置为禁用") + changed = bool(self._pause_manager.pause(subscribe, record_now)) + return LifecycleResult(changed=changed, stopped=True, state="S" if changed else state, + reason=record_now.reason) + if current_record: + refreshed = bool(self._pause_manager.pause(subscribe, record_now, notify=False)) + changed = refreshed and current_record.reason != record_now.reason + return LifecycleResult(changed=changed, stopped=True, state="S", reason=record_now.reason) + return LifecycleResult(stopped=True, state="S", reason=record_now.reason) + if state == "S": + current_record = self._pause_manager.get_pause_record(subscribe) + current_reason = current_record.reason if current_record else None + if current_reason != "pre_air": + detail( + f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 全集洗版仅恢复上映前暂停记录," + f"当前暂停原因={current_reason or '无'},本轮不恢复" + ) + return LifecycleResult(stopped=True, state="S", reason=current_reason or "") + logger.info(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 上映/播出暂停条件解除,恢复订阅") + changed = bool(self._pause_manager.resume(subscribe)) + return LifecycleResult(changed=changed, stopped=True, state="R" if changed else "S", + reason=current_reason) + return LifecycleResult(stopped=True, reason="full_best_version") + + def _handle_airing_pause(self, subscribe, mediainfo, episodes, state: str | None) -> LifecycleResult: + """按上映前和播出间隔规则处理自动暂停,并只恢复插件拥有的暂停记录。""" + if state == "N" or not (self._airing and self._pause_manager): + return LifecycleResult() + + is_tv_media = self._is_tv_media(mediainfo) + resolved_episodes = self._resolve_episodes(subscribe, mediainfo, episodes) if is_tv_media else [] + record_now = self._airing.check_pre_air(subscribe, mediainfo, episodes=resolved_episodes) + if not record_now and is_tv_media: + record_now = self._airing.check( + subscribe, + mediainfo, + next_episode=mediainfo.next_episode_to_air, + latest_episode=last_aired_episode(resolved_episodes), + episodes=resolved_episodes, + ) + if record_now: + current_record = self._pause_manager.get_pause_record(subscribe) if state == "S" else None + if state != "S": + logger.info(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 满足{record_now.reason}暂停条件,置为禁用") + changed = bool(self._pause_manager.pause(subscribe, record_now)) + return LifecycleResult(changed=changed, stopped=True, state="S" if changed else state, + reason=record_now.reason) + if current_record: + refreshed = bool(self._pause_manager.pause(subscribe, record_now, notify=False)) + changed = refreshed and current_record.reason != record_now.reason + return LifecycleResult(changed=changed, stopped=True, state="S", reason=record_now.reason) + return LifecycleResult(stopped=True, state="S", reason=record_now.reason) + + if state == "S": + current_record = self._pause_manager.get_pause_record(subscribe) + current_reason = current_record.reason if current_record else None + if current_reason not in ("pre_air", "airing_gap"): + detail(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 非插件上映/播出暂停,本轮不恢复") + return LifecycleResult(stopped=True, state="S", reason=current_reason or "") + if current_reason == "airing_gap": + should_resume = self._airing.should_resume_airing_gap( + subscribe, + mediainfo, + next_episode=mediainfo.next_episode_to_air, + episodes=resolved_episodes if is_tv_media else [], + current_record=current_record, + ) + if not should_resume: + detail(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 播出暂停记录保留,等待明确下一集窗口释放") + return LifecycleResult(stopped=True, state="S", reason=current_reason) + logger.info(f"元数据巡检:{_format_lifecycle_subscribe(subscribe)} 上映/播出暂停条件解除,恢复订阅") + changed = bool(self._pause_manager.resume(subscribe)) + return LifecycleResult(changed=changed, state="R" if changed else "S", reason=current_reason) + + return LifecycleResult() + + def _resolve_mediainfo(self, subscribe, mediainfo=None): + """复用已识别媒体信息;未传入时调用入口注入的识别函数。""" + if mediainfo is not None: + return mediainfo + if not self._recognize_mediainfo: + return None + return self._recognize_mediainfo(subscribe) + + def _resolve_episodes(self, subscribe, mediainfo, episodes=None) -> list: + """按订阅季和 episode_group 读取 TMDB 分集列表;非剧集按空列表处理。""" + if episodes is not None: + return list(episodes or []) + if not self._is_tv_media(mediainfo) or not self._tmdb_episodes: + return [] + tmdb_id = subscribe_tmdb_id(subscribe) + if tmdb_id is None: + return [] + return list(self._tmdb_episodes( + tmdb_id, + subscribe.season, + episode_group=subscribe.episode_group, + ) or []) + + def _is_tv_media(self, mediainfo) -> bool: + """统一封装媒体类型判断;未注入判断器时按剧集兼容旧测试替身。""" + if self._is_tv is None: + return True + return bool(self._is_tv(mediainfo)) + + @staticmethod + def _format_reset_recovery_summary(recovered_pending: list[str], recovered_paused: list[str]) -> str: + """生成插件任务数据重置前的订阅状态恢复汇总。""" + lines = [] + if recovered_pending: + lines.append(f"已将 {len(recovered_pending)} 个待定订阅恢复为启用:" + "、".join(recovered_pending)) + if recovered_paused: + lines.append(f"已将 {len(recovered_paused)} 个自动暂停订阅恢复为启用:" + "、".join(recovered_paused)) + return "\n".join(lines) + + def _pause_after_library_update(self, subscribe, mediainfo, episodes: list, source: str) -> LifecycleResult: + """媒体库状态已更新后,按当前播出窗口决定是否暂停订阅。""" + if not (self._airing and self._pause_manager): + return LifecycleResult() + record = self._airing.check( + subscribe, + mediainfo, + next_episode=getattr(mediainfo, "next_episode_to_air", None), + latest_episode=last_aired_episode(episodes), + episodes=episodes, + ) + if not record: + return LifecycleResult() + logger.info(f"{source}:{_format_lifecycle_subscribe(subscribe)} 满足播出间隔暂停条件,置为禁用") + changed = self._pause_manager.pause(subscribe, record) + return LifecycleResult( + changed=bool(changed), + stopped=bool(changed), + state="S" if changed else None, + reason=record.reason, + ) + + def _get_subscribe(self, subscribe_id: int): + """从订阅表读取订阅对象;缺少读取依赖时返回空。""" + if not self._subscribe_oper: + return None + getter = getattr(self._subscribe_oper, "get", None) + if not getter: + return None + try: + return getter(subscribe_id) + except Exception as err: + logger.warning(f"生命周期编排:读取订阅 {subscribe_id} 失败,错误:{err}") + return None + + def _list_subscribes(self, **kwargs) -> list: + """从订阅表读取订阅列表;读取失败时按空列表处理,避免重置流程中断。""" + if not self._subscribe_oper: + return [] + lister = getattr(self._subscribe_oper, "list", None) + if not lister: + return [] + try: + return list(lister(**kwargs) or []) + except Exception as err: + logger.warning(f"生命周期编排:读取订阅列表失败,错误:{err}") + return [] diff --git a/plugins.v3/subscribeassistantenhanced/pause/__init__.py b/plugins.v3/subscribeassistantenhanced/pause/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/pause/airing.py b/plugins.v3/subscribeassistantenhanced/pause/airing.py new file mode 100644 index 00000000..bdff9bac --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pause/airing.py @@ -0,0 +1,185 @@ +"""播出暂停:完结信号前置过滤后按间隔判断是否暂停订阅。""" +import re +from datetime import date, timedelta +from typing import Optional + +from app.schemas.types import MediaType + +from ..engine.types import CompletionSignal, PauseRecord +from ..shared.media import ( + episode_candidates_after, + episode_field, + date_context, + first_available_scope_episode_air_date, + get_tv_season_air_date, + parse_date, + resolve_airing_next_episode, +) +from ..shared.subscribe import resolve_subscribe_media_type + + +class AiringPauseChecker: + """播出暂停判定:完结信号确认后不暂停,否则按间隔判断。""" + + def __init__(self, pause_days: int, evidence_pipeline, + movie_air_days: int = 0, tv_air_days: int = 0): + """保存播出间隔与上映前暂停阈值。""" + self._pause_days = pause_days + self._evidence_pipeline = evidence_pipeline + self._movie_air_days = movie_air_days + self._tv_air_days = tv_air_days + + def check_pre_air(self, subscribe, mediainfo, + as_of: Optional[date] = None, + episodes: Optional[list] = None) -> Optional[PauseRecord]: + """检查电影上映或剧集开播前是否应暂停。""" + today = as_of or date.today() + media_type = resolve_subscribe_media_type(subscribe) + + if media_type == MediaType.MOVIE: + if not self._movie_air_days: + return None + release_date = parse_date(mediainfo.release_date) + if release_date is None: + # 上映日期无法解析时默认暂停,避免在不明窗口期下载 + return PauseRecord( + reason="pre_air", + since=0.0, + detail="上映日期未知,暂停等待", + ) + if today < release_date - timedelta(days=self._movie_air_days): + return PauseRecord( + reason="pre_air", + since=0.0, + detail=f"{date_context('上映日期', release_date, as_of=today)},暂未到订阅窗口", + ) + return None + + if media_type != MediaType.TV: + return None + + if not self._tv_air_days: + return None + air_date = parse_date(get_tv_season_air_date( + mediainfo, + subscribe.season, + )) + if air_date is None: + air_date = first_available_scope_episode_air_date(subscribe, episodes or []) + if air_date is None: + # 剧集没有任何可用排期时保持暂停,避免未知开播窗口被集数待定提前接管。 + return PauseRecord( + reason="pre_air", + since=0.0, + detail="开播日期未知", + ) + if today < air_date - timedelta(days=self._tv_air_days): + return PauseRecord( + reason="pre_air", + since=0.0, + detail=f"{date_context('开播日期', air_date, as_of=today)},暂未到订阅窗口", + ) + return None + + def check(self, subscribe, mediainfo, next_episode, latest_episode, + episodes: Optional[list] = None, + as_of: Optional[date] = None) -> Optional[PauseRecord]: + """按聚合字段、SeasonScope 和 note 首待下载集检查是否应播出暂停。""" + today = as_of or date.today() + + signal: CompletionSignal = self._evidence_pipeline.evaluate( + subscribe, + mediainfo, + ).primary_signal + if signal.completed: + return None + + resolved_next = resolve_airing_next_episode( + subscribe, + next_episode, + episodes or [], + as_of=today, + ) + if resolved_next: + next_air_date = episode_field(resolved_next, "air_date") + air = parse_date(next_air_date) + if air: + days_until = (air - today).days + if days_until > self._pause_days: + return PauseRecord( + reason="airing_gap", + since=0.0, + detail=f"{date_context('下一集日期', air, as_of=today)}", + ) + return None + + return None + + def should_resume_airing_gap(self, subscribe, mediainfo, next_episode, + episodes: Optional[list] = None, + current_record: Optional[PauseRecord] = None, + as_of: Optional[date] = None) -> bool: + """判断已有 airing_gap 暂停是否具备明确恢复证据。""" + today = as_of or date.today() + + signal: CompletionSignal = self._evidence_pipeline.evaluate( + subscribe, + mediainfo, + ).primary_signal + if signal.completed: + return True + + paused_air_date = self._parse_pause_air_date(current_record) + if paused_air_date and paused_air_date <= today: + return True + + resolved_next = self._resolve_airing_resume_episode( + subscribe, + next_episode, + episodes or [], + as_of=today, + ) + if not resolved_next: + return False + next_air_date = episode_field(resolved_next, "air_date") + air = parse_date(next_air_date) + if not air: + return False + return (air - today).days <= self._pause_days + + def _resolve_airing_resume_episode(self, subscribe, aggregate_episode, + episodes: list, as_of: date): + """解析 airing_gap 恢复使用的窗口集,允许下一集已进入下载窗口。""" + resolved_next = resolve_airing_next_episode( + subscribe, + aggregate_episode, + episodes, + as_of=as_of, + ) + if resolved_next: + return resolved_next + window_start = as_of - timedelta(days=self._pause_days) + candidates = [ + episode + for episode in episode_candidates_after(subscribe, episodes, date.min) + if parse_date(episode_field(episode, "air_date")) >= window_start + ] + if not candidates: + return None + return min( + candidates, + key=lambda episode: ( + parse_date(episode_field(episode, "air_date")), + episode_field(episode, "episode_number", 0), + ), + ) + + @staticmethod + def _parse_pause_air_date(record: Optional[PauseRecord]) -> Optional[date]: + """从已保存的播出暂停说明中提取当时的下一集日期。""" + if not record or record.reason != "airing_gap" or not record.detail: + return None + match = re.search(r"\d{4}-\d{2}-\d{2}", record.detail) + if not match: + return None + return parse_date(match.group(0)) diff --git a/plugins.v3/subscribeassistantenhanced/pause/manager.py b/plugins.v3/subscribeassistantenhanced/pause/manager.py new file mode 100644 index 00000000..8f3f60d7 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pause/manager.py @@ -0,0 +1,309 @@ +"""暂停管理:处理优先级覆盖、用户名自动暂停和双向恢复。""" +import re +import time +from typing import Callable, Optional + +from app.log import logger + +from ..engine.types import PauseRecord +from ..shared.log import detail as log_detail +from ..shared.subscribe import format_subscribe +from ..shared.update import update_subscribe + +# 只为 PauseManager 持有的暂停原因定义覆盖顺序;其他业务场景不写 pause_reason 参与优先级竞争。 +# 未列出的兼容/异常原因按 0 处理,数值越大越优先。 +# airing_gap 比 pre_air 更具体;auto_user/no_download 属于标记暂停,不被可自动恢复暂停接管; +# external 代表用户或外部系统的暂停事实,始终拥有最高优先级。 +PRIORITY_ORDER = {"pre_air": 0, "airing_gap": 1, "auto_user": 2, "no_download": 2, "external": 3} + + +class PauseManager: + """暂停优先级管理与恢复协调。""" + + def __init__(self, task_data_read: Callable, task_data_update: Callable, + subscribe_oper=None, auto_pause_users: Optional[list] = None, + notify_fn: Optional[Callable] = None, pending_state=None, + pause_enhanced_enabled: bool = True): + """注入任务数据、订阅写库、用户名规则和状态通知回调。""" + self._read = task_data_read + self._update = task_data_update + self._subscribe_oper = subscribe_oper + self._auto_pause_users = auto_pause_users or [] + self._notify = notify_fn + self._pending_state = pending_state + self._pause_enhanced_enabled = pause_enhanced_enabled + + def pause(self, subscribe, record: PauseRecord, notify: bool = True): + """设置暂停,仅当新原因优先级 >= 当前原因时生效;可静默刷新内部归因。""" + if self._pause_enhanced_enabled and self._is_guarded(subscribe, record): + return False + + current = self.get_pause_record(subscribe) + if current: + cur_prio = PRIORITY_ORDER.get(current.reason, 0) + new_prio = PRIORITY_ORDER.get(record.reason, 0) + if new_prio < cur_prio: + log_detail(f"暂停管理:{format_subscribe(subscribe)} 新暂停原因 {record.reason} 优先级低于现有 {current.reason},不覆盖") + return False + + if not record.since: + record.since = time.time() + + sid = str(subscribe.id) + is_refresh = current is not None and current.reason == record.reason + if is_refresh: + log_detail( + f"暂停刷新:{format_subscribe(subscribe)} 暂停原因仍满足," + f"原因={record.reason},detail={record.detail}" + ) + elif not notify: + log_detail(f"暂停管理:{format_subscribe(subscribe)} 静默刷新暂停记录(原因={record.reason},detail={record.detail})") + else: + log_detail(f"暂停管理:{format_subscribe(subscribe)} 写暂停记录(原因={record.reason},detail={record.detail})并置订阅为禁用(S)") + if self._pending_state: + self._pending_state.clear_for_pause(subscribe, reason=f"暂停覆盖:{record.reason}") + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + task["pause_reason"] = record.reason + task["pause_since"] = record.since + task["pause_detail"] = record.detail + data[sid] = task + return data + + self._update("subscribes", updater) + + if self._subscribe_oper and not is_refresh and subscribe.state != "S": + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "S"}) + if notify and not is_refresh: + self._notify_pause(subscribe, record) + return not is_refresh + + def adopt_external(self, subscribe, detail: str = "外部暂停") -> bool: + """静默登记外部暂停事实;已有插件暂停记录时保留首次归因。""" + if subscribe.state != "S": + return False + if self.get_pause_record(subscribe): + return False + sid = str(subscribe.id) + now = time.time() + external_detail = detail or "外部暂停" + log_detail(f"暂停管理:{format_subscribe(subscribe)} 登记外部暂停(detail={external_detail})") + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + if task.get("pause_reason"): + data[sid] = task + return data + task["pause_reason"] = "external" + task["pause_since"] = now + task["pause_detail"] = external_detail + data[sid] = task + return data + + self._update("subscribes", updater) + return True + + def clear_probe_schedule(self, subscribe, include_last: bool = False): + """清理当前主动补搜调度字段;按调用场景决定是否重置限频时间。""" + sid = str(subscribe.id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + self._drop_probe_schedule_fields(task, include_last=include_last) + data[sid] = task + return data + + self._update("subscribes", updater) + + def clear_probe_fields_for_resume(self, subscribe): + """下载命中恢复后清理全部主动补搜调度字段,保留恢复防打回窗口。""" + self.clear_probe_schedule(subscribe, include_last=True) + + def set_resume_guard(self, subscribe, reason: str, hours: int = 48) -> bool: + """记录下载命中恢复后的同原因防打回窗口;external 不参与自动原因保护。""" + if reason == "external": + log_detail(f"暂停管理:{format_subscribe(subscribe)} 外部暂停恢复不写防打回保护") + return False + sid = str(subscribe.id) + until = time.time() + max(int(hours), 0) * 3600 + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + task["paused_probe_resume_guard_reason"] = reason + task["paused_probe_resume_guard_until"] = until + data[sid] = task + return data + + self._update("subscribes", updater) + return True + + def resume(self, subscribe, notify: bool = True): + """恢复订阅:清插件暂停记录并把订阅状态置回 R。 + + 是否调用 resume 的判定(标记暂停跳过、上映条件双向恢复)由上层巡检负责。 + """ + record = self.get_pause_record(subscribe) + if not record: + log_detail(f"暂停管理:{format_subscribe(subscribe)} 无插件暂停记录,跳过恢复") + return False + log_detail(f"暂停管理:{format_subscribe(subscribe)} 清暂停记录并置订阅为启用(R)") + sid = str(subscribe.id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + self._drop_pause_fields(task) + self._drop_probe_schedule_fields(task, include_last=False) + data[sid] = task + return data + + self._update("subscribes", updater) + + if self._subscribe_oper: + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "R"}) + if notify: + self._notify_resume(subscribe, record) + return True + + def clear_pause_record(self, subscribe): + """清理插件侧暂停记录元数据,但不改订阅状态本身。 + + 用于订阅状态被用户/外部变更后重置插件的暂停跟踪; + 与 resume 区别:resume 会把订阅状态置为 R,本方法仅丢弃插件记录、把状态归属交还调用方。 + """ + log_detail(f"暂停管理:{format_subscribe(subscribe)} 仅清插件暂停记录(不改订阅状态)") + sid = str(subscribe.id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + self._drop_pause_fields(task) + self._drop_probe_schedule_fields(task, include_last=False) + data[sid] = task + return data + + self._update("subscribes", updater) + + def get_pause_record(self, subscribe) -> Optional[PauseRecord]: + """读取当前插件侧暂停记录;无记录返回 None。 + + 不对“无记录但 state=S”合成暂停记录;外部暂停由 adopt_external() 显式接管, + 便于保留首次发现时间并区分用户/外部暂停与插件自动暂停。 + """ + sid = str(subscribe.id) + data = self._read("subscribes") + task = data.get(sid, {}) + reason = task.get("pause_reason") + if not reason: + return None + return PauseRecord( + reason=reason, + since=task.get("pause_since", 0.0), + detail=task.get("pause_detail", ""), + ) + + def check_auto_pause_for_user(self, subscribe) -> bool: + """检查是否应按用户名自动暂停新增订阅。 + + 新增订阅用户在名单内时写入 reason=auto_user 的标记暂停:元数据巡检在 state=S 时跳过, + 不被上映检查自动恢复;用户重新启用后再清标记。 + """ + if not self._auto_pause_users: + return False + username = subscribe.username + if username in self._auto_pause_users: + logger.info(f"暂停管理:{format_subscribe(subscribe)} 用户 {username} 在自动暂停名单内,标记暂停") + self.pause(subscribe, PauseRecord( + reason="auto_user", + since=time.time(), + detail=f"用户 {username} 的订阅自动暂停", + )) + return True + return False + + def _notify_pause(self, subscribe, record: PauseRecord): + """发送暂停状态通知;无下载流程由外层统一通知,避免重复消息。""" + if not self._notify or record.reason == "no_download": + return + reason = { + "pre_air": "上映", + "airing_gap": "播出", + "auto_user": "用户规则", + }.get(record.reason, record.reason) + self._notify( + subscribe, + f"{reason}满足订阅暂停,已标记暂停", + detail=record.detail, + ) + + def _notify_resume(self, subscribe, record: Optional[PauseRecord]): + """发送暂停恢复状态通知。""" + if not self._notify: + return + reason_key = record.reason if record else "" + reason = { + "pre_air": "上映", + "airing_gap": "播出", + "auto_user": "用户规则", + }.get(reason_key, "暂停") + detail = self._resume_detail(reason_key, record) + self._notify( + subscribe, + f"{reason}不再满足订阅暂停,已标记订阅中", + detail=detail, + ) + + def _is_guarded(self, subscribe, record: PauseRecord) -> bool: + """判断下载命中恢复后的同原因保护窗口是否拦截本次自动暂停。""" + if record.reason == "external": + return False + sid = str(subscribe.id) + task = self._read("subscribes").get(sid, {}) + guard_reason = task.get("paused_probe_resume_guard_reason") + guard_until = task.get("paused_probe_resume_guard_until") or 0 + if record.reason != guard_reason: + return False + now = time.time() + if now >= float(guard_until): + return False + remaining = int(float(guard_until) - now) + log_detail( + f"暂停管理:{format_subscribe(subscribe)} 同原因 {record.reason} 仍在恢复保护窗口内," + f"剩余 {remaining} 秒,跳过自动暂停" + ) + return True + + @staticmethod + def _drop_pause_fields(task: dict): + """删除当前暂停归因字段,不影响 probe 限频和恢复保护字段。""" + task.pop("pause_reason", None) + task.pop("pause_since", None) + task.pop("pause_detail", None) + + @staticmethod + def _drop_probe_schedule_fields(task: dict, include_last: bool = False): + """删除主动补搜调度字段;恢复场景可同时删除上次安排时间。""" + task.pop("paused_probe_scheduled_run_at", None) + task.pop("paused_probe_reason", None) + if include_last: + task.pop("paused_probe_last_scheduled_at", None) + + @staticmethod + def _resume_detail(reason_key: str, record: Optional[PauseRecord]) -> str: + """生成恢复通知正文,保留原暂停窗口上下文但改写为当前状态。""" + pause_detail = record.detail if record else "" + if reason_key == "pre_air": + pause_detail = re.sub(r"^(?:电影|电视剧|剧集)\s+", "", pause_detail) + pause_detail = re.sub(r",距今\s+\d+\s+天", "", pause_detail) + pause_detail = re.sub(r",已过\s+\d+\s+天", "", pause_detail) + pause_detail = pause_detail.replace(",今天", "") + if pause_detail and "暂未到订阅窗口" in pause_detail: + return pause_detail.replace("暂未到订阅窗口", "已进入订阅窗口") + return "已进入订阅窗口" + if reason_key == "airing_gap": + match = re.search(r"(下一集(?:日期:|\s+)\d{4}-\d{2}-\d{2})", pause_detail) + if match: + return f"{match.group(1)},已进入播出窗口" + return "已进入播出窗口" + if reason_key == "auto_user": + return "用户规则暂停已解除" + return "暂停条件已解除" diff --git a/plugins.v3/subscribeassistantenhanced/pause/nodownload.py b/plugins.v3/subscribeassistantenhanced/pause/nodownload.py new file mode 100644 index 00000000..44537bec --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pause/nodownload.py @@ -0,0 +1,115 @@ +"""无下载处理策略:上映后超期且无下载时按配置暂停、完成或删除订阅。""" +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Optional + +from app.schemas.types import MediaType + +from ..shared.log import detail +from ..shared.media import date_context, get_tv_season_air_date, parse_date, relative_day_text +from ..shared.subscribe import format_subscribe, resolve_subscribe_media_type + + +@dataclass(frozen=True) +class NoDownloadDecision: + """无下载策略命中结果,供日志和通知复用同一份用户可读原因。""" + + action: str + reason: str + air_date: date + deadline: date + days: int + + +class NoDownloadPolicy: + """无下载处理策略:按媒体类型在上映后超期且无下载时给出动作。""" + + def __init__(self, movie_days: int = 0, tv_days: int = 0, + actions: Optional[list] = None): + """保存电影、剧集的超期天数与启用动作。""" + self._movie_days = movie_days + self._tv_days = tv_days + self._actions_ordered = list(actions or []) + + def evaluate(self, subscribe, mediainfo, last_download_date=None, + as_of: Optional[date] = None) -> Optional[str]: + """返回应执行的动作 pause/complete/delete,或 None。 + + 截止日取上映或开播日、订阅创建日、订阅最后更新日、最近下载日中的最大值, + 再加对应类型的无下载天数。今天超过截止日才处理; + 动作按配置顺序取该媒体类型的第一个。 + """ + decision = self.evaluate_detail(subscribe, mediainfo, last_download_date, as_of=as_of) + return decision.action if decision else None + + def evaluate_detail(self, subscribe, mediainfo, last_download_date=None, + as_of: Optional[date] = None) -> Optional[NoDownloadDecision]: + """返回无下载处理动作和日期原因;未命中时返回 None。""" + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.MOVIE: + is_movie = True + days = self._movie_days + air_label = "上映日期" + elif media_type == MediaType.TV: + is_movie = False + days = self._tv_days + air_label = "开播日期" + else: + return None + if not days: + return None + + suffix = "movie" if is_movie else "tv" + relevant = [action for action in self._actions_ordered if action.endswith(f"_{suffix}")] + if not relevant: + return None + action = relevant[0].split("_")[0] + if action not in {"pause", "complete", "delete"}: + return None + + if is_movie: + air_date = parse_date(mediainfo.release_date) + else: + air_date = parse_date( + get_tv_season_air_date(mediainfo, subscribe.season) + or mediainfo.first_air_date + ) + if not air_date: + return None + + subscribe_date = parse_date( + subscribe.date, + fmt="%Y-%m-%d %H:%M:%S", + ) + last_update_date = parse_date( + subscribe.last_update, + fmt="%Y-%m-%d %H:%M:%S", + ) + dates = [value for value in (air_date, subscribe_date, last_update_date, last_download_date) if value] + if not dates: + return None + + today = as_of or date.today() + deadline = max(dates) + timedelta(days=days) + if today > deadline: + reason = ( + f"{date_context(air_label, air_date, as_of=today)}," + f"无下载截止日:{deadline.isoformat()},{self._deadline_relative_text(deadline, today)}" + ) + detail(f"无下载策略:{format_subscribe(subscribe)} {reason}(阈值 {days} 天),建议动作={action}") + return NoDownloadDecision( + action=action, + reason=reason, + air_date=air_date, + deadline=deadline, + days=days, + ) + return None + + @staticmethod + def _deadline_relative_text(deadline: date, today: date) -> str: + """无下载截止日命中后使用超期语义,其余日期复用通用相对天数。""" + overdue_days = (today - deadline).days + if overdue_days > 0: + return f"已超过 {overdue_days} 天" + return relative_day_text(deadline, as_of=today) diff --git a/plugins.v3/subscribeassistantenhanced/pause/probe.py b/plugins.v3/subscribeassistantenhanced/pause/probe.py new file mode 100644 index 00000000..b8f89590 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pause/probe.py @@ -0,0 +1,255 @@ +"""暂停订阅低频补搜协调器。""" +import random +import threading +import time +from typing import Callable, Optional + +from app.log import logger +from app.schemas.types import MediaType + +from ..shared.log import detail +from ..shared.subscribe import format_subscribe, format_subscribe_label, resolve_subscribe_media_type + + +PROBE_LAST_SCHEDULED_AT = "paused_probe_last_scheduled_at" +PROBE_SCHEDULED_RUN_AT = "paused_probe_scheduled_run_at" +PROBE_REASON = "paused_probe_reason" +PROBE_MAX_CANDIDATES = 10 +PROBE_MIN_INTERVAL_HOURS = 24 +PROBE_DELAY_SECONDS = (60, 300) + + +class PausedProbeCoordinator: + """维护暂停订阅的低频补搜调度。 + + 该协调器只负责选择候选、写入本轮调度字段、执行前复核和调用单订阅搜索; + 暂停记录、恢复保护和订阅状态变更仍由 PauseManager 与事件层负责。 + """ + + def __init__(self, config, task_data_read: Callable, task_data_update: Callable, + subscribe_oper, subscribe_chain, pause_manager, + download_monitor=None, + timer_factory: Optional[Callable] = None, + now_fn: Optional[Callable] = None, + delay_fn: Optional[Callable] = None): + """注入配置、任务数据、订阅查询、搜索入口和可替换时钟/Timer。""" + self._config = config + self._read = task_data_read + self._update = task_data_update + self._subscribe_oper = subscribe_oper + self._subscribe_chain = subscribe_chain + self._pause_manager = pause_manager + self._download_monitor = download_monitor + self._timer_factory = timer_factory or threading.Timer + self._now = now_fn or time.time + self._delay = delay_fn or (lambda: random.randint(*PROBE_DELAY_SECONDS)) + self._generation = 0 + self._timers: dict[str, object] = {} + self._lock = threading.RLock() + + def stop(self): + """取消并失效当前所有待执行 Timer。""" + with self._lock: + self._generation += 1 + timers = list(self._timers.values()) + self._timers.clear() + for timer in timers: + cancel = getattr(timer, "cancel", None) + if cancel: + cancel() + detail("暂停补搜:已取消待执行任务") + + def run(self): + """扫描暂停订阅并为符合条件的候选安排一次补搜。""" + if not self._enabled(): + detail("暂停补搜:自动暂停未开启,跳过") + return + if not (self._subscribe_oper and self._subscribe_chain and self._pause_manager): + detail("暂停补搜:运行依赖未就绪,跳过") + return + + now = self._now() + selected_reasons = self._selected_reasons() + scheduled = 0 + for subscribe in (self._subscribe_oper.list(state="S") or []): + if resolve_subscribe_media_type(subscribe) not in (MediaType.MOVIE, MediaType.TV): + continue + if scheduled >= PROBE_MAX_CANDIDATES: + detail("暂停补搜:本轮已达到 10 个候选上限") + break + + record = self._pause_manager.get_pause_record(subscribe) + if record is None: + self._pause_manager.adopt_external(subscribe) + record = self._pause_manager.get_pause_record(subscribe) + if record is None: + detail(f"暂停补搜:{format_subscribe(subscribe)} 无暂停记录,跳过") + continue + + if not selected_reasons: + detail(f"暂停补搜:{format_subscribe(subscribe)} 未配置补搜场景,仅登记暂停状态") + continue + + reason = record.reason + sid = str(subscribe.id) + task = (self._read("subscribes") or {}).get(sid, {}) + scheduled_run_at = task.get(PROBE_SCHEDULED_RUN_AT) + if scheduled_run_at: + if scheduled_run_at <= now: + detail(f"暂停补搜:{format_subscribe(subscribe)} 清理过期调度字段") + self._pause_manager.clear_probe_schedule(subscribe, include_last=False) + else: + detail(f"暂停补搜:{format_subscribe(subscribe)} 已有待执行调度,跳过") + continue + + skip_reason = self._candidate_skip_reason(subscribe, record, task, now, selected_reasons) + if skip_reason: + detail(f"暂停补搜:{format_subscribe(subscribe)} {skip_reason}") + continue + + self._schedule(subscribe, reason, now) + scheduled += 1 + + def _enabled(self) -> bool: + """读取总开关;配置对象是插件内部稳定结构。""" + return bool(self._config.pause_enhanced_enabled) + + def _selected_reasons(self) -> set[str]: + """读取当前允许 probe 的暂停场景集合。""" + return {str(reason).strip() for reason in self._config.paused_probe_reasons if str(reason).strip()} + + def _interval_seconds(self) -> int: + """按当前配置计算限频间隔,运行时下限固定为 24 小时。""" + return max(int(self._config.paused_probe_interval_hours or 0), PROBE_MIN_INTERVAL_HOURS) * 3600 + + @staticmethod + def _reason_allowed(reason: str, selected_reasons: set[str]) -> bool: + """判断暂停原因是否被当前场景配置允许;all 覆盖未来新增原因。""" + if "all" in selected_reasons: + return True + return reason in selected_reasons + + def _candidate_skip_reason(self, subscribe, record, task: dict, now: float, + selected_reasons: set[str]) -> str: + """返回候选跳过原因;空字符串表示可以安排 probe。""" + reason = record.reason + if not self._reason_allowed(reason, selected_reasons): + return f"暂停原因 {reason} 未配置补搜" + pause_since = float(record.since or 0) + min_pause_days = int(self._config.paused_probe_min_pause_days or 0) + if min_pause_days <= 0: + return "暂停满天数为 0,不处理主动补搜" + min_pause_seconds = min_pause_days * 86400 + if now - pause_since < min_pause_seconds: + return f"暂停未满 {min_pause_days} 天" + last_scheduled_at = task.get(PROBE_LAST_SCHEDULED_AT) + if last_scheduled_at and now - float(last_scheduled_at) < self._interval_seconds(): + return "距离上次安排未达到补搜间隔" + if self._download_monitor and self._download_monitor.has_active_downloads(subscribe.id): + return "存在进行中下载,跳过补搜" + return "" + + def _schedule(self, subscribe, reason: str, now: float): + """写入调度字段并安排延迟 Timer。""" + sid = str(subscribe.id) + delay = int(self._delay()) + run_at = now + delay + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + task[PROBE_LAST_SCHEDULED_AT] = now + task[PROBE_SCHEDULED_RUN_AT] = run_at + task[PROBE_REASON] = reason + data[sid] = task + return data + + self._update("subscribes", updater) + with self._lock: + generation = self._generation + old_timer = self._timers.pop(sid, None) + if old_timer and getattr(old_timer, "cancel", None): + old_timer.cancel() + timer = self._timer_factory(delay, lambda: self._execute(sid, generation)) + self._timers[sid] = timer + timer.start() + run_text = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(run_at)) + logger.info( + f"暂停补搜:{format_subscribe(subscribe)} 安排 {delay} 秒后执行," + f"预计时间 {run_text},原因={reason}" + ) + + def _execute(self, sid: str, generation: int): + """Timer 回调:执行前复核状态,满足条件时调用主程序单订阅搜索。""" + stale_generation = False + try: + subscribe = self._subscribe_oper.get(int(sid)) if self._subscribe_oper else None + cleanup_last = False + skip_reason = self._preflight_skip_reason(sid, subscribe, generation) + if skip_reason: + logger.info(f"暂停补搜:{format_subscribe_label(subscribe, sid)} 执行前跳过:{skip_reason}") + if skip_reason == "调度已失效": + stale_generation = True + return + if subscribe: + cleanup_last = skip_reason in { + "配置已关闭", "未配置补搜场景", "暂停满天数为 0", "当前原因未配置", "暂停原因变化" + } + self._pause_manager.clear_probe_schedule(subscribe, include_last=cleanup_last) + else: + self._clear_probe_schedule_by_sid(sid, include_last=False) + return + logger.info(f"暂停补搜:{format_subscribe(subscribe)} 开始执行单订阅搜索") + self._subscribe_chain.search(sid=subscribe.id) + except Exception as err: + logger.error(f"暂停补搜:订阅 {sid} 执行失败,本次已计入间隔:{err}", exc_info=True) + finally: + with self._lock: + self._timers.pop(sid, None) + if stale_generation: + return + subscribe = self._subscribe_oper.get(int(sid)) if self._subscribe_oper else None + if subscribe: + self._pause_manager.clear_probe_schedule(subscribe, include_last=False) + else: + self._clear_probe_schedule_by_sid(sid, include_last=False) + + def _clear_probe_schedule_by_sid(self, sid: str, include_last: bool = False): + """订阅对象不可用时按 sid 清理本轮调度字段。""" + + def updater(data: dict) -> dict: + task = data.get(str(sid), {}) + task.pop(PROBE_SCHEDULED_RUN_AT, None) + task.pop(PROBE_REASON, None) + if include_last: + task.pop(PROBE_LAST_SCHEDULED_AT, None) + data[str(sid)] = task + return data + + self._update("subscribes", updater) + + def _preflight_skip_reason(self, sid: str, subscribe, generation: int) -> str: + """Timer 执行前复核配置、订阅状态和暂停原因。""" + with self._lock: + if generation != self._generation: + return "调度已失效" + task = (self._read("subscribes") or {}).get(str(sid), {}) + scheduled_reason = task.get(PROBE_REASON) + if not self._enabled(): + return "配置已关闭" + selected_reasons = self._selected_reasons() + if not selected_reasons: + return "未配置补搜场景" + if int(self._config.paused_probe_min_pause_days or 0) <= 0: + return "暂停满天数为 0" + if not subscribe: + return "订阅不存在" + if subscribe.state != "S": + return "订阅已非暂停状态" + record = self._pause_manager.get_pause_record(subscribe) + if not record: + return "暂停记录不存在" + if record.reason != scheduled_reason: + return "暂停原因变化" + if not self._reason_allowed(record.reason, selected_reasons): + return "当前原因未配置" + return "" diff --git a/plugins.v3/subscribeassistantenhanced/pending/__init__.py b/plugins.v3/subscribeassistantenhanced/pending/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/pending/judge.py b/plugins.v3/subscribeassistantenhanced/pending/judge.py new file mode 100644 index 00000000..2c285fff --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pending/judge.py @@ -0,0 +1,234 @@ +"""待定(P)进入与退出判定,按状态来源分治。""" +import time +from typing import Callable, Optional + +from app.log import logger +from app.schemas.types import MediaType + +from ..engine.proximity import assess_completion_proximity +from ..engine.types import CompletionSignal, PendingTimeoutManagerProtocol +from ..shared.config import PluginConfig +from ..shared.log import detail +from ..shared.subscribe import subscribe_tmdb_id +from ..shared.media import date_context, get_tv_season_air_date, get_tv_season_episode_count, parse_date +from ..shared.subscribe import format_subscribe, resolve_subscribe_media_type +from .state import PendingStateCoordinator + +ENTER_TITLES = { + "pending_judge": "剧集信息待确认,订阅已进入待定", + "guard_veto": "完成前检查未通过,订阅已进入待定", +} + +EXIT_TITLES = { + "pending_judge": "剧集待定条件解除,订阅已恢复启用", + "guard_veto": "完成前观察结束,订阅已恢复启用", +} + + +class PendingJudge: + """待定判定器,区分 pending_judge 与 guard_veto 来源。""" + + def __init__(self, config: PluginConfig, + evidence_pipeline, + subscribe_oper, + timeout_manager: PendingTimeoutManagerProtocol, + task_data_read: Callable, + task_data_update: Callable, + resolve_missing_fn: Optional[Callable] = None, + notify_fn: Optional[Callable] = None, + state_coordinator: Optional[PendingStateCoordinator] = None): + """注入待定判定、状态写库、超时管理、任务数据和状态通知回调。""" + self._config = config + self._evidence_pipeline = evidence_pipeline + self._resolve_missing_fn = resolve_missing_fn + self._subscribe_oper = subscribe_oper + self._timeout = timeout_manager + self._read = task_data_read + self._update = task_data_update + self._notify = notify_fn + self._state = state_coordinator or PendingStateCoordinator( + task_data_read, task_data_update, subscribe_oper=subscribe_oper) + + def should_enter_pending(self, subscribe, mediainfo, episodes: list, + signal: Optional[CompletionSignal] = None) -> tuple[bool, str]: + """按 OR 逻辑判断是否进入待定(P),任一条件满足即待定。""" + if resolve_subscribe_media_type(subscribe) != MediaType.TV: + return False, "" + + evaluated_signal = signal + + def current_signal() -> CompletionSignal: + nonlocal evaluated_signal + if evaluated_signal is None: + evaluated_signal = self._evidence_pipeline.evaluate( + subscribe, + mediainfo, + ).primary_signal + return evaluated_signal + + def has_strong_completion() -> bool: + return self._is_strong_completion_signal(current_signal()) + + season_air_date = get_tv_season_air_date(mediainfo, subscribe.season) + air_date = parse_date(season_air_date or mediainfo.first_air_date) + + pending_days = self._config.auto_tv_pending_days + if pending_days and air_date: + from datetime import date, timedelta + today = date.today() + if air_date + timedelta(days=pending_days) > today: + if has_strong_completion(): + return False, "" + return True, f"{date_context('开播日期', air_date, as_of=today)},仍在开播待定窗口内" + + ep_count = get_tv_season_episode_count(mediainfo, subscribe.season, episodes) + pending_episodes = self._config.auto_tv_pending_episodes + if pending_episodes and ep_count is not None and ep_count <= pending_episodes: + if has_strong_completion(): + return False, "" + return True, f"集数不足({ep_count} ≤ {pending_episodes})" + + signal_for_volatility = current_signal() if self._config.pending_use_volatility else None + if signal_for_volatility and not signal_for_volatility.stable: + proximity = assess_completion_proximity( + episodes=episodes, + total=signal_for_volatility.scope_total or subscribe.total_episode or len(episodes or []), + missing_episodes=None, + ) + if proximity.near_completion: + detail_text = ( + f"({signal_for_volatility.volatility_detail})" + if signal_for_volatility.volatility_detail else "" + ) + return True, f"目标总集数近期变化{detail_text}" + detail(f"待定判定:{format_subscribe(subscribe)} 总集数近期变化但未接近完结,不进入待定") + + if episodes and not any(ep.air_date for ep in episodes): + if has_strong_completion(): + return False, "" + return True, "本季无任何 air_date 信息" + + return False, "" + + def check_exit(self, subscribe, mediainfo, tmdb_episodes_fn, source: Optional[str] = None) -> bool: + """检查指定待定来源是否应退出,未指定时沿用任务主来源。""" + task_data = self._read_subscribe_task(subscribe) + if not task_data or task_data.get("state") != "P": + return False + + active_source = source or task_data.get("source") or "pending_judge" + if source is not None and source not in self._state.active_sources(subscribe.id): + return False + + if active_source == "pending_judge": + signal: CompletionSignal = self._evidence_pipeline.evaluate( + subscribe, + mediainfo, + ).primary_signal + if self._is_strong_completion_signal(signal): + self._exit_pending(subscribe, "信号确认完结", source=active_source) + return True + tmdb_id = subscribe_tmdb_id(subscribe) + episodes = tmdb_episodes_fn( + tmdb_id, + subscribe.season, + episode_group=subscribe.episode_group, + ) if tmdb_id is not None else [] + should_stay, _ = self.should_enter_pending(subscribe, mediainfo, episodes, signal) + if not should_stay: + self._exit_pending(subscribe, "待定条件不再满足", source=active_source) + return True + return False + + elif active_source == "guard_veto": + evidence = self._evidence_pipeline.evaluate( + subscribe, + mediainfo, + resolve_missing_fn=self._resolve_missing_fn, + meta=None, + ) + decision = self._timeout.check_observation( + subscribe, + evidence, + mode=self._config.completion_guard_mode, + ) + if decision.exit_pending: + self._exit_pending(subscribe, decision.reason or "完成前观察结束", source=active_source) + return True + return False + + return False + + @staticmethod + def _is_strong_completion_signal(signal: Optional[CompletionSignal]) -> bool: + """只有高置信完结事实可影响剧集待定进入和提前释放。""" + return bool(signal and signal.completed and signal.confidence == "high") + + def _exit_pending(self, subscribe, reason: str, source: Optional[str] = None): + """退出指定或当前待定来源,并由 PendingStateCoordinator 仲裁是否恢复启用(R)。""" + sid = subscribe.id + task = self._read_subscribe_task(subscribe) + active_source = source or task.get("source") or "pending_judge" + if active_source == "guard_veto": + self._timeout.clear_observation(sid) + restored = self._state.clear_active( + subscribe, + source=active_source, + reason=reason, + ) + if restored: + logger.info(f"待定退出:{format_subscribe(subscribe)} 退出待定(P),原因:{reason}") + title = EXIT_TITLES.get(active_source) + if title: + self._notify_status(subscribe, title, detail=reason) + else: + logger.info( + f"待定退出:{format_subscribe(subscribe)} 已解除来源={active_source}," + f"仍保持待定(P),原因:{reason}" + ) + self._update_subscribe_task(subscribe, { + "exit_reason": reason, + "exit_at": time.time(), + }) + + def mark_pending(self, subscribe, source: str = "pending_judge", + reason: str = ""): + """登记待定来源,并在订阅真实进入待定(P)时发送状态通知。""" + changed = self._state.mark_active(subscribe, source=source, reason=reason) + if changed: + detail( + f"待定进入:{format_subscribe(subscribe)} 标记为待定(P)," + f"来源={source},原因:{reason}" + ) + else: + detail( + f"待定刷新:{format_subscribe(subscribe)} 待定来源仍满足," + f"未触发状态切换,来源={source},原因:{reason}" + ) + title = ENTER_TITLES.get(source) + if changed and title: + self._notify_status(subscribe, title, detail=reason) + + def _read_subscribe_task(self, subscribe) -> dict: + """读取订阅的任务数据。""" + sid = str(subscribe.id) + data = self._read("subscribes") + return data.get(sid, {}) + + def _update_subscribe_task(self, subscribe, updates: dict): + """更新订阅的任务数据。""" + sid = str(subscribe.id) + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + task.update(updates) + data[sid] = task + return data + + self._update("subscribes", updater) + + def _notify_status(self, subscribe, title_suffix: str, detail: Optional[str] = None): + """发送待定状态通知。""" + if not self._notify: + return + self._notify(subscribe, title_suffix, detail=detail) diff --git a/plugins.v3/subscribeassistantenhanced/pending/refresh.py b/plugins.v3/subscribeassistantenhanced/pending/refresh.py new file mode 100644 index 00000000..2fec6159 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pending/refresh.py @@ -0,0 +1,14 @@ +"""EpisodesRefresh 待定观察。 + +P 状态只保护订阅生命周期,不覆盖主程序计算出的 total_episode。 +""" + +from app.schemas.event import SubscribeEpisodesRefreshEventData + + +class PendingRefresh: + """EpisodesRefresh 事件处理:保持职责边界,不修改搜索目标范围。""" + + def handle_refresh(self, data: SubscribeEpisodesRefreshEventData): + """待定状态不覆盖 total_episode,主程序继续使用自己的刷新结果。""" + return None diff --git a/plugins.v3/subscribeassistantenhanced/pending/state.py b/plugins.v3/subscribeassistantenhanced/pending/state.py new file mode 100644 index 00000000..002d907d --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/pending/state.py @@ -0,0 +1,234 @@ +"""统一待定状态仲裁。 + +该模块只负责多来源待定合并与订阅表待定(P)/启用(R)同步,不判断具体业务条件。 +""" +import time +from typing import Callable, Optional + +from app.log import logger + +from ..shared.subscribe import format_subscribe +from ..shared.update import update_subscribe + + +class PendingStateCoordinator: + """多来源待定状态协调器。 + + pending_sources 记录各业务域的活跃来源;任一来源存在时保持待定(P),最后一个来源清除后才恢复启用(R), + 避免 download_pending、pending_judge 与 guard_veto 互相误释放。 + """ + + def __init__(self, task_data_read: Callable, task_data_update: Callable, + subscribe_oper=None): + """注入任务存储与订阅表写入依赖。""" + self._read = task_data_read + self._update = task_data_update + self._subscribe_oper = subscribe_oper + + def mark_active(self, subscribe, source: str, reason: str = "") -> bool: + """记录一个待定原因,并把主订阅状态同步为待定(P)。""" + if not subscribe or not source: + return False + sid = str(subscribe.id) + now = time.time() + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + sources = self._normalize_sources(task) + sources[source] = { + "reason": reason, + "since": sources.get(source, {}).get("since") or now, + "updated_at": now, + } + task["pending_sources"] = sources + task["state"] = "P" + task["source"] = self._primary_source(sources) + task["reason"] = sources[task["source"]].get("reason", "") + task["since"] = sources[task["source"]].get("since", now) + data[sid] = task + return data + + self._update("subscribes", updater) + if self._subscribe_oper and subscribe.state != "P": + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "P"}) + logger.info( + f"待定状态:{format_subscribe(subscribe)} 因【{self._source_label(source)}】进入待定(P)" + ) + return True + return False + + def clear_active(self, subscribe, source: str, reason: str = "") -> bool: + """解除一个待定原因,并按剩余原因决定是否恢复启用(R)。""" + if not subscribe or not source: + return False + sid = str(subscribe.id) + result = {"active": False, "primary": None} + + def updater(data: dict) -> dict: + task = data.get(sid, {}) + sources = self._normalize_sources(task) + sources.pop(source, None) + result["active"] = bool(sources) + result["primary"] = self._primary_source(sources) if sources else None + task["pending_sources"] = sources + if sources: + primary = result["primary"] + task["state"] = "P" + task["source"] = primary + task["reason"] = sources[primary].get("reason", "") + task["since"] = sources[primary].get("since") + else: + task["state"] = "R" + task["source"] = None + task["reason"] = reason + task["exit_at"] = time.time() + data[sid] = task + return data + + self._update("subscribes", updater) + if result["active"]: + logger.info( + f"待定状态:{format_subscribe(subscribe)}【{self._source_label(source)}】已解除," + f"仍因【{self._source_label(result['primary'])}】保持待定(P)" + ) + return False + if self._subscribe_oper and subscribe.state == "P": + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "R"}) + logger.info(f"待定状态:{format_subscribe(subscribe)} 全部待定原因已解除,恢复为启用(R)") + return True + return False + + def clear_all_owned(self, subscribe, reason: str = "") -> bool: + """清除增强版明确持有的全部待定来源,并把主订阅恢复为启用(R)。""" + if not subscribe: + return False + sid = str(subscribe.id) + task = (self._read("subscribes") or {}).get(sid) + if not task or task.get("state") != "P": + return False + + restored = False + if self._subscribe_oper and subscribe.state == "P": + # 数据库是用户可见状态事实源;必须先恢复成功,再清除插件侧归属证据。 + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "R"}) + restored = True + + def updater(data: dict) -> dict: + current = data.get(sid, {}) + current["pending_sources"] = {} + current["state"] = "R" + current["source"] = None + current["reason"] = reason + current["exit_at"] = time.time() + data[sid] = current + return data + + self._update("subscribes", updater) + if restored: + logger.info(f"待定状态:{format_subscribe(subscribe)} {reason},恢复为启用(R)") + return True + return False + + def reconcile_orphaned(self, subscribe, reason: str = "") -> bool: + """恢复 DB 仍为待定、但增强版已无有效待定来源的订阅。""" + if not subscribe or subscribe.state != "P": + return False + sid = str(subscribe.id) + task = (self._read("subscribes") or {}).get(sid) + if not task: + return self._restore_unowned_pending(subscribe, reason=reason) + if task.get("state") != "P" or self._normalize_sources(task): + return False + return self.clear_all_owned(subscribe, reason=reason) + + def clear_for_pause(self, subscribe, reason: str = "") -> bool: + """插件暂停覆盖待定时清除待定归属,但不把订阅恢复为 R。""" + if not subscribe: + return False + sid = str(subscribe.id) + task = (self._read("subscribes") or {}).get(sid) + if not task or task.get("state") != "P": + return False + if not self._normalize_sources(task): + return False + + def updater(data: dict) -> dict: + current = data.get(sid, {}) + current["pending_sources"] = {} + current["state"] = "S" + current["source"] = None + current["reason"] = reason + current["exit_at"] = time.time() + data[sid] = current + return data + + self._update("subscribes", updater) + logger.info(f"待定状态:{format_subscribe(subscribe)} 被暂停状态覆盖,清理待定归属") + return True + + def _restore_unowned_pending(self, subscribe, reason: str = "") -> bool: + """恢复缺少插件归属记录的 P 状态残留。""" + if not self._subscribe_oper: + return False + update_subscribe(self._subscribe_oper, subscribe.id, {"state": "R"}) + sid = str(subscribe.id) + + def updater(data: dict) -> dict: + current = data.get(sid, {}) + current["pending_sources"] = {} + current["state"] = "R" + current["source"] = None + current["reason"] = reason + current["exit_at"] = time.time() + data[sid] = current + return data + + self._update("subscribes", updater) + logger.info(f"待定状态:{format_subscribe(subscribe)} {reason},恢复为启用(R)") + return True + + def has_active(self, subscribe_id: int) -> bool: + """判断订阅是否还有未解除的待定原因。""" + task = (self._read("subscribes") or {}).get(str(subscribe_id), {}) + return bool(self._normalize_sources(task)) + + def active_sources(self, subscribe_id: int) -> dict: + """读取订阅当前未解除的待定原因。""" + task = (self._read("subscribes") or {}).get(str(subscribe_id), {}) + return self._normalize_sources(task) + + @staticmethod + def _normalize_sources(task: Optional[dict]) -> dict: + """兼容单 source 待定数据,统一返回 pending_sources 字典。""" + if not task: + return {} + sources = task.get("pending_sources") + if isinstance(sources, dict): + return dict(sources) + if task.get("state") == "P" and task.get("source"): + return { + task["source"]: { + "reason": task.get("reason", ""), + "since": task.get("since"), + "updated_at": task.get("since"), + } + } + return {} + + @staticmethod + def _primary_source(sources: dict) -> Optional[str]: + """选择写回单 source 字段的主来源,保证待定状态读取结果稳定。""" + for source in ("pending_judge", "guard_veto", "download_pending"): + if source in sources: + return source + return next(iter(sources), None) + + @staticmethod + def _source_label(source: Optional[str]) -> str: + """把内部待定原因转成日志中可读的中文名称。""" + labels = { + "pending_judge": "剧集信息待确认", + "guard_veto": "完成前检查未通过", + "download_pending": "下载还未整理入库", + } + return labels.get(source or "", source or "未知原因") diff --git a/plugins.v3/subscribeassistantenhanced/postcheck/__init__.py b/plugins.v3/subscribeassistantenhanced/postcheck/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/postcheck/rebuilder.py b/plugins.v3/subscribeassistantenhanced/postcheck/rebuilder.py new file mode 100644 index 00000000..348894c2 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/postcheck/rebuilder.py @@ -0,0 +1,129 @@ +"""完成快照订阅重建:解析模式、创建订阅并校验实际接管范围。""" +from typing import Callable, Tuple + +from app.log import logger +from app.schemas.types import MediaType, SystemConfigKey + +from .verifier import format_snapshot_label + + +class CompletionSubscribeRebuilder: + """按完成快照重建增集订阅,并确保结果不是全集洗版。""" + + def __init__(self, subscribe_chain, subscribe_oper, + default_config_getter: Callable, plugin_name: str): + """注入订阅创建、订阅读取和主程序默认规则查询能力。""" + self._subscribe_chain = subscribe_chain + self._subscribe_oper = subscribe_oper + self._default_config_getter = default_config_getter + self._plugin_name = plugin_name + + def rebuild(self, snap: dict, config: dict) -> bool: + """使用当前默认规则和完成快照重建按集追踪的增集订阅。""" + if not self._subscribe_chain or not self._subscribe_oper: + return False + payload = dict(config) + title = payload.pop("name", "") + year = payload.pop("year", None) + for field in ( + "id", "type", "media_source", "media_id", "season", "episode_group", + "best_version", "best_version_full", + ): + payload.pop(field, None) + best_version, best_version_full = self._resolve_mode(snap) + payload["best_version"] = best_version + payload["best_version_full"] = best_version_full + payload["manual_total_episode"] = 0 + payload["state"] = "N" + try: + subscribe_id, _ = self._subscribe_chain.add( + title=title, + year=year, + mtype=MediaType.TV, + media_source=snap.get("media_source"), + media_id=snap.get("media_id"), + season=snap.get("season"), + episode_group=snap.get("episode_group_id"), + username=self._plugin_name, + message=False, + exist_ok=True, + **payload, + ) + rebuilt = self._subscribe_oper.get(subscribe_id) if subscribe_id else None + if not self._is_valid( + rebuilt, + snap=snap, + config=config, + best_version=best_version, + ): + logger.warning( + f"完成后验证:{format_snapshot_label(snap)} 重建结果未接管目标新增集," + "已保留快照等待重试" + ) + return False + logger.info( + f"完成后验证:{format_snapshot_label(snap)} 检测到增集," + f"已重建订阅(新 id={subscribe_id})" + ) + return True + except Exception as err: + logger.warning( + f"{self._plugin_name}按完成快照重建订阅失败:" + f"{format_snapshot_label(snap)}, error={err}" + ) + return False + + def validate(self, subscribe, snap: dict, current_total: int) -> bool: + """验证既有订阅是否按当前纠错规则接管快照发现的新增集。""" + best_version, _ = self._resolve_mode(snap) + return self._is_valid( + subscribe, + snap=snap, + config={ + "start_episode": snap.get("total_at_completion", 0) + 1, + "total_episode": current_total, + }, + best_version=best_version, + ) + + @staticmethod + def _is_valid(subscribe, snap: dict, config: dict, best_version: int) -> bool: + """确认实际订阅按目标模式覆盖完整新增集区间。""" + if not subscribe: + return False + requested_start = config.get("start_episode") or 1 + requested_total = config.get("total_episode") or 0 + actual_start = subscribe.start_episode or 1 + actual_total = subscribe.total_episode or 0 + return ( + subscribe.media_source == snap.get("media_source") + and str(subscribe.media_id) == str(snap.get("media_id")) + and subscribe.season == snap.get("season") + and subscribe.episode_group == snap.get("episode_group_id") + and bool(subscribe.best_version) == bool(best_version) + and not bool(subscribe.best_version_full) + and actual_start <= requested_start + and actual_total >= requested_total + ) + + def _resolve_mode(self, snap: dict) -> Tuple[int, int]: + """解析自动纠错重建模式,保证结果只可能是普通订阅或分集洗版。""" + default_best_version, default_best_version_full = self._get_default_tv_mode() + if not default_best_version_full: + return int(default_best_version), 0 + + snapshot_config = snap.get("subscribe_config") or {} + snapshot_best_version = bool(snapshot_config.get("best_version")) + snapshot_best_version_full = bool(snapshot_config.get("best_version_full")) + if snapshot_best_version and not snapshot_best_version_full: + return 1, 0 + return 0, 0 + + def _get_default_tv_mode(self) -> Tuple[bool, bool]: + """读取用户在主程序中保存的默认电视剧订阅模式。""" + default_config = self._default_config_getter(SystemConfigKey.DefaultTvSubscribeConfig) + if not isinstance(default_config, dict): + default_config = {} + best_version = bool(default_config.get("best_version")) + best_version_full = best_version and bool(default_config.get("best_version_full")) + return best_version, best_version_full diff --git a/plugins.v3/subscribeassistantenhanced/postcheck/timeout.py b/plugins.v3/subscribeassistantenhanced/postcheck/timeout.py new file mode 100644 index 00000000..010395a2 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/postcheck/timeout.py @@ -0,0 +1,522 @@ +"""完成前观察状态机:管理守卫待定与一次性低置信放行令牌。""" +import time +from typing import Callable, Optional + +from ..engine.types import CompletionEvidence, CompletionObservationDecision, CompletionSignal +from ..shared.log import detail +from ..shared.subscribe import ( + format_subscribe_label, identity_matches, subscribe_identity, +) + + +class PendingTimeoutManager: + """完成前观察状态机与一次性低置信放行令牌管理。""" + + def __init__(self, task_data_read: Callable, task_data_update: Callable, + timeout_days: int = 7, + cadence_acceleration: bool = True, + subscribe_get_fn: Optional[Callable] = None): + self._read = task_data_read + self._update = task_data_update + self._timeout_seconds = timeout_days * 86400 + self._cadence_acceleration = cadence_acceleration + self._subscribe_get = subscribe_get_fn + + def record_observation(self, subscribe_or_id, signal: Optional[CompletionSignal] = None, + total_episode: Optional[int] = None): + """CompletionCheck 否决时开始计时,并记录本轮完成前观察上下文。""" + subscribe, subscribe_id = self._resolve_subscribe(subscribe_or_id) + sid = str(subscribe_id) + snapshot = self._snapshot_from_signal(signal, total_episode) + + def updater(data: dict) -> dict: + current = data.get(sid) + if current is None or ( + subscribe is not None + and self._identity_mismatched(current, subscribe) + ): + data[sid] = self._observation_record_payload( + snapshot, subscribe, reset_timer=True + ) + if subscribe is not None: + data[sid]["identity"] = subscribe_identity(subscribe) + return data + + self._update("blocks", updater) + + def clear_observation(self, subscribe_id: int): + """清除订阅的完成前观察记录。""" + sid = str(subscribe_id) + + def updater(data: dict) -> dict: + data.pop(sid, None) + return data + + self._update("blocks", updater) + + def record_release_token(self, subscribe_or_id, signal: CompletionSignal, + total_episode: Optional[int] = None): + """记录一次性低置信放行令牌,供下一次 CompletionCheck 消费。""" + subscribe, subscribe_id = self._resolve_subscribe(subscribe_or_id) + sid = str(subscribe_id) + token = { + "signals": list(signal.signals), + "confidence": signal.confidence, + "total_episode": total_episode, + "released_at": time.time(), + } + if subscribe is not None: + token["identity"] = subscribe_identity(subscribe) + + def updater(data: dict) -> dict: + data[sid] = token + return data + + self._update("releases", updater) + + def consume_release_token(self, subscribe_or_id, signal: CompletionSignal, + total_episode: Optional[int] = None) -> bool: + """消费匹配当前低置信信号的一次性放行令牌。""" + subscribe, subscribe_id = self._resolve_subscribe(subscribe_or_id) + sid = str(subscribe_id) + total_episode = self._resolve_total(signal, total_episode) + releases = self._read("releases") + token = releases.get(sid) + if not token: + return False + if subscribe is not None and self._identity_mismatched(token, subscribe): + self._clear_release_token(sid) + return False + + if not self._matches_token(token, signal, total_episode): + self._clear_release_token(sid) + return False + + self._clear_release_token(sid) + return True + + def clear_release_token(self, subscribe_or_id): + """清理一次性完成放行令牌,供完成守卫直接放行时清除残留令牌。""" + _, subscribe_id = self._resolve_subscribe(subscribe_or_id) + self._clear_release_token(str(subscribe_id)) + + def check_observation(self, subscribe_or_id, evidence: CompletionEvidence, + mode: str) -> CompletionObservationDecision: + """按完成证据状态机生成完成前观察裁决并维护持久观察数据。""" + subscribe, subscribe_id = self._resolve_subscribe(subscribe_or_id) + sid = str(subscribe_id) + label = self._format_subscribe_label(subscribe_id) + signal = self._signal_from_evidence(evidence) + snapshot = self._snapshot_from_evidence(evidence) + total_episode = snapshot.get("total_episode") + + if mode == "off": + detail(f"完成前观察:{label} 守卫已关闭,清理既有观察状态") + self.clear_observation(subscribe_id) + self._clear_release_token(sid) + return CompletionObservationDecision.release_guard("完成守卫已关闭") + + data = self._read("blocks") or {} + observation_record = data.get(sid) + if ( + observation_record + and subscribe is not None + and self._identity_mismatched(observation_record, subscribe) + ): + detail(f"完成前观察:{label} 观察记录媒体身份不匹配,重新建立观察状态") + self.clear_observation(subscribe_id) + self._clear_release_token(sid) + observation_record = None + + record_snapshot = ( + self._snapshot_from_observation_record(observation_record) + if observation_record else {} + ) + record_parseable = self._is_parseable_snapshot(record_snapshot) + + kind = snapshot.get("observation_kind") or "none" + record_total = record_snapshot.get("total_episode") + if record_parseable and record_total and total_episode and total_episode > record_total: + detail( + f"完成前观察:{label} 观察期间总集数增长 " + f"{record_total}→{total_episode},释放本轮观察并等待重新判定" + ) + self.clear_observation(subscribe_id) + self._clear_release_token(sid) + return CompletionObservationDecision.release_guard("观察期间目标总集数增长") + + if kind in ("hard_veto", "unstable"): + reset = not record_parseable or not self._same_observation_family(record_snapshot, snapshot) + self._write_observation(sid, subscribe, snapshot, reset_timer=reset) + self._clear_release_token(sid) + if reset: + detail( + f"完成前观察:{label} 切换为 {kind} 观察," + f"原因={snapshot.get('reason') or 'guard_veto'},重新计时" + ) + return CompletionObservationDecision.hold("继续观察") + + if self._is_allowed_completion(evidence, signal, mode): + detail( + f"完成前观察:{label} 当前证据已允许完成," + f"信号={self._signal_tags(signal)},清理观察状态" + ) + self.clear_observation(subscribe_id) + self._clear_release_token(sid) + return CompletionObservationDecision.allow_complete("信号确认完结") + + if kind == "medium_target_complete": + reset = not record_parseable or not self._same_observation_family(record_snapshot, snapshot) + self._write_observation(sid, subscribe, snapshot, reset_timer=reset) + self._clear_release_token(sid) + observation_record = self._read("blocks").get(sid, {}) + record_snapshot = self._snapshot_from_observation_record(observation_record) + if reset: + detail(f"完成前观察:{label} 切换为 target_complete 观察,重新计时") + effective_timeout = self._effective_timeout(evidence, signal, label) + elapsed = time.time() - record_snapshot.get("blocked_at", time.time()) + if elapsed <= effective_timeout: + return CompletionObservationDecision.hold("继续观察") + self.clear_observation(subscribe_id) + return CompletionObservationDecision.release_guard("完成前观察到期") + + if self._is_low_observation(snapshot, signal): + if not observation_record or not record_parseable: + detail(f"完成前观察:{label} 开始低置信完成前观察") + self._write_observation(sid, subscribe, snapshot, reset_timer=True) + observation_record = self._read("blocks").get(sid, {}) + record_snapshot = self._snapshot_from_observation_record(observation_record) + elif self._same_low_identity(record_snapshot, snapshot): + self._write_observation(sid, subscribe, snapshot, reset_timer=False) + elif self._same_i_family(record_snapshot, snapshot): + detail(f"完成前观察:{label} I 族低置信信号切换,沿用观察计时") + self._write_observation(sid, subscribe, snapshot, reset_timer=False) + else: + detail(f"完成前观察:{label} 低置信观察来源切换,重新计时") + self._write_observation(sid, subscribe, snapshot, reset_timer=True) + observation_record = self._read("blocks").get(sid, {}) + record_snapshot = self._snapshot_from_observation_record(observation_record) + self._clear_release_token(sid) + + effective_timeout = self._effective_timeout(evidence, signal, label) + elapsed = time.time() - record_snapshot.get("blocked_at", time.time()) + if elapsed <= effective_timeout: + return CompletionObservationDecision.hold("继续观察") + + self.record_release_token( + subscribe or subscribe_id, signal, total_episode=total_episode + ) + self.clear_observation(subscribe_id) + return CompletionObservationDecision.release_with_token("完成前观察到期") + + if not observation_record: + self._clear_release_token(sid) + return CompletionObservationDecision.release_guard("完成前观察记录缺失") + + if not record_parseable: + detail(f"完成前观察:{label} 旧观察记录无法解析,重新建立无完成证据观察") + self._write_observation(sid, subscribe, snapshot, reset_timer=True) + self._clear_release_token(sid) + return CompletionObservationDecision.hold("继续观察") + + self._clear_release_token(sid) + effective_timeout = self._effective_timeout(evidence, signal, label) + elapsed = time.time() - record_snapshot.get("blocked_at", time.time()) + if elapsed <= effective_timeout: + return CompletionObservationDecision.hold("继续观察") + + detail(f"完成前观察:{label} 当前无完成证据且观察到期,释放本轮守卫") + self.clear_observation(subscribe_id) + return CompletionObservationDecision.release_guard("完成前观察到期") + + @staticmethod + def _resolve_subscribe(subscribe_or_id): + """同时支持订阅对象和订阅 ID;对象路径可校验媒体身份。""" + if hasattr(subscribe_or_id, "id"): + return subscribe_or_id, subscribe_or_id.id + return None, subscribe_or_id + + def _format_subscribe_label(self, subscribe_id: int) -> str: + """按订阅 ID 生成超时诊断标签;查库失败时仍保留 ID。""" + subscribe = self._subscribe_get(subscribe_id) if self._subscribe_get else None + return format_subscribe_label(subscribe, subscribe_id) + + def _write_observation(self, sid: str, subscribe, snapshot: dict, + reset_timer: bool): + """写入当前观察快照;同源切换可保留原计时。""" + def updater(data: dict) -> dict: + current = data.get(sid, {}) + payload = self._observation_record_payload( + snapshot, + subscribe, + reset_timer=reset_timer, + current=current, + ) + data[sid] = payload + return data + self._update("blocks", updater) + + def _clear_release_token(self, sid: str): + """清理订阅的一次性完成放行令牌。""" + def updater(data: dict) -> dict: + data.pop(sid, None) + return data + self._update("releases", updater) + + def _resolve_total(self, signal: CompletionSignal, + total_episode: Optional[int]) -> Optional[int]: + """优先使用信号携带的 TMDB scope 总数,缺失时回退调用方传入值。""" + return signal.scope_total or total_episode + + def _matches_token(self, token: dict, signal: CompletionSignal, + total_episode: Optional[int]) -> bool: + """判断一次性放行令牌是否仍匹配当前低置信信号。""" + return ( + token.get("confidence") == signal.confidence + and token.get("signals") == list(signal.signals) + and token.get("total_episode") in (None, total_episode) + ) + + @staticmethod + def _identity_mismatched(record: dict, subscribe) -> bool: + """只有带持久化身份的记录才参与 ID 复用保护;旧记录按信号兼容解析。""" + identity = record.get("identity") if record else None + return bool(identity) and not identity_matches(identity, subscribe) + + def _snapshot_from_signal(self, signal: Optional[CompletionSignal], + total_episode: Optional[int]) -> dict: + """把单一完成信号转换为可持久化的观察快照。""" + if signal is None: + return { + "observation_kind": "none", + "signals": [], + "confidence": "", + "total_episode": total_episode, + "reason": "guard_veto", + } + total_episode = self._resolve_total(signal, total_episode) + signals = list(signal.signals) + return { + "observation_kind": self._observation_kind_from_signal(signal), + "signals": signals, + "confidence": signal.confidence, + "total_episode": total_episode, + "reason": signal.reason or "guard_veto", + } + + def _snapshot_from_evidence(self, evidence: CompletionEvidence) -> dict: + """把流水线证据压缩成完成前观察可持久化的身份。""" + signal = self._signal_from_evidence(evidence) + total_episode = evidence.scope_total or (signal.scope_total if signal else None) + snapshot = self._snapshot_from_signal(signal, total_episode) + if evidence.hard_veto is not None: + snapshot["observation_kind"] = "hard_veto" + snapshot["signals"] = list(evidence.hard_veto.signals) + snapshot["confidence"] = evidence.hard_veto.confidence + snapshot["reason"] = evidence.hard_veto.reason or snapshot.get("reason") or "guard_veto" + snapshot["total_episode"] = total_episode + return snapshot + snapshot["observation_kind"] = ( + evidence.observation_kind + or snapshot.get("observation_kind") + or "none" + ) + snapshot["total_episode"] = total_episode + return snapshot + + def _snapshot_from_observation_record(self, record: Optional[dict]) -> dict: + """解析当前和旧格式观察记录;缺少必要信号字段时标记为无法复用计时。""" + if not record: + return {} + signals = record.get("signals") or [] + confidence = record.get("confidence") or "" + return { + "observation_kind": record.get("observation_kind") + or self._observation_kind_from_observation_record(record), + "signals": list(signals), + "confidence": confidence, + "total_episode": record.get("total_episode"), + "reason": record.get("reason") or "", + "blocked_at": record.get("blocked_at"), + } + + @staticmethod + def _signal_from_evidence(evidence: CompletionEvidence) -> CompletionSignal: + """选择代表当前观察身份的信号;G 只留在 evidence,不进入身份。""" + kind = evidence.observation_kind + if kind == "hard_veto" and evidence.hard_veto is not None: + return evidence.hard_veto + if kind == "unstable" and evidence.unstable_signal is not None: + return evidence.unstable_signal + if kind == "medium_target_complete" and evidence.target_complete_signal is not None: + return evidence.target_complete_signal + if kind == "high_completion" and evidence.high_completion is not None: + return evidence.high_completion + if kind == "low_l" and evidence.local_signal is not None: + return evidence.local_signal + if kind == "low_i" and evidence.i_low_signal is not None: + return evidence.i_low_signal + if kind == "i_medium" and evidence.i_signal is not None: + return evidence.i_signal + return ( + evidence.hard_veto + or evidence.high_completion + or evidence.target_complete_signal + or evidence.unstable_signal + or evidence.local_signal + or evidence.i_low_signal + or evidence.i_signal + or evidence.primary_signal + or CompletionSignal(signals=["none"]) + ) + + @staticmethod + def _signal_tags(signal: CompletionSignal) -> str: + """把完成信号来源压缩成日志可读的组合标签。""" + return " + ".join(signal.signals or ["none"]) if signal else "none" + + @staticmethod + def _observation_kind_from_signal(signal: CompletionSignal) -> str: + """按当前完成信号推导观察类别。""" + signals = list(signal.signals) + if signal.confidence == "low" and signals == ["L:target_satisfied"]: + return "low_l" + if signal.confidence == "low" and signals in (["I:all_aired"], ["I:cooldown"]): + return "low_i" + if signal.confidence == "medium" and "L:target_satisfied" in signals: + return "medium_target_complete" + if signal.confidence == "medium": + return "i_medium" + if signal.confidence == "high": + return "high_completion" + if "M:mid_season" in signals or ( + "F:unstable" in signals and signal.volatility_direction == "down" + ): + return "hard_veto" + if "F:unstable" in signals or not signal.stable: + return "unstable" + return "none" + + def _observation_kind_from_observation_record(self, record: dict) -> str: + """从旧格式观察记录的 signals/confidence 恢复当前观察类别。""" + signals = list(record.get("signals") or []) + confidence = record.get("confidence") or "" + if not signals and not confidence and record.get("total_episode") is None: + return "" + if confidence == "low" and signals == ["L:target_satisfied"]: + return "low_l" + if confidence == "low" and signals in (["I:all_aired"], ["I:cooldown"]): + return "low_i" + if confidence == "medium" and "L:target_satisfied" in signals: + return "medium_target_complete" + if confidence == "medium": + return "i_medium" + if confidence == "high": + return "high_completion" + if "M:mid_season" in signals: + return "hard_veto" + if "F:unstable" in signals: + return "unstable" + if signals == ["none"]: + return "none" + return "" + + @staticmethod + def _is_parseable_snapshot(snapshot: dict) -> bool: + """判断旧格式观察记录是否足以参与当前状态机;否则必须重新建档。""" + return bool(snapshot and snapshot.get("observation_kind")) + + @staticmethod + def _is_allowed_completion(evidence: CompletionEvidence, + signal: CompletionSignal, + mode: str) -> bool: + """高置信、独立 medium I、宽松策略下的 target_complete 可结束观察。""" + if signal and signal.completed and signal.confidence == "high": + return True + if not signal or not signal.completed or signal.confidence != "medium": + return False + if ( + evidence.target_complete_signal is signal + or evidence.observation_kind == "medium_target_complete" + or "L:target_satisfied" in list(signal.signals or []) + ): + return mode in ("balanced", "loose") + return bool( + evidence.observation_kind == "i_medium" + or evidence.i_signal is signal + ) + + @staticmethod + def _is_low_observation(snapshot: dict, signal: CompletionSignal) -> bool: + """低置信 L/I 才能生成一次性放行令牌。""" + return bool( + signal + and signal.completed + and signal.confidence == "low" + and snapshot.get("observation_kind") in ("low_l", "low_i") + ) + + @staticmethod + def _same_low_identity(left: dict, right: dict) -> bool: + """同一低置信观察身份可以沿用计时。""" + return ( + left.get("observation_kind") == right.get("observation_kind") + and left.get("signals") == right.get("signals") + and left.get("confidence") == right.get("confidence") + and left.get("total_episode") in (None, right.get("total_episode")) + ) + + @staticmethod + def _same_i_family(left: dict, right: dict) -> bool: + """I:all_aired 与 I:cooldown 属于同源 I 族,切换不重置计时。""" + i_signals = (["I:all_aired"], ["I:cooldown"]) + return ( + left.get("observation_kind") == "low_i" + and right.get("observation_kind") == "low_i" + and left.get("signals") in i_signals + and right.get("signals") in i_signals + and left.get("total_episode") in (None, right.get("total_episode")) + ) + + @staticmethod + def _same_observation_family(left: dict, right: dict) -> bool: + """hard veto 或 F 观察同类保持计时,跨类重新计时。""" + return ( + left.get("observation_kind") == right.get("observation_kind") + and left.get("signals") == right.get("signals") + and left.get("total_episode") in (None, right.get("total_episode")) + ) + + def _effective_timeout(self, evidence: CompletionEvidence, + signal: CompletionSignal, + label: str) -> float: + """G 只影响观察超时阈值,不改变观察身份。""" + if self._cadence_acceleration and ( + evidence.cadence_expired or getattr(signal, "cadence_expired", False) + ): + detail(f"完成前观察:{label} 节奏已到期,观察阈值减半加速释放") + return self._timeout_seconds / 2 + return self._timeout_seconds + + @staticmethod + def _observation_record_payload( + snapshot: dict, + subscribe=None, + reset_timer: bool = True, + current: Optional[dict] = None, + ) -> dict: + """按观察快照生成持久化观察记录,保留同源切换的既有计时。""" + current = current or {} + payload = { + "blocked_at": time.time() if reset_timer else current.get("blocked_at", time.time()), + "reason": snapshot.get("reason") or "guard_veto", + "observation_kind": snapshot.get("observation_kind") or "none", + "signals": list(snapshot.get("signals") or []), + "confidence": snapshot.get("confidence") or "", + "total_episode": snapshot.get("total_episode"), + } + if subscribe is not None: + payload["identity"] = subscribe_identity(subscribe) + elif current.get("identity"): + payload["identity"] = current["identity"] + return payload diff --git a/plugins.v3/subscribeassistantenhanced/postcheck/verifier.py b/plugins.v3/subscribeassistantenhanced/postcheck/verifier.py new file mode 100644 index 00000000..efb08685 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/postcheck/verifier.py @@ -0,0 +1,245 @@ +"""完成后异步自验证:保存完成快照、检测增集并重建订阅。""" +import time +from typing import Callable, Optional + +from app.log import logger + +from ..engine.types import SeasonScope +from ..shared.log import detail +from ..shared.subscribe import ( + format_subscribe, + format_subscribe_label, + is_full_best_version_subscribe, + subscribe_media_identity, + subscribe_tmdb_id, +) + + +class CompletionVerifier: + """完成后定期复查 TMDB,发现增集自动重建订阅。""" + + def __init__(self, task_data_read: Callable, task_data_update: Callable, + tmdb_episodes_fn: Optional[Callable] = None, + subscribe_oper=None, + retention_days: int = 180, + notify_fn: Optional[Callable] = None, + rebuild_subscribe_fn: Optional[Callable] = None, + validate_rebuild_subscribe_fn: Optional[Callable] = None, + get_subscribe_image_fn: Optional[Callable] = None): + """注入完成快照存储、集数查询、订阅查询和真实订阅重建能力。""" + self._read = task_data_read + self._update = task_data_update + self._tmdb_fn = tmdb_episodes_fn + self._subscribe_oper = subscribe_oper + self._retention_seconds = retention_days * 86400 + self._notify = notify_fn + self._rebuild_subscribe = rebuild_subscribe_fn + self._validate_rebuild_subscribe = validate_rebuild_subscribe_fn + self._get_subscribe_image = get_subscribe_image_fn + + def snapshot(self, subscribe, mediainfo, scope: Optional[SeasonScope]): + """保存完成快照,同一季同一剧集组只保留最新记录。""" + media_source, media_id = subscribe_media_identity(subscribe) + tmdbid = subscribe_tmdb_id(subscribe) + if tmdbid is None: + return + season = subscribe.season + episode_group_id = subscribe.episode_group + total = subscribe.total_episode + + snap = { + "media_source": media_source, + "media_id": media_id, + "season": season, + "episode_group_id": episode_group_id, + "scope_source": scope.source if scope else "main_season", + "total_at_completion": total, + "completed_at": time.time(), + "subscribe_config": _extract_config(subscribe), + } + image = self._get_subscribe_image(subscribe) if self._get_subscribe_image else None + if not image and mediainfo: + image = mediainfo.get_message_image() + if image: + snap["subscribe_image"] = image + + def updater(data: dict) -> dict: + snapshots = data.get("list", []) + key = (media_source, media_id, season, episode_group_id) + snapshots = [s for s in snapshots if _snap_key(s) != key] + snapshots.append(snap) + data["list"] = snapshots + return data + + detail(f"完成后验证:{format_subscribe_label(subscribe)} 登记完成快照(完成时总集数={total})") + self._update("snapshots", updater) + + def verify_all(self): + """定时复查所有完成快照。""" + self.cleanup_expired() + data = self._read("snapshots") + snapshots = data.get("list", []) + to_remove = [] + + for snap in snapshots: + try: + current_total = self._fetch_current_total(snap) + if current_total is not None and current_total > snap.get("total_at_completion", 0): + snap_label = format_snapshot_label(snap) + logger.info(f"完成后验证:{snap_label} 检测到增集 {snap.get('total_at_completion', 0)}→{current_total},尝试重建订阅") + if self._rebuild(snap, current_total): + to_remove.append(snap) + except Exception as err: + # 单条快照失败时保留原记录重试,不能阻断同批其他订阅的纠错。 + logger.warning( + f"完成后验证:{format_snapshot_label(snap)} 处理失败," + f"已保留快照等待重试,error={err}" + ) + + if to_remove: + self._remove_snapshots(to_remove) + + def cleanup_expired(self) -> int: + """按用户配置的保留期清理 H 快照,不请求 TMDB。""" + data = self._read("snapshots") + snapshots = data.get("list", []) + now = time.time() + expired = [ + snap for snap in snapshots + if now - snap.get("completed_at", now) > self._retention_seconds + ] + if expired: + self._remove_snapshots(expired) + return len(expired) + + def _fetch_current_total(self, snap: dict) -> Optional[int]: + if not self._tmdb_fn: + return None + episode_group_id = snap.get("episode_group_id") + if episode_group_id: + episodes = self._tmdb_fn(int(snap["media_id"]), snap["season"], + episode_group=episode_group_id) + else: + episodes = self._tmdb_fn(int(snap["media_id"]), snap["season"]) + return len(episodes) if episodes else None + + def _rebuild(self, snap: dict, current_total: int) -> bool: + """发现增集后清理完成快照并重建订阅;失败时保留快照重试。""" + if not self._subscribe_oper: + return False + media_source = snap["media_source"] + media_id = str(snap["media_id"]) + tmdbid = int(media_id) + season = snap["season"] + episode_group_id = snap.get("episode_group_id") + config = dict(snap.get("subscribe_config", {})) + removed_full_best_version = False + + existing = self._subscribe_oper.list() + matched = [ + sub for sub in (existing or []) + if ( + sub.media_source == media_source + and str(sub.media_id) == media_id + and sub.season == season + and sub.episode_group == episode_group_id + ) + ] + + full_best_version_subscribes = [ + sub for sub in matched if is_full_best_version_subscribe(sub) + ] + if full_best_version_subscribes: + for sub in full_best_version_subscribes: + logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅") + self._subscribe_oper.delete(sub.id) + removed_full_best_version = True + else: + covered = [sub for sub in matched if (sub.total_episode or 0) >= current_total] + if ( + covered + and self._validate_rebuild_subscribe + and any(self._validate_rebuild_subscribe(sub, snap, current_total) for sub in covered) + ): + return True + + # 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。 + if matched: + return False + + old_total = snap.get("total_at_completion", 0) + config["start_episode"] = old_total + 1 + config["total_episode"] = current_total + config["lack_episode"] = current_total - old_total + if not self._rebuild_subscribe or not self._rebuild_subscribe(snap, config): + return False + + if self._notify: + name = config.get("name", f"TMDB {tmdbid}") + season = config.get("season", season) + season_text = f" S{season}" if season is not None else "" + action_text = "已移除旧洗版订阅并重建订阅" if removed_full_best_version else "已自动重建订阅" + self._notify( + f"{name}{season_text} 检测到新增集数({old_total}→{current_total}),{action_text}", + image=snap.get("subscribe_image"), + ) + return True + + def _remove_snapshots(self, to_remove: list): + keys_to_remove = {_snap_key(s) for s in to_remove} + + def updater(data: dict) -> dict: + snapshots = data.get("list", []) + data["list"] = [s for s in snapshots if _snap_key(s) not in keys_to_remove] + return data + + self._update("snapshots", updater) + + +def _snap_key(snap: dict) -> tuple: + return ( + snap.get("media_source"), + str(snap.get("media_id") or ""), + snap.get("season"), + snap.get("episode_group_id"), + ) + + +def format_snapshot_label(snap: dict) -> str: + """格式化完成快照日志标签;配置缺名称时回退到 TMDB/季号。""" + config = snap.get("subscribe_config") or {} + name = config.get("name") + if name: + probe = type("SnapshotSubscribe", (), {"name": name, "season": snap.get("season")})() + return format_subscribe(probe) + return f"{snap.get('media_source')}:{snap.get('media_id')} S{snap.get('season')}" + + +def _extract_config(subscribe) -> dict: + """提取订阅配置用于重建。""" + values = { + "name": subscribe.name, + "year": subscribe.year, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, + "episode_group": subscribe.episode_group, + "type": subscribe.type, + "best_version": int(bool(subscribe.best_version)), + "best_version_full": int(bool(subscribe.best_version_full)), + "keyword": subscribe.keyword, + "save_path": subscribe.save_path, + "sites": subscribe.sites, + "downloader": subscribe.downloader, + "filter": subscribe.filter, + "filter_groups": subscribe.filter_groups, + "include": subscribe.include, + "exclude": subscribe.exclude, + "quality": subscribe.quality, + "resolution": subscribe.resolution, + "effect": subscribe.effect, + "search_imdbid": subscribe.search_imdbid, + "custom_words": subscribe.custom_words, + "media_category": subscribe.media_category, + } + return {field: value for field, value in values.items() if value is not None} diff --git a/plugins.v3/subscribeassistantenhanced/recognition/__init__.py b/plugins.v3/subscribeassistantenhanced/recognition/__init__.py new file mode 100644 index 00000000..886516bd --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/__init__.py @@ -0,0 +1,5 @@ +"""识别增强:订阅候选资源准入域。""" +from .guard import RecognitionGuard +from .types import RecognitionRuntime, RecognitionSettings + +__all__ = ["RecognitionSettings", "RecognitionRuntime", "RecognitionGuard"] diff --git a/plugins.v3/subscribeassistantenhanced/recognition/audit.py b/plugins.v3/subscribeassistantenhanced/recognition/audit.py new file mode 100644 index 00000000..ad840565 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/audit.py @@ -0,0 +1,49 @@ +"""识别增强审计摘要工具。""" +import hashlib +import re + +from ..shared.log import truncate_log_value + + +_URL_RE = re.compile(r"(?:https?|ftp)://\S+|magnet:\?\S+", re.IGNORECASE) +_AUTH_SCHEME_RE = re.compile( + r"(?i)\b(?:authorization|auth)\s*(?::|=)?\s*(?:bearer|basic|digest|token)\s+[^\s&;,|]+" +) +_SECRET_RE = re.compile( + r"(?i)\b(token|passkey|apikey|api_key|authorization|auth|password|passwd|pwd|sid|session|cookie)" + r"\s*[:=]\s*[^&\s]+" +) +_COOKIE_HEADER_RE = re.compile( + r"(?i)\bcookie\s*(?::|=)\s*[^\r\n|]*[^\s\r\n|]|\bcookie\s+(?=[\w.-]+\s*=)[^\r\n|]*[^\s\r\n|]" +) +_LOCAL_PATH_RE = re.compile(r"(?:/[^\s|;;,,]+){2,}") + + +def candidate_fingerprint(torrent_info) -> str: + """生成稳定候选指纹;输入可含敏感 URL,但输出只暴露不可逆短摘要。""" + raw = "\n".join([ + str(getattr(torrent_info, "enclosure", "") or ""), + str(getattr(torrent_info, "page_url", "") or ""), + str(getattr(torrent_info, "site_name", "") or getattr(torrent_info, "site", "") or ""), + str(getattr(torrent_info, "title", "") or ""), + ]) + return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] + + +def redact_sensitive_text(value) -> str: + """脱敏审计文本中的链接和常见凭据字段,避免日志暴露站点令牌。""" + text = str(value or "") + text = _URL_RE.sub("[redacted-url]", text) + text = _AUTH_SCHEME_RE.sub("[redacted-secret]", text) + text = _COOKIE_HEADER_RE.sub("[redacted-secret]", text) + text = _SECRET_RE.sub("[redacted-secret]", text) + return _LOCAL_PATH_RE.sub("[redacted-path]", text) + + +def sanitize_candidate_summary(torrent_info, max_length: int = 220) -> str: + """候选审计摘要:只保留站点、短指纹和可展示文本,避免泄漏下载链接参数。""" + site = getattr(torrent_info, "site_name", None) or getattr(torrent_info, "site", None) or "-" + title = truncate_log_value(redact_sensitive_text(getattr(torrent_info, "title", "") or ""), 120) + desc = truncate_log_value(redact_sensitive_text(getattr(torrent_info, "description", "") or ""), 80) + fp = candidate_fingerprint(torrent_info) + return truncate_log_value(f"{site} #{fp} {title} {desc}", max_length) diff --git a/plugins.v3/subscribeassistantenhanced/recognition/guard.py b/plugins.v3/subscribeassistantenhanced/recognition/guard.py new file mode 100644 index 00000000..9fc0400b --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/guard.py @@ -0,0 +1,952 @@ +"""识别增强候选准入判定器。""" +import re +import time +from collections import Counter +from dataclasses import fields +from hashlib import sha1 +from types import SimpleNamespace + +from app.core.metainfo import MetaInfo + +from .audit import redact_sensitive_text, sanitize_candidate_summary +from .keywords import load_keyword_groups, match_first +from .scope import build_target, candidate_from_context +from .strategy import parse_strategy +from .types import ( + ACTION_ALLOW, + ACTION_BLOCK, + ACTION_FAIL_OPEN, + ACTION_OBSERVE, + ACTION_SOFT_BLOCK, + BatchDecision, + CandidateResource, + Decision, + Evidence, + RecognitionRuntime, + RecognitionSettings, + RecognitionTarget, + SecondaryRecognitionRouteResult, +) + +_SECONDARY_CACHE_KEY_VERSION = "v2" +_SECONDARY_ROUTE_TITLE = "title" +_SECONDARY_ROUTE_TITLE_SUBTITLE = "title_subtitle" +_SECONDARY_CONTROL_FIELDS = ("tmdbid", "doubanid", "episode_group", "type") +_BRACED_CONTROL_TAG_RE = re.compile( + r"\{\[[^\]]*(?:tmdbid|doubanid|type|g|s|e)\s*=[^\]]*]\}", + re.IGNORECASE, +) +_SQUARE_TMDB_TAG_RE = re.compile(r"\[(?:tmdbid|tmdb)[=-]\d+]", re.IGNORECASE) +_BRACE_TMDB_TAG_RE = re.compile(r"\{(?:tmdbid|tmdb)[=-]\d+}", re.IGNORECASE) +_HARD_BLOCK = "hard_block" +_SOFT_BLOCK = "soft_block" +_OBSERVE = "observe" +_ALLOW = "allow" +_POLICY_BLOCK = "policy_block" +_TRUSTED_MATCH_SOURCES = {"tmdbid", "doubanid"} +_FAIL_OPEN_COUNTERABLE_CODES = {"missing_year"} + + +class RecognitionGuard: + """订阅候选资源识别增强准入器。""" + + def __init__(self, settings: RecognitionSettings, runtime: RecognitionRuntime | None = None): + self.settings = settings or RecognitionSettings() + self.runtime = runtime or RecognitionRuntime() + self.strategy = parse_strategy(self.settings.mode, self.settings.custom_config) + # keyword_config 是内部测试/运行对象入口,不是公开配置键;用户 YAML 使用 custom_config。 + self.keyword_groups = ( + load_keyword_groups(self.settings.keyword_config) + if self.settings.keyword_config and not self.settings.custom_config + else self.strategy.keyword_groups + ) + self.last_batch: BatchDecision | None = None + self.last_audit_summary = "" + self.last_target: RecognitionTarget | None = None + self.last_notification = None + self._notification_cache: dict[tuple, float] = {} + self._secondary_cache: dict[str, dict] = {} + + def evaluate(self, target: RecognitionTarget, candidate: CandidateResource, + secondary_failed: bool = False) -> Decision: + """评估单个候选资源,输出原始动作与最终动作。""" + if self.settings.mode == "off": + return Decision(candidate=candidate) + + decision = self._decide_enabled(target, candidate, secondary_failed=secondary_failed) + if self.settings.mode == "audit": + decision.would_action = decision.final_action + decision.final_action = ACTION_OBSERVE if decision.would_action != ACTION_ALLOW else ACTION_ALLOW + return decision + + def evaluate_dicts(self, target_dict, candidate_dict, secondary_failed: bool = False) -> Decision: + """从 dict 构建目标和候选摘要,便于测试和样本回放。""" + target = self._target_from_dict(target_dict) + candidate = self._candidate_from_dict(candidate_dict) + return self.evaluate(target, candidate, secondary_failed=secondary_failed) + + def filter(self, contexts, *, subscribe, event_data=None, selection_original_count=None, stage_counts=None): + """过滤 ResourceSelection 候选上下文,返回保留下来的原始 context 列表。""" + mediainfo = None + if self.runtime.target_mediainfo_resolver: + mediainfo = self.runtime.target_mediainfo_resolver(subscribe) + target = build_target( + subscribe, + mediainfo=mediainfo, + tmdb_episodes_fn=self.runtime.tmdb_episodes_fn, + ) + self.last_target = target + raw_contexts = list(contexts or []) + candidates = [ + self._candidate_with_secondary(target, context, candidate_from_context(context, order=index)) + for index, context in enumerate(raw_contexts) + ] + batch = self.filter_candidate_dicts( + target, + candidates, + raw_contexts, + selection_original_count=selection_original_count, + stage_counts=stage_counts, + ) + return batch.retained + + def filter_candidate_dicts(self, target_dict, candidate_dicts, contexts, *, + selection_original_count=None, stage_counts=None) -> BatchDecision: + """批量评估候选并应用空结果保护。""" + target = target_dict if isinstance(target_dict, RecognitionTarget) else self._target_from_dict(target_dict) + self.last_target = target + candidates = [ + item if isinstance(item, CandidateResource) else self._candidate_from_dict(item) + for item in list(candidate_dicts or []) + ] + context_items = list(contexts or []) + if self.settings.mode == "off": + decisions = [Decision(candidate=candidate) for candidate in candidates] + retained = context_items[:len(candidates)] + output_count = len(retained) + batch = self._make_batch( + decisions, + retained, + len(candidates), + selection_original_count=selection_original_count, + stage_counts=self._with_recognition_stage(stage_counts, len(candidates), output_count), + ) + self.last_batch = batch + self.last_audit_summary = batch.audit_summary + return batch + + decisions = [self.evaluate(target, candidate) for candidate in candidates] + retained = [ + context + for context, decision in zip(context_items, decisions) + if decision.final_action not in {ACTION_BLOCK, ACTION_SOFT_BLOCK} + ] + fallback_applied = False + fallback_reason = "" + if not retained and decisions: + recovered = [] + for context, decision in zip(context_items, decisions): + if decision.final_action == ACTION_SOFT_BLOCK and self.strategy.is_recoverable(decision.code): + decision.final_action = ACTION_OBSERVE + recovered.append(context) + if recovered: + retained = recovered + fallback_applied = True + fallback_reason = "soft_block_empty_result_protection" + batch = self._make_batch( + decisions, + retained, + len(candidates), + selection_original_count=selection_original_count, + stage_counts=self._with_recognition_stage(stage_counts, len(candidates), len(retained)), + fallback_applied=fallback_applied, + fallback_reason=fallback_reason, + ) + self.last_batch = batch + self.last_audit_summary = batch.audit_summary + return batch + + def finalize_batch(self, final_count, stage_counts=None) -> BatchDecision | None: + """下游过滤完成后刷新最终计数和审计摘要。""" + if not self.last_batch: + return None + self.last_batch.final_count = int(final_count or 0) + if stage_counts is not None: + self.last_batch.stage_counts = list(stage_counts) + if not any(stage.get("stage") == "recognition" for stage in self.last_batch.stage_counts): + self.last_batch.stage_counts.insert( + 1, + { + "stage": "recognition", + "input": self.last_batch.recognition_input_count, + "output": self.last_batch.recognition_output_count, + }, + ) + self.last_batch.audit_summary = self._audit_summary(self.last_batch) + self.last_batch.notification_summary = self._notification_summary(self.last_batch) + self.last_audit_summary = self.last_batch.audit_summary + self._log_audit() + return self.last_batch + + def notification_payload(self, subscribe): + """返回识别增强通知负载;通知限频不改变过滤结果或审计摘要。""" + if not self.last_batch: + return None + notify_mode = self.settings.notify_mode or "off" + if self.settings.mode == "off" or notify_mode == "off": + return None + notify_actions = {ACTION_BLOCK, ACTION_SOFT_BLOCK} + if notify_mode == "all": + notify_actions.add(ACTION_OBSERVE) + decisions = [ + decision for decision in self.last_batch.decisions + if decision.final_action in notify_actions + ] + if not decisions: + return None + first = decisions[0] + subscribe_id = subscribe.id + key = (subscribe_id, first.final_action, first.code, first.reason) + now = time.time() + if self._notification_cache.get(key, 0) > now: + return None + self._notification_cache[key] = now + max(1, int(self.settings.notify_interval or 1)) + + counts = Counter(decision.final_action for decision in decisions) + title = f"识别增强:{subscribe.name or '订阅'} 候选风险" + lines = [ + f"拦截 {counts.get(ACTION_BLOCK, 0)} 条," + f"软拦截 {counts.get(ACTION_SOFT_BLOCK, 0)} 条," + f"观察 {counts.get(ACTION_OBSERVE, 0)} 条" + ] + if notify_mode == "summary": + self.last_notification = (title, "\n".join(lines)) + return self.last_notification + for decision in decisions[:10]: + candidate = getattr(decision, "candidate", None) or CandidateResource() + summary = sanitize_candidate_summary(self._torrent_like(candidate), max_length=120) + lines.append(f"- {decision.code}:{self._safe_reason(decision.reason)};{summary}") + self.last_notification = (title, "\n".join(lines)) + return self.last_notification + + def _decide_enabled(self, target: RecognitionTarget, candidate: CandidateResource, + secondary_failed: bool = False) -> Decision: + text = self._candidate_text(candidate) + allow_match = match_first(self.keyword_groups.allow, text) + hard_block_match = match_first(self.keyword_groups.hard_block, text) + block_match = match_first(self.keyword_groups.block, text) + live_action_match = self._live_action_match(text) + trusted_identity = self._trusted_same_identity(target, candidate) + + if hard_block_match: + return self._decision( + ACTION_BLOCK, + "user_hard_block", + "命中 hard_block 关键字", + candidate, + risk=_HARD_BLOCK, + ) + id_mismatch = self._id_mismatch(target, candidate) + if id_mismatch: + code, reason = id_mismatch + if allow_match: + reason = f"{reason},allow 关键字仅作为抵消证据" + return self._decision(ACTION_BLOCK, code, reason, candidate, risk=_HARD_BLOCK) + + hard_range_decision, soft_range_decision = self._range_decisions(target, candidate) + if hard_range_decision: + action, code, reason = hard_range_decision + return self._decision(action, code, reason, candidate, risk=_HARD_BLOCK) + + shape_decision = self._shape_decision( + target, + candidate, + live_action_match, + secondary_failed=secondary_failed, + ) + if shape_decision: + action, code, reason = shape_decision + return self._decision(action, code, reason, candidate, risk=_HARD_BLOCK) + + hard_type_decision, soft_type_decision = self._type_decisions(target, candidate, trusted_identity) + if hard_type_decision: + action, code, reason = hard_type_decision + return self._decision(action, code, reason, candidate, risk=_HARD_BLOCK) + + if trusted_identity: + return self._decision(ACTION_ALLOW, "candidate_same_identity", "候选识别身份与订阅目标一致", candidate) + + soft_evidence = [] + if soft_range_decision: + soft_evidence.append(soft_range_decision) + + if soft_type_decision: + soft_evidence.append(soft_type_decision) + + if block_match: + action = self.strategy.action_for("user_block") + soft_evidence.append((action, "user_block", "命中 block 关键字")) + + secondary_decision = self._secondary_decision(target, candidate, secondary_failed=secondary_failed) + if secondary_decision: + action, code, reason = secondary_decision + soft_evidence.append((action, code, reason)) + + if candidate.year is None: + action = self._missing_year_action() + soft_evidence.append((action, "missing_year", "候选缺少年份,按当前模式记录")) + + if soft_evidence: + return self._combine_soft_evidence(soft_evidence, candidate, allow_match) + + if allow_match: + return self._allow_decision(candidate, allow_match) + return self._decision(ACTION_ALLOW, "allow", "未命中风险证据", candidate) + + def _range_decisions(self, target: RecognitionTarget, candidate: CandidateResource): + if target.range_confidence == "unknown" and not target.target_episodes: + return None, (ACTION_FAIL_OPEN, "target_range_unknown", "目标范围不可用,范围 veto fail-open") + if target.range_confidence != "high" or not target.target_episodes: + return None, None + if self._known_season_conflict(target, candidate): + return (ACTION_BLOCK, "target_range_not_covered", "候选季与订阅目标季不一致"), None + if not candidate.episodes: + return None, None + target_set = set(target.target_episodes) + candidate_set = set(candidate.episodes) + if target.season == 0 and candidate.season_kind == "special" and candidate_set & target_set: + return None, None + if target_set.isdisjoint(candidate_set): + return (ACTION_BLOCK, "target_range_not_covered", "候选集数范围与订阅目标完全不相交"), None + if len(candidate_set) >= max(len(target_set) * 3, len(target_set) + 24) and target_set.issubset(candidate_set): + return None, ( + self.strategy.action_for("target_range_oversized"), + "target_range_oversized", + "候选全集范围明显大于本次目标窗口", + ) + return None, None + + def _shape_decision(self, target: RecognitionTarget, candidate: CandidateResource, live_action_match: str | None, + secondary_failed: bool = False): + if target.shape != "animation" or not live_action_match: + return None + return ACTION_BLOCK, "animation_live_action_conflict", f"动画目标命中真人实拍信号:{live_action_match}" + + def _type_decisions(self, target: RecognitionTarget, candidate: CandidateResource, trusted_identity: bool): + text = self._candidate_text(candidate) + episode_signal = bool(candidate.episodes or re.search(r"\bS\d{1,3}(?:E\d{1,4})?\b|第\s*\d+\s*集", text, re.I)) + movie_match = match_first(self.keyword_groups.movie, text) or match_first(["电影版", "剧场版", "劇場版"], text) + if target.media_type == "电视剧" and movie_match and not episode_signal: + return (ACTION_BLOCK, "series_movie_conflict", f"剧集目标命中电影版资源信号:{movie_match}"), None + if target.media_type != "电影" or trusted_identity: + return None, None + if episode_signal: + return (ACTION_BLOCK, "movie_series_conflict", "电影目标命中剧集资源信号"), None + return None, None + + def _secondary_decision(self, target: RecognitionTarget, candidate: CandidateResource, secondary_failed: bool = False): + if secondary_failed or candidate.secondary_status == "failed": + reason = "二次识别失败,按 fail-open 放行" + if candidate.secondary_failure: + reason = f"{reason}:{candidate.secondary_failure}" + return ACTION_FAIL_OPEN, "secondary_recognition_fail_open", reason + if candidate.secondary_status == "empty": + return ACTION_FAIL_OPEN, "secondary_recognition_fail_open", "二次识别无结果,按 fail-open 放行" + secondary_tmdb_id = candidate.secondary_tmdb_id + secondary_douban_id = candidate.secondary_douban_id + mismatch = ( + target.tmdb_id and secondary_tmdb_id and int(target.tmdb_id) != int(secondary_tmdb_id) + ) or ( + target.douban_id and secondary_douban_id and str(target.douban_id) != str(secondary_douban_id) + ) + if not mismatch: + return None + if self._has_strong_alias(target, candidate): + return ACTION_OBSERVE, "secondary_identity_conflict_with_alias", "二次识别不一致但候选含目标中文别名" + action = self.strategy.action_for("secondary_identity_conflict") + return action, "secondary_identity_conflict", "二次识别结果与订阅目标不一致" + + def _id_mismatch(self, target: RecognitionTarget, candidate: CandidateResource): + if target.tmdb_id and candidate.explicit_tmdb_id and int(target.tmdb_id) != int(candidate.explicit_tmdb_id): + return "tmdb_id_mismatch", ( + f"候选显式 TMDB ID {candidate.explicit_tmdb_id} 与订阅目标 {target.tmdb_id} 不一致" + ) + if target.douban_id and candidate.explicit_douban_id and str(target.douban_id) != str(candidate.explicit_douban_id): + return "douban_id_mismatch", ( + f"候选显式豆瓣 ID {candidate.explicit_douban_id} 与订阅目标 {target.douban_id} 不一致" + ) + return None + + def _trusted_same_identity(self, target: RecognitionTarget, candidate: CandidateResource) -> bool: + if not candidate.candidate_recognized or candidate.media_info_is_target: + return False + if candidate.match_source not in _TRUSTED_MATCH_SOURCES: + return False + if target.tmdb_id and candidate.explicit_tmdb_id and int(target.tmdb_id) == int(candidate.explicit_tmdb_id): + return True + if target.tmdb_id and candidate.recognized_tmdb_id and int(target.tmdb_id) == int(candidate.recognized_tmdb_id): + return True + if target.douban_id and candidate.explicit_douban_id and str(target.douban_id) == str(candidate.explicit_douban_id): + return True + return bool(target.douban_id and candidate.recognized_douban_id + and str(target.douban_id) == str(candidate.recognized_douban_id)) + + def _has_strong_alias(self, target: RecognitionTarget, candidate: CandidateResource) -> bool: + text = self._candidate_text(candidate) + aliases = target.aliases or [target.name] + for alias in aliases: + if not alias or alias not in text: + continue + strength = target.alias_strengths.get(alias) + if strength == "weak": + continue + if any("\u4e00" <= char <= "\u9fff" for char in alias): + return True + return False + + def _mode_action(self, *, loose: str, balanced: str, strict: str) -> str: + if self.settings.mode == "loose": + return loose + if self.settings.mode == "strict": + return strict + return balanced + + def _missing_year_action(self) -> str: + return self.strategy.action_for("missing_year") + + def _risk_or_allow(self, action: str, code: str, reason: str, candidate: CandidateResource, + allow_match: str | None) -> Decision: + if allow_match and action != ACTION_BLOCK: + decision = self._allow_decision(candidate, allow_match) + decision.evidence.append(Evidence(group="recognition", code=code, level=self._risk_for_action(action), + message=reason)) + return decision + return self._decision(action, code, reason, candidate, risk=self._risk_for_action(action)) + + def _combine_soft_evidence(self, evidence_items: list[tuple[str, str, str]], candidate: CandidateResource, + allow_match: str | None) -> Decision: + """合并低/策略风险证据;用户策略 block 不伪装成 hard veto。""" + fail_open_items = [item for item in evidence_items if item[0] == ACTION_FAIL_OPEN] + independent_risk_items = [ + item + for item in evidence_items + if item[0] in {ACTION_SOFT_BLOCK, ACTION_BLOCK} and not self._can_fail_open_counter(item) + ] + if fail_open_items and not independent_risk_items: + primary = fail_open_items[0] + else: + ranked_actions = [ACTION_ALLOW, ACTION_OBSERVE, ACTION_SOFT_BLOCK, ACTION_BLOCK] + primary = max( + [item for item in evidence_items if item[0] != ACTION_FAIL_OPEN], + key=lambda item: ranked_actions.index(item[0]), + ) + action, code, reason = primary + decision = self._risk_or_allow(action, code, reason, candidate, allow_match) + existing_codes = {item.code for item in decision.evidence} + for item_action, item_code, item_reason in evidence_items: + if item_code in existing_codes: + continue + decision.evidence.append(Evidence( + group="recognition", + code=item_code, + level=self._risk_for_action(item_action), + message=item_reason, + )) + return decision + + def _can_fail_open_counter(self, item: tuple[str, str, str]) -> bool: + """二次识别 fail-open 只抵消模式模板自带的空结果风险,不覆盖用户显式策略。""" + action, code, _reason = item + return ( + code in _FAIL_OPEN_COUNTERABLE_CODES + and action == ACTION_BLOCK + and code not in self.strategy.explicit_actions + ) + + def _allow_decision(self, candidate: CandidateResource, allow_match: str) -> Decision: + decision = self._decision( + ACTION_ALLOW, + "user_allow", + "命中 allow 关键字", + candidate, + risk=_ALLOW, + ) + decision.counters.append(Evidence(group="keyword", code="user_allow", level=_ALLOW, + message="allow 关键字")) + return decision + + def _decision(self, action: str, code: str, reason: str, candidate: CandidateResource, + risk: str = "none") -> Decision: + evidence = [] + if code != "allow": + evidence.append(Evidence(group="recognition", code=code, level=risk, message=reason)) + return Decision( + action=action, + final_action=action, + code=code, + reason=reason, + risk=risk, + would_action=action, + candidate=candidate, + evidence=evidence, + ) + + def _make_batch(self, decisions: list[Decision], retained: list, input_count: int, *, + selection_original_count=None, stage_counts=None, fallback_applied=False, + fallback_reason: str = "") -> BatchDecision: + original_action_counts = Counter(decision.action for decision in decisions) + final_action_counts = Counter(decision.final_action for decision in decisions) + output_count = len(retained) + batch = BatchDecision( + input_count=input_count, + output_count=output_count, + selection_original_count=selection_original_count if selection_original_count is not None else input_count, + recognition_input_count=input_count, + recognition_evaluated_count=len(decisions), + recognition_output_count=output_count, + final_count=output_count, + decisions=decisions, + retained=retained, + stage_counts=list(stage_counts or []), + fallback_applied=fallback_applied, + action_counts=dict(final_action_counts), + original_action_counts=dict(original_action_counts), + final_action_counts=dict(final_action_counts), + ) + batch.fallback_reason = fallback_reason + batch.audit_summary = self._audit_summary(batch) + batch.notification_summary = self._notification_summary(batch) + return batch + + @staticmethod + def _with_recognition_stage(stage_counts, input_count: int, output_count: int) -> list[dict]: + stages = list(stage_counts or []) + stages.append({"stage": "recognition", "input": input_count, "output": output_count}) + return stages + + def _candidate_with_secondary(self, target: RecognitionTarget, context, candidate: CandidateResource + ) -> CandidateResource: + if not self._should_run_secondary(): + return candidate + title_meta = getattr(context, "meta_info", None) + if not title_meta or not self.runtime.secondary_recognizer: + return candidate + + title_route_title = self._meta_route_title(title_meta, candidate.title) + title_route_subtitle = self._meta_route_subtitle(title_meta, candidate.description) + routes = [ + self._run_secondary_route( + target, + candidate, + _SECONDARY_ROUTE_TITLE, + title_route_title, + title_route_subtitle, + title_meta, + ), + self._run_title_subtitle_route(target, candidate, title_meta), + ] + candidate.secondary_routes = routes + self._apply_secondary_routes(target, candidate) + return candidate + + def _run_title_subtitle_route(self, target: RecognitionTarget, candidate: CandidateResource, title_meta + ) -> SecondaryRecognitionRouteResult: + subtitle = candidate.description or "" + if not subtitle.strip(): + return SecondaryRecognitionRouteResult( + route=_SECONDARY_ROUTE_TITLE_SUBTITLE, + route_title=candidate.title or "", + route_subtitle=subtitle, + status="skipped", + cache_key_version=_SECONDARY_CACHE_KEY_VERSION, + skipped_reason="empty_subtitle", + ) + + promoted_subtitle, control_fields_sanitized = self._sanitize_promoted_subtitle(subtitle) + route_title = " ".join(part for part in [candidate.title or "", promoted_subtitle] if part).strip() + if ( + not promoted_subtitle + or self._normalize_route_text(route_title) == self._normalize_route_text(candidate.title or "") + ): + return SecondaryRecognitionRouteResult( + route=_SECONDARY_ROUTE_TITLE_SUBTITLE, + route_title=route_title or candidate.title or "", + route_subtitle=subtitle, + status="skipped", + cache_key_version=_SECONDARY_CACHE_KEY_VERSION, + skipped_reason="duplicate_route", + control_fields_sanitized=control_fields_sanitized, + ) + try: + meta = MetaInfo(title=route_title, subtitle=subtitle, custom_words=target.custom_words) + except Exception as err: + return SecondaryRecognitionRouteResult( + route=_SECONDARY_ROUTE_TITLE_SUBTITLE, + route_title=route_title, + route_subtitle=subtitle, + status="failed", + cache_key_version=_SECONDARY_CACHE_KEY_VERSION, + failure=redact_sensitive_text(err), + control_fields_sanitized=control_fields_sanitized, + ) + + ignored_fields = ( + self._restore_promoted_control_fields(title_meta, meta) + if control_fields_sanitized + else [] + ) + return self._run_secondary_route( + target, + candidate, + _SECONDARY_ROUTE_TITLE_SUBTITLE, + route_title, + subtitle, + meta, + control_fields_sanitized=control_fields_sanitized, + control_fields_ignored=ignored_fields, + ) + + def _run_secondary_route(self, target: RecognitionTarget, candidate: CandidateResource, route: str, route_title: str, + route_subtitle: str, meta, *, control_fields_sanitized: bool = False, + control_fields_ignored: list[str] | None = None) -> SecondaryRecognitionRouteResult: + result = SecondaryRecognitionRouteResult( + route=route, + route_title=route_title, + route_subtitle=route_subtitle, + cache_key_version=_SECONDARY_CACHE_KEY_VERSION, + applied_words_count=len(getattr(meta, "apply_words", []) or []), + control_fields_sanitized=control_fields_sanitized, + control_fields_ignored=list(control_fields_ignored or []), + meta=meta, + ) + cache_key = self._secondary_cache_key(target, candidate, route, route_title, route_subtitle) + cached = self._secondary_cache.get(cache_key) + if cached is not None: + result.cache_hit = True + result.status = cached.get("status") or "not_run" + result.tmdb_id = cached.get("tmdb_id") + result.douban_id = cached.get("douban_id") + result.failure = cached.get("failure") or "" + return result + + try: + media_info = self.runtime.secondary_recognizer(meta) + except Exception as err: + result.status = "failed" + result.failure = redact_sensitive_text(err) + self._remember_secondary(cache_key, result) + return result + if not media_info: + result.status = "empty" + self._remember_secondary(cache_key, result) + return result + result.tmdb_id = getattr(media_info, "tmdb_id", None) + result.douban_id = getattr(media_info, "douban_id", None) + result.status = "recognized" + self._remember_secondary(cache_key, result) + return result + + def _apply_secondary_routes(self, target: RecognitionTarget, candidate: CandidateResource): + recognized = [route for route in candidate.secondary_routes if route.status == "recognized"] + candidate.secondary_result_conflict = self._secondary_result_conflict(recognized) + target_matches = [route for route in recognized if self._secondary_route_matches_target(target, route)] + selected = target_matches[0] if target_matches else (recognized[0] if recognized else None) + if selected: + candidate.secondary_tmdb_id = selected.tmdb_id + candidate.secondary_douban_id = selected.douban_id + candidate.secondary_status = "recognized" + candidate.secondary_failure = "" + candidate.secondary_selected_route = selected.route + candidate.secondary_result_target_match = bool(target_matches) + return + + failures = [route for route in candidate.secondary_routes if route.status == "failed"] + if failures: + candidate.secondary_status = "failed" + candidate.secondary_failure = "; ".join(filter(None, [route.failure for route in failures])) + return + if any(route.status == "empty" for route in candidate.secondary_routes): + candidate.secondary_status = "empty" + return + candidate.secondary_status = "not_run" + + @staticmethod + def _secondary_route_matches_target(target: RecognitionTarget, route: SecondaryRecognitionRouteResult) -> bool: + if target.tmdb_id and route.tmdb_id and int(target.tmdb_id) == int(route.tmdb_id): + return True + return bool(target.douban_id and route.douban_id and str(target.douban_id) == str(route.douban_id)) + + @staticmethod + def _secondary_result_conflict(routes: list[SecondaryRecognitionRouteResult]) -> bool: + tmdb_ids = {str(route.tmdb_id) for route in routes if route.tmdb_id} + if tmdb_ids: + return len(tmdb_ids) > 1 + douban_ids = {str(route.douban_id) for route in routes if route.douban_id} + return len(douban_ids) > 1 + + @staticmethod + def _sanitize_promoted_subtitle(subtitle: str) -> tuple[str, bool]: + sanitized = subtitle + control_fields_sanitized = False + for pattern in (_BRACED_CONTROL_TAG_RE, _SQUARE_TMDB_TAG_RE, _BRACE_TMDB_TAG_RE): + sanitized_next = pattern.sub("", sanitized) + if sanitized_next != sanitized: + control_fields_sanitized = True + sanitized = sanitized_next + return re.sub(r"\s+", " ", sanitized).strip(), control_fields_sanitized + + @staticmethod + def _restore_promoted_control_fields(title_meta, promoted_meta) -> list[str]: + ignored = [] + for field_name in _SECONDARY_CONTROL_FIELDS: + baseline = getattr(title_meta, field_name, None) + current = getattr(promoted_meta, field_name, None) + if current == baseline: + continue + setattr(promoted_meta, field_name, baseline) + ignored.append(field_name) + return ignored + + @staticmethod + def _normalize_route_text(value: str) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + @staticmethod + def _meta_route_title(meta, fallback: str) -> str: + return str(getattr(meta, "title", None) or getattr(meta, "org_string", None) or fallback or "") + + @staticmethod + def _meta_route_subtitle(meta, fallback: str) -> str: + return str(getattr(meta, "subtitle", None) or fallback or "") + + def _should_run_secondary(self) -> bool: + mode = "balanced" if self.settings.mode == "audit" else self.settings.mode + recheck = self.settings.tmdb_recheck_mode + if mode == "off": + return False + if recheck == "off": + return False + if recheck == "all": + return True + if recheck == "strict": + return mode == "strict" + if recheck == "balanced_strict": + return mode in {"balanced", "strict"} + return False + + def _secondary_cache_key(self, target: RecognitionTarget, candidate: CandidateResource, + route: str, route_title: str, route_subtitle: str) -> str: + target_identity = str(target.subscribe_id or "") or "|".join([ + f"name={target.name or ''}", + f"tmdb_id={target.tmdb_id or ''}", + f"douban_id={target.douban_id or ''}", + f"media_type={target.media_type or ''}", + f"season={target.season or ''}", + ]) + media_type = candidate.media_type or target.media_type or "" + year = candidate.year if candidate.year is not None else target.year + season = candidate.season if candidate.season is not None else target.season + episode_group = candidate.episode_group or target.episode_group or "" + raw = "\n".join([ + _SECONDARY_CACHE_KEY_VERSION, + target_identity, + route, + self._hash_text(route_title), + self._hash_text(route_subtitle), + self._hash_text("\n".join(target.custom_words or [])), + str(media_type), + str(year or ""), + str(season or ""), + str(episode_group), + ]) + return sha1(raw.encode("utf-8")).hexdigest() + + @staticmethod + def _hash_text(value: str) -> str: + return sha1(RecognitionGuard._normalize_route_text(value).encode("utf-8")).hexdigest() + + def _remember_secondary(self, key: str, value: SecondaryRecognitionRouteResult): + maxsize = max(1, int(self.settings.cache_maxsize or 1)) + if key in self._secondary_cache: + del self._secondary_cache[key] + self._secondary_cache[key] = { + "tmdb_id": value.tmdb_id, + "douban_id": value.douban_id, + "status": value.status, + "failure": value.failure, + } + while len(self._secondary_cache) > maxsize: + oldest = next(iter(self._secondary_cache)) + del self._secondary_cache[oldest] + + def _log_audit(self): + """写出完整识别增强审计摘要;通知限频不影响该日志。""" + if self.runtime.logger_fn and self.last_audit_summary: + self.runtime.logger_fn(f"识别增强审计:{self.last_audit_summary}") + + def _audit_summary(self, batch: BatchDecision) -> str: + parts = [ + f"mode={self.settings.mode}", + f"strategy_version={self.settings.strategy_version}", + f"keyword_version={self.settings.keyword_version}", + f"tmdb_recheck_mode={self.settings.tmdb_recheck_mode}", + f"notify_mode={self.settings.notify_mode}", + f"cache_maxsize={self.settings.cache_maxsize}", + f"strategy={self.strategy.summary}", + f"selection_original_count={batch.selection_original_count}", + f"recognition_input_count={batch.recognition_input_count}", + f"recognition_evaluated_count={batch.recognition_evaluated_count}", + f"recognition_output_count={batch.recognition_output_count}", + f"final_count={batch.final_count}", + f"fallback_applied={str(batch.fallback_applied).lower()}", + ] + if batch.fallback_reason: + parts.append(f"fallback_reason={batch.fallback_reason}") + for action, count in sorted(batch.final_action_counts.items()): + parts.append(f"{action}={count}") + if self.last_target: + parts.append(f"range_source={self.last_target.range_source}") + parts.append(f"range_confidence={self.last_target.range_confidence}") + for stage in batch.stage_counts: + parts.append( + "stage={stage} input={input} output={output}".format( + stage=stage.get("stage", "-"), + input=stage.get("input", 0), + output=stage.get("output", 0), + ) + ) + for index, decision in enumerate(batch.decisions): + candidate = decision.candidate or CandidateResource(order=index) + summary = sanitize_candidate_summary(self._torrent_like(candidate)) + parts.append( + "candidate={index} fingerprint={fingerprint} summary={summary} " + "original_action={original_action} final_action={final_action} " + "would_action={would_action} code={code} reason={reason}".format( + index=index, + fingerprint=candidate.fingerprint or "-", + summary=self._audit_value(summary), + original_action=decision.action, + final_action=decision.final_action, + would_action=decision.would_action, + code=decision.code, + reason=self._audit_value(decision.reason), + ) + ) + if candidate.secondary_routes: + parts.append( + "candidate={index} secondary_routes={routes} selected_route={selected_route} " + "target_match={target_match} result_conflict={result_conflict}".format( + index=index, + routes=",".join(route.route for route in candidate.secondary_routes), + selected_route=candidate.secondary_selected_route or "-", + target_match=str(candidate.secondary_result_target_match).lower(), + result_conflict=str(candidate.secondary_result_conflict).lower(), + ) + ) + for route in candidate.secondary_routes: + parts.append( + "candidate={index} route={route} route_status={status} " + "route_title={route_title} route_subtitle={route_subtitle} " + "route_tmdb_id={tmdb_id} route_douban_id={douban_id} " + "route_cache_hit={cache_hit} route_cache_key_version={cache_key_version} " + "applied_words_count={applied_words_count} skipped_reason={skipped_reason} " + "failure={failure} control_fields_sanitized={control_fields_sanitized} " + "control_fields_ignored={control_fields_ignored}".format( + index=index, + route=route.route, + status=route.status, + route_title=self._audit_value(route.route_title), + route_subtitle=self._audit_value(route.route_subtitle), + tmdb_id=route.tmdb_id or "", + douban_id=route.douban_id or "", + cache_hit=str(route.cache_hit).lower(), + cache_key_version=route.cache_key_version, + applied_words_count=route.applied_words_count, + skipped_reason=self._audit_value(route.skipped_reason), + failure=self._audit_value(route.failure), + control_fields_sanitized=str(route.control_fields_sanitized).lower(), + control_fields_ignored=",".join(route.control_fields_ignored), + ) + ) + return " | ".join(parts) + + def _notification_summary(self, batch: BatchDecision) -> str: + return ( + f"识别增强:输入 {batch.recognition_input_count}," + f"输出 {batch.recognition_output_count},最终 {batch.final_count}" + ) + + @staticmethod + def _risk_for_action(action: str) -> str: + if action == ACTION_BLOCK: + return _POLICY_BLOCK + if action == ACTION_SOFT_BLOCK: + return _SOFT_BLOCK + if action == ACTION_OBSERVE: + return _OBSERVE + if action == ACTION_FAIL_OPEN: + return "fail_open" + return _ALLOW + + @staticmethod + def _candidate_text(candidate: CandidateResource) -> str: + return " ".join([candidate.title or "", candidate.description or "", candidate.category or ""]) + + @staticmethod + def _safe_reason(reason: str) -> str: + return redact_sensitive_text(reason) + + @staticmethod + def _audit_value(value) -> str: + text = redact_sensitive_text(value) + return text.replace("\r", "\\n").replace("\n", "\\n").replace("|", "/").replace("=", ":") + + @staticmethod + def _known_season_conflict(target: RecognitionTarget, candidate: CandidateResource) -> bool: + if target.season is None or candidate.season is None: + return False + if target.season == 0 and candidate.season_kind == "special": + return False + return int(target.season) != int(candidate.season) + + def _live_action_match(self, text: str) -> str | None: + return match_first(self.keyword_groups.live_action, text) or match_first(["真人版", "实拍版", "真人剧"], text) + + @staticmethod + def _has_explicit_episode_signal(text: str) -> bool: + return bool(re.search(r"\bS\d{1,3}E\d{1,4}\b|第\s*\d+\s*集", text, re.I)) + + @staticmethod + def _has_hard_live_action_signal(text: str) -> bool: + return any(token in text for token in ("真人剧", "真人版", "实拍版")) + + @staticmethod + def _target_from_dict(data) -> RecognitionTarget: + if isinstance(data, RecognitionTarget): + return data + values = dict(data or {}) + allowed = {field.name for field in fields(RecognitionTarget)} + return RecognitionTarget(**{key: value for key, value in values.items() if key in allowed}) + + @staticmethod + def _candidate_from_dict(data) -> CandidateResource: + if isinstance(data, CandidateResource): + return data + values = dict(data or {}) + if "explicit_tmdb_id" not in values and "tmdb_id" in values: + values["explicit_tmdb_id"] = values["tmdb_id"] + if "explicit_douban_id" not in values and "douban_id" in values: + values["explicit_douban_id"] = values["douban_id"] + allowed = {field.name for field in fields(CandidateResource)} + candidate = CandidateResource(**{key: value for key, value in values.items() if key in allowed}) + return candidate + + @staticmethod + def _torrent_like(candidate: CandidateResource): + return SimpleNamespace( + title=candidate.title, + description=candidate.description, + site_name=candidate.site, + enclosure=candidate.fingerprint, + page_url="", + ) diff --git a/plugins.v3/subscribeassistantenhanced/recognition/keywords.py b/plugins.v3/subscribeassistantenhanced/recognition/keywords.py new file mode 100644 index 00000000..f1e6bc73 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/keywords.py @@ -0,0 +1,91 @@ +"""识别增强关键字配置加载与匹配。""" +import re +from dataclasses import dataclass, field +from typing import Any + +from ruamel.yaml import YAML + +DEFAULT_KEYWORD_CONFIG = """live_action: + - '电视剧版' + - '真人版' + - '实拍版' + - '真人剧' +animation: + - '动画' + - '动漫' + - '国漫' + - '番剧' +movie: + - '电影版' + - '剧场版' + - '劇場版' + - '\\bMovie\\b' +tv: + - '\\bS\\d{1,3}(?:E\\d{1,4})?\\b' + - '第\\s*\\d+\\s*[集季]' + - '全\\s*\\d+\\s*集' +allow: [] +block: [] +hard_block: [] +""" + + +@dataclass +class KeywordGroups: + """识别增强关键字分组;普通 block 不是 hard veto,hard_block 才代表用户强规则。""" + live_action: list[str] = field(default_factory=list) + animation: list[str] = field(default_factory=list) + movie: list[str] = field(default_factory=list) + tv: list[str] = field(default_factory=list) + allow: list[str] = field(default_factory=list) + block: list[str] = field(default_factory=list) + hard_block: list[str] = field(default_factory=list) + + +GROUP_KEYS = ("live_action", "animation", "movie", "tv", "allow", "block", "hard_block") + + +def _normalize_patterns(value: Any) -> list[str]: + """把 YAML 标量或列表统一成去空字符串列表;其他类型按空组 fail-open。""" + if value is None: + return [] + if isinstance(value, str): + text = value.strip() + return [text] if text else [] + if isinstance(value, (list, tuple)): + return [str(item).strip() for item in value if str(item).strip()] + return [] + + +def _load_yaml_mapping(config_text: str) -> dict: + """解析 YAML mapping;解析失败或非 mapping 时返回空 dict。""" + try: + data = YAML(typ="safe").load(config_text) or {} + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def load_keyword_groups(config_text: str) -> KeywordGroups: + """加载关键字分组。 + + 空配置和坏 YAML 都回退到内置安全词库;单个分组类型错误只禁用该分组。 + """ + source = config_text if str(config_text or "").strip() else DEFAULT_KEYWORD_CONFIG + data = _load_yaml_mapping(source) + if not data or not any(key in data for key in GROUP_KEYS): + data = _load_yaml_mapping(DEFAULT_KEYWORD_CONFIG) + groups = {key: _normalize_patterns(data.get(key)) for key in GROUP_KEYS} + return KeywordGroups(**groups) + + +def match_first(patterns: list[str], text: str) -> str | None: + """返回第一个匹配的关键字;非法正则只跳过该条,避免整组失效。""" + haystack = text or "" + for pattern in patterns: + try: + if re.search(pattern, haystack, re.IGNORECASE): + return pattern + except re.error: + continue + return None diff --git a/plugins.v3/subscribeassistantenhanced/recognition/scope.py b/plugins.v3/subscribeassistantenhanced/recognition/scope.py new file mode 100644 index 00000000..f0048537 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/scope.py @@ -0,0 +1,264 @@ +"""识别增强目标范围与候选摘要构建。""" +import re + +from app.schemas.types import MediaSource, MediaType + +from .audit import candidate_fingerprint +from .types import CandidateResource, RecognitionTarget +from ..shared.subscribe import ( + is_full_best_version_subscribe, + is_tv_episode_best_version_subscribe, + resolve_subscribe_media_type, + subscribe_media_identity, + subscribe_tmdb_id, +) + + +def _episode_number(episode) -> int | None: + """读取 TMDB 分集号,兼容对象与简单测试替身。""" + value = getattr(episode, "episode_number", None) + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _subscribe_range(subscribe) -> list[int]: + """按订阅 start_episode..total_episode 构建目标窗口。""" + try: + start = int(subscribe.start_episode or 1) + total = int(subscribe.total_episode or 0) + except (TypeError, ValueError): + return [] + return list(range(start, total + 1)) if total >= start else [] + + +def _dedupe_text(values) -> list[str]: + """保持输入顺序去重,避免别名重复影响审计输出。""" + seen = set() + result = [] + for value in values: + text = str(value or "").strip() + if text and text not in seen: + seen.add(text) + result.append(text) + return result + + +def _is_weak_alias(text: str) -> bool: + """短英文别名容易误识别为同名作品,默认只作为弱同一性证据。""" + ascii_letters = sum(1 for ch in text if ch.isascii() and ch.isalpha()) + cjk_letters = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff") + return ascii_letters > 0 and cjk_letters == 0 and len(text.replace(" ", "")) <= 12 + + +def _target_aliases(subscribe, mediainfo) -> tuple[list[str], dict[str, str]]: + """收集订阅目标别名并标注强度,供后续证据抵消规则使用。""" + strengths = {} + words = [] + for text in [subscribe.name or ""]: + if text: + words.append(text) + strengths[text] = "strong" + for text in str(subscribe.custom_words or "").splitlines(): + if text: + words.append(text) + strengths[text] = "strong" + if mediainfo: + for text in [ + getattr(mediainfo, "title", "") or "", + getattr(mediainfo, "original_title", "") or "", + ]: + if text: + words.append(text) + strengths.setdefault(text, "medium") + for text in [getattr(mediainfo, "en_title", "") or "", *(getattr(mediainfo, "names", None) or [])]: + if text: + words.append(text) + strength = "weak" if _is_weak_alias(text) else "medium" + if strengths.get(text) != "strong": + strengths[text] = strength + aliases = _dedupe_text(words) + return aliases, {alias: strengths.get(alias, "weak") for alias in aliases} + + +def _target_shape(mediainfo) -> str: + """判断订阅目标形态;首版只在有明确动画信号时返回 animation。""" + if not mediainfo: + return "unknown" + category = str(getattr(mediainfo, "category", "") or "") + genres = getattr(mediainfo, "genres", None) or [] + genre_names = [str(item.get("name", "") if isinstance(item, dict) else item) for item in genres] + text = " ".join([category, *genre_names]) + if any(token in text for token in ("动画", "动漫", "番剧", "国漫", "Animation")): + return "animation" + return "unknown" + + +def _media_type_value(value) -> str: + """统一媒体类型口径:MediaType 枚举与字符串都输出中文业务值。""" + if isinstance(value, MediaType): + return value.value + enum_value = getattr(value, "value", value) + return str(enum_value or "") + + +def build_target(subscribe, mediainfo=None, tmdb_episodes_fn=None) -> RecognitionTarget: + """构建当前订阅目标与本次应覆盖的集数范围。""" + media_type = resolve_subscribe_media_type(subscribe) + source = "movie" if media_type == MediaType.MOVIE else "subscribe_range" + episodes = [] if media_type == MediaType.MOVIE else _subscribe_range(subscribe) + confidence = "high" if media_type == MediaType.MOVIE or episodes else "unknown" + media_source, media_id = subscribe_media_identity(subscribe) + tmdb_id = subscribe_tmdb_id(subscribe) + if media_type == MediaType.TV: + if is_full_best_version_subscribe(subscribe): + episodes = [] + source = "scope_unavailable" + confidence = "unknown" + if tmdb_episodes_fn and tmdb_id is not None: + try: + scope_eps = tmdb_episodes_fn( + tmdbid=tmdb_id, + season=subscribe.season, + episode_group=subscribe.episode_group, + ) + except Exception: + scope_eps = [] + parsed = [num for num in (_episode_number(ep) for ep in scope_eps or []) if num is not None] + if parsed: + episodes = parsed + source = "episode_group" if subscribe.episode_group else "full_best_version" + confidence = "high" + elif is_tv_episode_best_version_subscribe(subscribe): + source = "episode_best_version" + aliases, alias_strengths = _target_aliases(subscribe, mediainfo) + return RecognitionTarget( + subscribe_id=subscribe.id, + name=subscribe.name or "", + year=str(subscribe.year or ""), + media_type=_media_type_value(media_type), + season=subscribe.season, + episode_group=subscribe.episode_group, + tmdb_id=tmdb_id, + douban_id=media_id if media_source == MediaSource.Douban.value else None, + custom_words=[line.strip() for line in str(subscribe.custom_words or "").splitlines() + if line.strip()], + aliases=aliases, + alias_strengths=alias_strengths, + shape=_target_shape(mediainfo), + target_episodes=episodes, + range_source=source, + range_confidence=confidence, + ) + + +def _episodes_from_text(text: str) -> list[int]: + """从标题/副标题解析候选集范围,供 ResourceSelection 阶段使用。""" + range_match = re.search(r"E(\d{1,4})\s*-\s*E?(\d{1,4})", text, re.I) + if range_match: + start, end = int(range_match.group(1)), int(range_match.group(2)) + return list(range(start, end + 1)) if end >= start else [] + single_match = re.search(r"S\d{1,3}E(\d{1,4})", text, re.I) + if single_match: + return [int(single_match.group(1))] + range_match = re.search(r"第\s*(\d+)\s*-\s*(\d+)\s*集", text) + if range_match: + start, end = int(range_match.group(1)), int(range_match.group(2)) + return list(range(start, end + 1)) if end >= start else [] + full_match = re.search(r"全\s*(\d+)\s*集", text) + if full_match: + total = int(full_match.group(1)) + return list(range(1, total + 1)) if total > 0 else [] + single_match = re.search(r"第\s*(\d+)\s*集", text) + if single_match: + return [int(single_match.group(1))] + return [] + + +def _episodes_from_meta(meta) -> list[int]: + """从主程序 MetaInfo 读取候选集范围。""" + if not meta: + return [] + episode_list = list(getattr(meta, "episode_list", None) or []) + if episode_list: + return [int(ep) for ep in episode_list if str(ep).isdigit()] + begin = getattr(meta, "begin_episode", None) + end = getattr(meta, "end_episode", None) + try: + begin = int(begin) if begin is not None else None + end = int(end) if end is not None else begin + except (TypeError, ValueError): + return [] + if begin is not None and end is not None and end >= begin: + return list(range(begin, end + 1)) + return [] + + +def _season_kind(season) -> str: + """S00 是合法特别季,不能与未知季号混淆。""" + return "special" if season == 0 else "main" + + +def _explicit_special_season(text: str) -> bool: + """识别标题中的特别篇 / SP 标记;仅在缺显式季号时用于避免退化成主季。""" + return bool(re.search(r"\bS00E\d{1,4}\b|\bSP\d{1,4}\b|特别篇|番外", text, re.I)) + + +def _list_attr(obj, *names) -> list[str]: + """读取候选识别结果里的语种/地区列表,缺失时返回空列表。""" + for name in names: + values = getattr(obj, name, None) + if values: + return [str(value) for value in values if value] + return [] + + +def candidate_from_context(context, order: int = 0) -> CandidateResource: + """从主程序 ResourceSelection context 构建候选摘要。""" + torrent = getattr(context, "torrent_info", None) + meta = getattr(context, "meta_info", None) + title = getattr(torrent, "title", "") or "" + desc = getattr(torrent, "description", "") or "" + text = f"{title} {desc}" + episodes = _episodes_from_meta(meta) + if not episodes: + episodes = list(getattr(torrent, "episode_list", None) or []) + if not episodes: + episodes = _episodes_from_text(text) + range_source = "unknown" + if _episodes_from_meta(meta): + range_source = "meta_info" + elif getattr(torrent, "episode_list", None): + range_source = "torrent_info" + elif episodes: + range_source = "title" + season = getattr(meta, "begin_season", None) + if season is None and _explicit_special_season(text): + season = 0 + media_info = getattr(context, "media_info", None) + return CandidateResource( + fingerprint=candidate_fingerprint(torrent), + title=title, + description=desc, + site=str(getattr(torrent, "site_name", None) or getattr(torrent, "site", "") or ""), + category=str(getattr(torrent, "category", "") or ""), + order=order, + year=getattr(meta, "year", None), + media_type=_media_type_value(getattr(meta, "type", "")), + season=season, + episode_group=getattr(meta, "episode_group", None), + season_kind=_season_kind(season), + episodes=[int(ep) for ep in episodes if str(ep).isdigit()], + total_episode=getattr(meta, "total_episode", None), + range_source=range_source, + languages=_list_attr(media_info, "languages", "spoken_languages"), + origin_countries=_list_attr(media_info, "origin_country", "production_countries"), + explicit_tmdb_id=getattr(meta, "tmdbid", None), + explicit_douban_id=getattr(meta, "doubanid", None), + recognized_tmdb_id=getattr(media_info, "tmdb_id", None), + recognized_douban_id=getattr(media_info, "douban_id", None), + candidate_recognized=bool(getattr(context, "candidate_recognized", False)), + match_source=str(getattr(context, "match_source", "unknown") or "unknown"), + media_info_is_target=bool(getattr(context, "media_info_is_target", False)), + ) diff --git a/plugins.v3/subscribeassistantenhanced/recognition/strategy.py b/plugins.v3/subscribeassistantenhanced/recognition/strategy.py new file mode 100644 index 00000000..354b1f8b --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/strategy.py @@ -0,0 +1,243 @@ +"""识别增强 YAML 策略解析与模式模板合并。""" +import hashlib +import re +from dataclasses import dataclass, field +from typing import Any + +from ruamel.yaml import YAML + +from .keywords import DEFAULT_KEYWORD_CONFIG, GROUP_KEYS, KeywordGroups, _load_yaml_mapping, _normalize_patterns +from .types import ACTION_BLOCK, ACTION_OBSERVE, ACTION_SOFT_BLOCK + +ACTION_INHERIT = "inherit" +EMPTY_POOL_RECOVER_SOFT_BLOCK = "recover_soft_block" +EMPTY_POOL_NEVER_RECOVER = "never_recover" + +ACTION_CODES = { + "missing_year", + "target_range_oversized", + "user_block", + "secondary_identity_conflict", +} +ACTION_VALUES = {ACTION_INHERIT, ACTION_OBSERVE, ACTION_SOFT_BLOCK, ACTION_BLOCK} +EMPTY_POOL_POLICIES = {EMPTY_POOL_RECOVER_SOFT_BLOCK, EMPTY_POOL_NEVER_RECOVER} + +_MODE_ACTIONS = { + "audit": { + "missing_year": ACTION_OBSERVE, + "target_range_oversized": ACTION_SOFT_BLOCK, + "user_block": ACTION_SOFT_BLOCK, + "secondary_identity_conflict": ACTION_BLOCK, + }, + "loose": { + "missing_year": ACTION_OBSERVE, + "target_range_oversized": ACTION_OBSERVE, + "user_block": ACTION_OBSERVE, + "secondary_identity_conflict": ACTION_OBSERVE, + }, + "balanced": { + "missing_year": ACTION_OBSERVE, + "target_range_oversized": ACTION_SOFT_BLOCK, + "user_block": ACTION_SOFT_BLOCK, + "secondary_identity_conflict": ACTION_BLOCK, + }, + "strict": { + "missing_year": ACTION_BLOCK, + "target_range_oversized": ACTION_SOFT_BLOCK, + "user_block": ACTION_BLOCK, + "secondary_identity_conflict": ACTION_BLOCK, + }, +} + +_MODE_EMPTY_POOL = { + "audit": EMPTY_POOL_RECOVER_SOFT_BLOCK, + "loose": EMPTY_POOL_RECOVER_SOFT_BLOCK, + "balanced": EMPTY_POOL_RECOVER_SOFT_BLOCK, + "strict": EMPTY_POOL_NEVER_RECOVER, +} + + +@dataclass +class RecognitionStrategy: + """识别增强生效策略快照,供候选判定和审计摘要复用。""" + mode: str + actions: dict[str, str] + explicit_actions: dict[str, str] = field(default_factory=dict) + empty_pool_policy: str = EMPTY_POOL_RECOVER_SOFT_BLOCK + non_recoverable_codes: set[str] = field(default_factory=set) + keyword_groups: KeywordGroups = field(default_factory=KeywordGroups) + warnings: set[str] = field(default_factory=set) + config_hash: str = "" + overridden_keyword_groups: set[str] = field(default_factory=set) + + def action_for(self, code: str) -> str: + """返回原因码的最终动作模板;未知原因码按 observe fail-open。""" + return self.actions.get(code, ACTION_OBSERVE) + + def is_recoverable(self, code: str) -> bool: + """集合级保护是否可恢复该 soft_block 原因码。""" + return ( + self.empty_pool_policy == EMPTY_POOL_RECOVER_SOFT_BLOCK + and code not in self.non_recoverable_codes + ) + + @property + def summary(self) -> str: + """白名单化策略摘要,不输出 YAML 原文或用户关键词。""" + parts = [f"hash={self.config_hash or '-'}"] + if self.explicit_actions: + parts.append("actions=" + ",".join(sorted(self.explicit_actions))) + parts.append(f"policy={self.empty_pool_policy}") + if self.non_recoverable_codes: + parts.append("non_recoverable=" + ",".join(sorted(self.non_recoverable_codes))) + if self.overridden_keyword_groups: + parts.append("keywords=" + ",".join(sorted(self.overridden_keyword_groups))) + if self.warnings: + parts.append("warnings=" + ",".join(sorted(self.warnings))) + return " ".join(parts) + + +def parse_strategy(mode: str, config_text: str = "") -> RecognitionStrategy: + """解析用户 YAML 并与当前模式模板合并。""" + normalized_mode = mode if mode in _MODE_ACTIONS else "balanced" + warnings: set[str] = set() + text = str(config_text or "") + config_hash = hashlib.sha1(text.encode("utf-8")).hexdigest()[:12] if text.strip() else "" + actions = dict(_MODE_ACTIONS[normalized_mode]) + empty_pool_policy = _MODE_EMPTY_POOL[normalized_mode] + non_recoverable_codes: set[str] = set() + keyword_groups = _default_keyword_groups() + overridden_keyword_groups: set[str] = set() + explicit_actions: dict[str, str] = {} + + parsed, root_warning = _parse_yaml_root(text) + if root_warning: + warnings.add(root_warning) + return RecognitionStrategy( + mode=normalized_mode, + actions=actions, + explicit_actions=explicit_actions, + empty_pool_policy=empty_pool_policy, + non_recoverable_codes=non_recoverable_codes, + keyword_groups=keyword_groups, + warnings=warnings, + config_hash=config_hash, + overridden_keyword_groups=overridden_keyword_groups, + ) + + for key in parsed: + if key not in {"actions", "empty_pool", "keywords"}: + warnings.add("unknown_strategy_key") + + raw_actions = parsed.get("actions") + if raw_actions is not None: + if not isinstance(raw_actions, dict): + warnings.add("invalid_actions_type") + else: + for code, action in raw_actions.items(): + code = str(code or "").strip() + action = str(action or "").strip() + if code not in ACTION_CODES: + warnings.add("unknown_action_code") + continue + if action not in ACTION_VALUES: + warnings.add("invalid_action_value") + continue + if action == ACTION_INHERIT: + continue + actions[code] = action + explicit_actions[code] = action + + raw_empty_pool = parsed.get("empty_pool") + if raw_empty_pool is not None: + if not isinstance(raw_empty_pool, dict): + warnings.add("invalid_empty_pool_type") + else: + policy = raw_empty_pool.get("policy") + if policy is not None: + policy = str(policy or "").strip() + if policy in EMPTY_POOL_POLICIES: + empty_pool_policy = policy + else: + warnings.add("invalid_empty_pool_policy") + raw_non_recoverable = raw_empty_pool.get("non_recoverable_codes") + if raw_non_recoverable is not None: + if not isinstance(raw_non_recoverable, list): + warnings.add("invalid_non_recoverable_codes_type") + else: + for code in raw_non_recoverable: + code = str(code or "").strip() + if code in ACTION_CODES: + non_recoverable_codes.add(code) + else: + warnings.add("unknown_non_recoverable_code") + + raw_keywords = parsed.get("keywords") + if raw_keywords is not None: + if not isinstance(raw_keywords, dict): + warnings.add("invalid_keywords_type") + else: + keyword_groups = _merge_keyword_groups(raw_keywords, keyword_groups, overridden_keyword_groups, warnings) + + return RecognitionStrategy( + mode=normalized_mode, + actions=actions, + explicit_actions=explicit_actions, + empty_pool_policy=empty_pool_policy, + non_recoverable_codes=non_recoverable_codes, + keyword_groups=keyword_groups, + warnings=warnings, + config_hash=config_hash, + overridden_keyword_groups=overridden_keyword_groups, + ) + + +def _parse_yaml_root(text: str) -> tuple[dict, str | None]: + """解析 YAML 根节点;空文本、纯注释和 null 根节点均视为无覆盖。""" + if not text.strip(): + return {}, None + try: + data = YAML(typ="safe").load(text) + except Exception: + return {}, "invalid_yaml" + if data is None: + return {}, None + if not isinstance(data, dict): + return {}, "non_mapping_yaml" + if any(key is None or not str(key).strip() for key in data): + return {}, "invalid_yaml" + return data, None + + +def _default_keyword_groups() -> KeywordGroups: + data = _load_yaml_mapping(DEFAULT_KEYWORD_CONFIG) + return KeywordGroups(**{key: _normalize_patterns(data.get(key)) for key in GROUP_KEYS}) + + +def _merge_keyword_groups(raw_keywords: dict, base: KeywordGroups, overridden: set[str], + warnings: set[str]) -> KeywordGroups: + groups = {key: list(getattr(base, key)) for key in GROUP_KEYS} + for key, value in raw_keywords.items(): + key = str(key or "").strip() + if key not in GROUP_KEYS: + warnings.add("invalid_keyword_group_type") + continue + if value is None: + continue + if not isinstance(value, list): + warnings.add("invalid_keyword_group_type") + continue + patterns: list[str] = [] + for item in value: + pattern = str(item or "").strip() + if not pattern: + continue + try: + re.compile(pattern) + except re.error: + warnings.add("invalid_keyword_regex") + continue + patterns.append(pattern) + groups[key] = patterns + overridden.add(key) + return KeywordGroups(**groups) diff --git a/plugins.v3/subscribeassistantenhanced/recognition/types.py b/plugins.v3/subscribeassistantenhanced/recognition/types.py new file mode 100644 index 00000000..200df929 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/recognition/types.py @@ -0,0 +1,165 @@ +"""识别增强领域模型。""" +from dataclasses import dataclass, field +from typing import Callable, Optional + +ACTION_ALLOW = "allow" +ACTION_OBSERVE = "observe" +ACTION_SOFT_BLOCK = "soft_block" +ACTION_BLOCK = "block" +ACTION_SKIP = "skip" +ACTION_FAIL_OPEN = "fail_open" + + +@dataclass +class RecognitionSettings: + """识别增强运行策略快照;用户配置在入口处一次性解析后注入。""" + mode: str = "off" + strategy_version: str = "2026-06-24" + keyword_version: str = "2026-06-24" + notify_mode: str = "off" + notify_interval: int = 3600 + tmdb_recheck_mode: str = "balanced_strict" + cache_maxsize: int = 100000 + custom_config: str = "" + keyword_config: str = "" + + +@dataclass +class RecognitionRuntime: + """识别增强外部依赖;目标事实必须从订阅目标路径注入,不能从候选反推。""" + target_mediainfo_resolver: Optional[Callable] = None + tmdb_episodes_fn: Optional[Callable] = None + secondary_recognizer: Optional[Callable] = None + logger_fn: Optional[Callable] = None + + +@dataclass +class RecognitionTarget: + """当前订阅目标及本次订阅要下载的范围。""" + subscribe_id: Optional[int] = None + name: str = "" + year: str = "" + media_type: str = "" + season: Optional[int] = None + episode_group: Optional[str] = None + tmdb_id: Optional[int] = None + douban_id: Optional[str] = None + custom_words: list[str] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + alias_strengths: dict[str, str] = field(default_factory=dict) + languages: list[str] = field(default_factory=list) + origin_countries: list[str] = field(default_factory=list) + shape: str = "unknown" + target_episodes: list[int] = field(default_factory=list) + range_source: str = "unknown" + range_confidence: str = "unknown" + + +@dataclass +class SecondaryRecognitionRouteResult: + """二次识别单路输入、输出和缓存状态,用于审计 route 级行为。""" + route: str = "" + route_title: str = "" + route_subtitle: str = "" + status: str = "not_run" + tmdb_id: Optional[int] = None + douban_id: Optional[str] = None + cache_hit: bool = False + cache_key_version: str = "" + applied_words_count: int = 0 + skipped_reason: str = "" + failure: str = "" + control_fields_sanitized: bool = False + control_fields_ignored: list[str] = field(default_factory=list) + meta: object = field(default=None, repr=False, compare=False) + + +@dataclass +class CandidateResource: + """单个候选资源的可审计摘要。""" + fingerprint: str = "" + title: str = "" + description: str = "" + site: str = "" + category: str = "" + order: int = 0 + year: Optional[int] = None + media_type: str = "" + season: Optional[int] = None + episode_group: Optional[str] = None + season_kind: str = "main" + episodes: list[int] = field(default_factory=list) + total_episode: Optional[int] = None + range_source: str = "unknown" + languages: list[str] = field(default_factory=list) + origin_countries: list[str] = field(default_factory=list) + explicit_tmdb_id: Optional[int] = None + explicit_douban_id: Optional[str] = None + recognized_tmdb_id: Optional[int] = None + recognized_douban_id: Optional[str] = None + secondary_tmdb_id: Optional[int] = None + secondary_douban_id: Optional[str] = None + secondary_status: str = "not_run" + secondary_failure: str = "" + secondary_routes: list[SecondaryRecognitionRouteResult] = field(default_factory=list) + secondary_selected_route: str = "" + secondary_result_target_match: bool = False + secondary_result_conflict: bool = False + candidate_recognized: bool = False + match_source: str = "unknown" + media_info_is_target: bool = False + + +# `languages` 对应设计里的“语种”;`origin_countries` 对应“地区 / 来源国家”。 +# 不新增并行 `locale` 或 `region` 字段,避免同一证据出现多套口径。 + + +@dataclass +class Evidence: + """单条识别证据。""" + group: str + code: str + level: str + message: str + source: str = "" + can_be_countered_by: list[str] = field(default_factory=list) + + +@dataclass +class Decision: + """单候选最终动作及证据。""" + action: str = ACTION_ALLOW + final_action: str = ACTION_ALLOW + code: str = "allow" + reason: str = "未命中风险证据" + risk: str = "none" + would_action: str = ACTION_ALLOW + candidate: Optional[CandidateResource] = None + evidence: list[Evidence] = field(default_factory=list) + counters: list[Evidence] = field(default_factory=list) + + @property + def removed(self) -> bool: + return self.final_action in {ACTION_BLOCK, ACTION_SOFT_BLOCK} + + +@dataclass +class BatchDecision: + """一轮候选过滤输出。""" + input_count: int = 0 + output_count: int = 0 + selection_original_count: int = 0 + recognition_input_count: int = 0 + recognition_evaluated_count: int = 0 + recognition_output_count: int = 0 + final_count: int = 0 + decisions: list[Decision] = field(default_factory=list) + retained: list = field(default_factory=list) + stage_counts: list[dict] = field(default_factory=list) + fallback_applied: bool = False + action_counts: dict[str, int] = field(default_factory=dict) + original_action_counts: dict[str, int] = field(default_factory=dict) + final_action_counts: dict[str, int] = field(default_factory=dict) + audit_summary: str = "" + notification_summary: Optional[str] = None + fallback_reason: str = "" diff --git a/plugins.v3/subscribeassistantenhanced/shared/__init__.py b/plugins.v3/subscribeassistantenhanced/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins.v3/subscribeassistantenhanced/shared/config.py b/plugins.v3/subscribeassistantenhanced/shared/config.py new file mode 100644 index 00000000..a2a9f93b --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/config.py @@ -0,0 +1,561 @@ +"""插件配置解析器,统一类型转换和默认值。""" + + +# Tracker 默认关键字覆盖常见无效种子响应,避免空配置时监听开关没有实际匹配能力。 +DEFAULT_TRACKER_RESPONSE = """torrent not registered with this tracker +torrent banned""" + +# 自动删种默认跳过 H&R 标签,避免误删需要长期做种的任务。 +DEFAULT_DELETE_EXCLUDE_TAGS = "H&R" + +DEFAULT_VOLATILITY_WINDOW_DAYS = 3 + +DEFAULT_RECOGNITION_GUARD_CUSTOM_CONFIG = """####### 配置说明 BEGIN ####### +# 1. 本配置只控制识别增强的策略覆盖和关键词,不控制通知、二次识别触发或缓存大小。 +# 2. 未配置或保持注释的项目均继承 recognition_guard_mode 当前模板。 +# 3. actions 的值可选:inherit / observe / soft_block / block: +# - inherit:继承当前 recognition_guard_mode 模板,不单独覆盖。 +# - observe:只记录审计和可选通知,不移除候选,下载选择不受影响。 +# - soft_block:先从候选池移除;如果整轮候选被清空,且 empty_pool 策略允许,该候选可降级为 observe 恢复。 +# - block:从候选池移除,集合级保护也不得恢复;用于用户明确不想下载的风险。 +# 4. allow 只能抵消非 hard veto 风险;不能覆盖显式 ID 错配、明确类型/形态互串、目标范围完全不覆盖等 hard veto。 +# 5. block 是普通黑名单风险,动作由 mode 或 actions.user_block 决定;hard_block 才是一律强拦截。 +# 6. 正则使用 Python re 语法;非法正则会跳过对应条目并记录配置告警,不影响其他规则。 +# 7. keywords 下的内置证据词分组如果取消注释配置,表示替换该分组;未配置的分组继续使用内置默认。 +####### 配置说明 END ####### + +actions: + # 候选缺少年份。多站点用户可改为 block,少站点用户建议 inherit 或 observe。 + # missing_year: block + + # 候选全集范围明显大于目标窗口,例如目标缺 E08-E19,候选是全 60 集。 + # target_range_oversized: block + + # 命中 keywords.block 时的动作。 + # user_block: soft_block + + # 二次识别结果与订阅目标不一致。 + # secondary_identity_conflict: block + +empty_pool: + # 整轮候选被识别增强清空时的恢复策略:recover_soft_block / never_recover。 + # policy: recover_soft_block + + # 即使动作是 soft_block,也不允许因整轮候选清空而恢复的原因码。 + # non_recoverable_codes: + # - target_range_oversized + # - missing_year + +keywords: + # 白名单:只抵消非 hard veto 风险。 + # allow: + # - 官方合集 + + # 普通黑名单:动作由 mode 或 actions.user_block 决定。 + # block: + # - 低可信风险词 + + # 强黑名单:所有启用模式下 hard veto;audit 只记录 would block。 + # hard_block: + # - 强制错误词 + + # 以下是内置证据词分组;如需覆盖某一组,取消注释并完整写出该组。 + # live_action: + # - 真人版 + # - 电视剧版 + # - 实拍版 + # - 真人剧 + # animation: + # - 动画 + # - 动漫 + # - 国漫 + # - 番剧 + # movie: + # - 电影版 + # - 剧场版 + # - 劇場版 + # - '\\bMovie\\b' + # tv: + # - '\\bS\\d{1,3}(?:E\\d{1,4})?\\b' + # - '第\\s*\\d+\\s*[集季]' + # - '全\\s*\\d+\\s*集' +""" + + +class PluginConfig: + """所有配置项属性化访问,类型安全,缺失 key 走默认值。""" + + def __init__(self, raw: dict): + self._raw = raw or {} + self._recognition_guard_config_warnings: set[str] = set() + + def get_bool(self, key: str, default: bool = False) -> bool: + """布尔值解析:支持 bool / 字符串 true/false/on/off/yes/no/1/0。""" + val = self._raw.get(key) + if val is None: + return default + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in ("true", "on", "yes", "1", "guard") + return bool(val) + + def get_int(self, key: str, default: int = 0) -> int: + val = self._raw.get(key) + if val is None: + return default + try: + return int(float(val)) + except (ValueError, TypeError): + return default + + def get_float(self, key: str, default: float = 0.0) -> float: + val = self._raw.get(key) + if val is None: + return default + try: + return float(val) + except (ValueError, TypeError): + return default + + def get_str(self, key: str, default: str = "") -> str: + val = self._raw.get(key) + if val is None: + return default + return str(val) + + def get_non_empty_str(self, key: str, default: str = "") -> str: + """文本配置解析:缺失或空白都回退默认值,适用于必须有安全基线的字段。""" + val = self._raw.get(key) + if val is None: + return default + text = str(val) + return text if text.strip() else default + + def get_list(self, key: str, default=None) -> list: + """列表型配置:原生 list 直返;逗号分隔字符串拆分去空;其余返回默认。""" + val = self._raw.get(key) + if isinstance(val, list): + return [str(v).strip() for v in val if str(v).strip()] + if isinstance(val, str): + return [v.strip() for v in val.split(",") if v.strip()] + return list(default or []) + + def _get_recognition_enum(self, key: str, default: str, allowed: set[str], warning_code: str) -> str: + """识别增强枚举配置解析;非法值回退安全默认并保留稳定告警码。""" + value = self.get_str(key, default).strip().lower() + if value in allowed: + return value + self._recognition_guard_config_warnings.add(warning_code) + return default + + def _get_recognition_min_int(self, key: str, default: int, minimum: int, warning_code: str) -> int: + """识别增强正整数解析;非法、浮点或低于下限时使用运行时默认值并记录告警。""" + val = self._raw.get(key) + if val is None: + return default + if isinstance(val, bool): + self._recognition_guard_config_warnings.add(warning_code) + return default + try: + text = str(val).strip() + if not text or any(char in text for char in (".", "e", "E")): + raise ValueError + parsed = int(text) + except (ValueError, TypeError): + self._recognition_guard_config_warnings.add(warning_code) + return default + if parsed < minimum: + self._recognition_guard_config_warnings.add(warning_code) + return default + return parsed + + # ---- 全局开关与运行 ---- + + @property + def enabled(self) -> bool: + """插件总开关:关闭后所有域与定时任务不生效。""" + return self.get_bool("enabled", False) + + @property + def notify(self) -> bool: + """是否在关键事件(删种/暂停/重建等)发送通知。""" + return self.get_bool("notify", True) + + @property + def onlyonce(self) -> bool: + """立即运行一次:保存后触发一轮全量巡检,执行后自动复位。""" + return self.get_bool("onlyonce", False) + + @property + def reset_task(self) -> bool: + """重置数据:清空待定/暂停/监控等插件任务数据,执行后自动复位。""" + return self.get_bool("reset_task", False) + + # ---- 公共周期 ---- + + @property + def auto_check_interval_minutes(self) -> int: + """通用巡检周期(分钟):站点证据采样、待定释放、无下载处理和本地清理共用。""" + return self.get_int("auto_check_interval_minutes", 30) + + @property + def download_check_interval_minutes(self) -> int: + """下载检查周期(分钟):定时读取下载器状态并处理超时/Tracker/手动删种。""" + return self.get_int("download_check_interval_minutes", 10) + + @property + def meta_check_interval_hours(self) -> int: + """元数据检查周期(小时):定时复核订阅元数据并重评信号/待定。""" + return self.get_int("meta_check_interval_hours", 3) + + @property + def best_version_cron(self) -> str: + """洗版检查周期:cron 表达式,由 CronTrigger 调度定时推进洗版订阅。""" + return self.get_str("best_version_cron", "0 15 * * *") + + # ---- 订阅清理 ---- + + @property + def download_monitor_enabled(self) -> bool: + """下载超时自动删除:控制超时、Tracker 关键字和手动删种善后等破坏性下载管理。""" + return self.get_bool("download_monitor_enabled", True) + + @property + def manual_delete_listen(self) -> bool: + """监听用户手动删除的种子;关闭后下载器侧消失不触发删除处理。""" + return self.get_bool("manual_delete_listen", True) + + @property + def tracker_response_listen(self) -> bool: + """Tracker 返回内容包含关键字时自动删种;关闭后不按 Tracker 返回内容删种。""" + return self.get_bool("tracker_response_listen", True) + + @property + def auto_search_when_delete(self) -> bool: + """删种后自动触发该订阅补全搜索。""" + return self.get_bool("auto_search_when_delete", True) + + @property + def skip_deletion(self) -> bool: + """资源选择阶段跳过删除指纹命中的近期删除资源,避免再次下载刚删的种子。""" + return self.get_bool("skip_deletion", True) + + @property + def download_timeout_minutes(self) -> int: + """下载超时时间:同一下载任务观察窗口内进度不足时才进入超时判断。""" + return self.get_int("download_timeout_minutes", 120) + + @property + 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: + """下载连续超时重试次数:达到上限后保留任务并停止自动删种重试。""" + return self.get_int("download_retry_limit", 3) + + @property + def delete_exclude_tags(self) -> str: + """自动删种排除标签:带有任一标签的种子不参与超时或 Tracker 关键字删除。""" + return self.get_non_empty_str("delete_exclude_tags", DEFAULT_DELETE_EXCLUDE_TAGS) + + @property + def default_tracker_response(self) -> str: + """Tracker 响应关键字:每行一个匹配项,空配置使用内置关键字。""" + return self.get_non_empty_str("default_tracker_response", DEFAULT_TRACKER_RESPONSE) + + @property + def delete_record_retention_hours(self) -> int: + """删除指纹保留期(小时):超过则定时清理,避免长期屏蔽同源资源。""" + return self.get_int("delete_record_retention_hours", 24) + + @property + def subscription_cleanup_history_type(self) -> str: + """订阅清理整理记录范围:no/all/movie/tv,命中后才允许执行破坏性清理事务。""" + val = self.get_str("subscription_cleanup_history_type", "no") + return val if val in ("no", "all", "movie", "tv") else "no" + + @property + def subscription_cleanup_history_scenes(self) -> list: + """订阅清理整理记录场景:normal/best_version/best_version_episode。""" + scenes = self.get_list("subscription_cleanup_history_scenes") + allowed = {"normal", "best_version", "best_version_episode"} + return [scene for scene in (str(scene or "") for scene in scenes) if scene in allowed] + + # ---- 识别增强 ---- + + @property + def recognition_guard_mode(self) -> str: + """识别增强模式:候选准入的风险偏好,历史配置缺字段时保持关闭。""" + return self._get_recognition_enum( + "recognition_guard_mode", + "off", + {"off", "audit", "loose", "balanced", "strict"}, + "invalid_mode", + ) + + @property + def recognition_guard_notify(self) -> str: + """识别增强通知模式:只影响消息推送,不影响本地审计日志。""" + return self._get_recognition_enum( + "recognition_guard_notify", + "off", + {"off", "summary", "detail", "all"}, + "invalid_recognition_notify", + ) + + @property + def recognition_guard_notify_interval(self) -> int: + """识别增强通知限频秒数,同订阅同动作同原因命中时只抑制通知。""" + return self._get_recognition_min_int( + "recognition_guard_notify_interval", + 3600, + 60, + "invalid_notify_interval", + ) + + @property + def recognition_guard_tmdb_recheck_mode(self) -> str: + """二次识别触发范围:audit 按 balanced 口径计算。""" + return self._get_recognition_enum( + "recognition_guard_tmdb_recheck_mode", + "balanced_strict", + {"off", "all", "strict", "balanced_strict"}, + "invalid_tmdb_recheck_mode", + ) + + @property + def recognition_guard_cache_maxsize(self) -> int: + """二次识别缓存上限,避免同一候选重复识别。""" + return self._get_recognition_min_int( + "recognition_guard_cache_maxsize", + 100000, + 100, + "invalid_cache_maxsize", + ) + + @property + def recognition_guard_custom_config(self) -> str: + """识别增强 YAML 自定义策略;空文本表示无自定义覆盖。""" + return self.get_str("recognition_guard_custom_config", DEFAULT_RECOGNITION_GUARD_CUSTOM_CONFIG) + + @property + def recognition_guard_config_warnings(self) -> set[str]: + """识别增强配置解析告警码快照,供日志和测试读取,不作为可保存配置。""" + return set(self._recognition_guard_config_warnings) + + # ---- 订阅待定 ---- + + @property + def pending_enhanced_enabled(self) -> bool: + """自动待定剧集订阅:按开播时间、已播集数和可选变化信号标记待定。""" + return self.get_bool("pending_enhanced_enabled", True) + + @property + def pending_download_enabled(self) -> bool: + """自动待定下载中订阅:存在进行中下载时否决完成,避免入库前完成订阅。""" + return self.get_bool("pending_download_enabled", True) + + @property + def auto_tv_pending_days(self) -> int: + """剧集待定天数:开播后 N 天内保持待定;0 表示不按天数进入待定。""" + return self.get_int("auto_tv_pending_days", 0) + + @property + def auto_tv_pending_episodes(self) -> int: + """剧集待定集数:已播出集数小于等于 N 时保持待定;0 表示不按集数进入待定。""" + return self.get_int("auto_tv_pending_episodes", 1) + + @property + def pending_use_volatility(self) -> bool: + """待定参考变更速率:接近完结且总集数近期变化时参与待定判断。""" + return self.get_bool("pending_use_volatility", False) + + # ---- 订阅暂停 ---- + + @property + def pause_enhanced_enabled(self) -> bool: + """自动暂停订阅:按播出距离、上映距离和用户名单暂停订阅。""" + return self.get_bool("pause_enhanced_enabled", False) + + @property + def auto_pause_users(self) -> str: + """用户名自动暂停名单,逗号分隔;新增订阅用户在名单内时自动暂停,空串表示不启用。""" + return self.get_str("auto_pause_users", "") + + @property + def airing_pause_days(self) -> int: + """即将播出暂停天数:下一集距离超过 N 天时暂停;0=不处理。""" + return self.get_int("airing_pause_days", 30) + + @property + def movie_air_pause_days(self) -> int: + """电影上映暂停天数:当前日期早于上映日期减 N 天则暂停;0=不处理。""" + return self.get_int("movie_air_pause_days", 7) + + @property + def tv_air_pause_days(self) -> int: + """剧集上映暂停天数:当前日期早于开播日期减 N 天则暂停;0=不处理。""" + return self.get_int("tv_air_pause_days", 14) + + @property + def movie_no_download_days(self) -> int: + """电影无下载处理天数:上映后 N 天内无下载则按无下载策略处理;0=不处理。""" + return self.get_int("movie_no_download_days", 365) + + @property + def tv_no_download_days(self) -> int: + """剧集无下载处理天数:上映后 N 天内无下载则按无下载策略处理;0=不处理。""" + return self.get_int("tv_no_download_days", 180) + + @property + def no_download_actions(self) -> list: + """无下载处理策略(多选):pause/complete/delete × movie/tv 的组合。""" + return self.get_list("no_download_actions") + + # ---- 订阅补全 ---- + + @property + def site_total_probe_enabled(self) -> bool: + """站点集数探测:使用站点缓存资源辅助发现目标集数不足。""" + return self.get_bool("site_total_probe_enabled", False) + + @property + def paused_probe_reasons(self) -> list: + """暂停订阅低频补搜场景;缺省只对无下载暂停进行主动搜索。""" + return self.get_list("paused_probe_reasons", ["no_download"]) + + @property + def paused_probe_min_pause_days(self) -> int: + """暂停订阅达到天数后才允许主动补搜;0=不处理。""" + return self.get_int("paused_probe_min_pause_days", 14) + + @property + def paused_probe_interval_hours(self) -> int: + """同一订阅主动补搜的最小间隔,运行时不低于 24 小时。""" + return max(self.get_int("paused_probe_interval_hours", 72), 24) + + # ---- 订阅洗版 ---- + + @property + def best_version_type(self) -> str: + """洗版类型:no=关闭自动洗版;all/movie/tv/tv_episode=按范围自动创建并巡检洗版订阅。""" + val = self.get_str("best_version_type", "no") + return val if val in ("no", "all", "movie", "tv", "tv_episode") else "no" + + @property + def best_version_movie_remaining_days(self) -> int: + """电影洗版时限:达到天数后自动终止,有下载则按最新时间计;0=不限。""" + return self.get_int("best_version_movie_remaining_days", 0) + + @property + def best_version_tv_remaining_days(self) -> int: + """剧集全集洗版时限:达到天数后自动终止,有下载则按最新时间计;0=不限。""" + return self.get_int("best_version_tv_remaining_days", 0) + + @property + def best_version_episode_to_full(self) -> bool: + """分集转全集:分集洗版订阅目标集满足时切换为整季洗版。""" + return self.get_bool("best_version_episode_to_full", False) + + @property + def best_version_backfill_enabled(self) -> bool: + """回填已存在集:新建或转分集洗版时把媒体库已有集标为顶档。""" + return self.get_bool("best_version_backfill_enabled", False) + + @property + def backfill_best_version_now(self) -> bool: + """立即扫描存量并回填:对现有分集洗版订阅执行一次回填。""" + return self.get_bool("backfill_best_version_now", False) + + # ---- 完结信号与验证 ---- + + @property + def completion_guard_mode(self) -> str: + """完结守卫模式:关闭、严格、平衡或宽松。""" + value = self.get_str("completion_guard_mode", "balanced").strip().lower() + return value if value in {"off", "strict", "balanced", "loose"} else "balanced" + + @property + def site_completion_evidence_enabled(self) -> bool: + """站点完结信号:使用站点资源标题佐证当前目标完成。""" + return self.get_bool("site_completion_evidence_enabled", True) + + @property + def volatility_enabled(self) -> bool: + """变更速率信号:总集数在观察窗口内变化时阻止直接完成。""" + return self.get_bool("volatility_enabled", True) + + @property + def volatility_window_days(self) -> int: + """变更速率窗口:统计总集数变化的天数,值越大越保守。""" + return self.get_int("volatility_window_days", DEFAULT_VOLATILITY_WINDOW_DAYS) + + @property + def cadence_enabled(self) -> bool: + """播出节奏信号:按已播间隔估算继续等待期,不单独判定完结。""" + return self.get_bool("cadence_enabled", True) + + @property + def cadence_multiplier(self) -> float: + """节奏窗口系数:放大已播间隔得到最低等待窗口。""" + return self.get_float("cadence_multiplier", 2.5) + + @property + def cadence_min_window_days(self) -> int: + """节奏窗口下限:节奏估算的等待窗口不得低于该天数。""" + return self.get_int("cadence_min_window_days", 7) + + @property + def cadence_min_episodes(self) -> int: + """节奏参与最少集数:已播集数达到该值后才计算播出节奏。""" + return self.get_int("cadence_min_episodes", 3) + + @property + def season_cooldown_days(self) -> int: + """季冷却期:末集播出后继续观察的天数。""" + return self.get_int("season_cooldown_days", 14) + + @property + def verify_enabled(self) -> bool: + """自动纠错:完成后定时复核集数,发现增加时重建订阅。""" + return self.get_bool("verify_enabled", False) + + @property + def verify_interval_hours(self) -> int: + """自动纠错间隔:完成快照复核的定时周期。""" + return self.get_int("verify_interval_hours", 12) + + @property + def verify_retention_days(self) -> int: + """完成快照保留天数;用户配置覆盖默认的 180 天。""" + return self.get_int("verify_retention_days", 180) + + @property + def timeout_release_days(self) -> int: + """完成前观察天数:低置信或不稳定完成否决可保留的最长观察窗口。""" + return self.get_int("timeout_release_days", 7) + + @property + def timeout_cadence_acceleration(self) -> bool: + """按节奏加速释放:播出节奏等待期结束后缩短完成前观察窗口。""" + return self.get_bool("timeout_cadence_acceleration", True) + + def declared_keys(self) -> list: + """返回所有配置键(与各 @property 同名)。供表单 model 覆盖校验,避免表单与配置漂移。""" + excluded = {"recognition_guard_config_warnings"} + return [name for name, value in vars(type(self)).items() + if isinstance(value, property) and name not in excluded] + + @classmethod + def defaults(cls) -> dict: + """返回所有配置键的默认值(构造空配置读取各 property),供表单 model 默认数据。""" + blank = cls({}) + return {key: getattr(blank, key) for key in blank.declared_keys()} diff --git a/plugins.v3/subscribeassistantenhanced/shared/deletes.py b/plugins.v3/subscribeassistantenhanced/shared/deletes.py new file mode 100644 index 00000000..eab55c98 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/deletes.py @@ -0,0 +1,74 @@ +"""删除指纹存储,对用户表现为“近期删除资源”。 + +记录因超时、Tracker 命中或手动删除而移除的种子,供 ResourceSelection 防止立即重选。 +匹配语义只使用 enclosure/page_url,不按站点或标题泛匹配,避免误挡同站同名的不同资源。 +""" +import time +from typing import Callable, Optional + + +class DeletesStore: + """已删除种子指纹的读写、匹配与老化。""" + + def __init__(self, task_data_read: Callable, task_data_update: Callable): + self._read = task_data_read + self._update = task_data_update + + def save(self, torrent_task: dict, reason: str = "timeout"): + """按 hash 归档删除指纹,保留 enclosure/page_url/title 等诊断字段。""" + if not torrent_task: + return + torrent_hash = torrent_task.get("hash") + if not torrent_hash: + return + + def updater(data: dict) -> dict: + entry = dict(torrent_task) + entry["delete_time"] = time.time() + entry["delete_type"] = reason + data[torrent_hash] = entry + return data + + self._update("deletes", updater) + + def match(self, enclosure: Optional[str] = None, page_url: Optional[str] = None, + partial: bool = True) -> bool: + """判断资源是否命中删除指纹,enclosure/page_url 任一匹配即命中。""" + + def is_match(field1, field2) -> bool: + if partial: + # 双向子串匹配:兼容种子 URL 带/不带 passkey 等细微差异 + return bool(field1 and field2 and (field1 in field2 or field2 in field1)) + return field1 == field2 + + deletes = self._read("deletes") or {} + for entry in deletes.values(): + if is_match(enclosure, entry.get("enclosure")): + return True + if is_match(page_url, entry.get("page_url")): + return True + return False + + def cleanup_expired(self, retention_hours: int = 24, now: Optional[float] = None) -> int: + """清理超过保留期的删除指纹,返回移除条数。 + + 指纹只增不减会长期挡住同源资源重选;按 delete_time 老化,保留期内保留、过期移除。 + now 可注入便于测试;缺 delete_time 的旧条目保守保留(不老化)。 + """ + now = time.time() if now is None else now + cutoff = now - retention_hours * 3600 + removed = 0 + + def updater(data: dict) -> dict: + nonlocal removed + kept = {} + for key, entry in (data or {}).items(): + delete_time = entry.get("delete_time", 0) + if delete_time and delete_time < cutoff: + removed += 1 + continue + kept[key] = entry + return kept + + self._update("deletes", updater) + return removed diff --git a/plugins.v3/subscribeassistantenhanced/shared/log.py b/plugins.v3/subscribeassistantenhanced/shared/log.py new file mode 100644 index 00000000..86aebb3a --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/log.py @@ -0,0 +1,49 @@ +"""日志摘要工具与诊断级日志开关。 + +主程序 logger(app.log)已按调用文件名/插件自动标注来源并分文件落盘 +(命中 plugins/ 即写 plugins/.log),因此业务日志不再手工加 +插件名/域名前缀,避免与框架自带来源标注重复。本模块只提供: +- detail:诊断级日志通道,beta 期抬到 info、灰度结束统一降回 debug; +- 标题/取值截断等日志摘要工具。 +""" +from typing import Any + +from app.log import logger + +# Beta 灰度期开关:控制 detail 诊断日志的实际级别。 +# True → 绑定 logger.info(默认日志级别即可见,便于快速排查); +# 灰度结束改为 False → 降为 logger.debug 降噪。一处常量切换,无需改动各调用点。 +BETA_VERBOSE = True + +# 诊断级日志:直接绑定 logger 的 bound method,等价于直接调用 logger.info / logger.debug。 +# 不再包一层函数是有意为之——主程序按 sys._getframe(3) 定位调用来源文件名与插件, +# 任何包装层都会新增一帧、让所有日志来源被记成 log.py,破坏按文件定位的能力。 +detail = logger.info if BETA_VERBOSE else logger.debug + + +def truncate_log_value(value: Any, max_length: int = 160, middle: bool = False) -> str: + """截断过长的日志值。""" + text = str(value) if value is not None else "" + if len(text) <= max_length: + return text + if middle: + # 头尾各留一半、中间省略号;奇数余量多出的 1 个字符归头部,保证截断后总长恰为 max_length + remain = max_length - 3 + if remain <= 0: + return "..." + head = remain - remain // 2 + tail = remain // 2 + return f"{text[:head]}...{text[-tail:]}" if tail else f"{text[:head]}..." + return f"{text[:max_length - 3]}..." + + +def format_log_title_desc(title: Any = None, description: Any = None, + max_length: int = 220) -> str: + """格式化标题和描述为日志行。""" + parts = [] + if title: + parts.append(str(title)) + if description: + parts.append(str(description)) + text = " - ".join(parts) + return truncate_log_value(text, max_length) diff --git a/plugins.v3/subscribeassistantenhanced/shared/media.py b/plugins.v3/subscribeassistantenhanced/shared/media.py new file mode 100644 index 00000000..c11cda2e --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/media.py @@ -0,0 +1,271 @@ +"""季信息/集信息/播出日期工具函数。""" +from datetime import datetime, date +from typing import Optional + +from .subscribe import pending_subscription_episodes + + +def relative_day_text(target_date: date, as_of: Optional[date] = None) -> str: + """把日期转成面向用户的相对天数描述。""" + today = as_of or date.today() + days = (target_date - today).days + if days > 0: + return f"距今 {days} 天" + if days < 0: + return f"已过 {-days} 天" + return "今天" + + +def date_context(label: str, target_date: date, as_of: Optional[date] = None) -> str: + """生成带日期和相对天数的通知上下文。""" + return f"{label}:{target_date.isoformat()},{relative_day_text(target_date, as_of=as_of)}" + + +def parse_date(date_str: Optional[str], fmt: str = "%Y-%m-%d") -> Optional[date]: + """解析日期字符串,失败返回 None。""" + if not date_str: + return None + try: + return datetime.strptime(date_str, fmt).date() + except (ValueError, TypeError): + return None + + +def episode_field(episode, name: str, default=None): + """读取 TMDB 集信息字段,兼容 API 原始 dict 与仓内 TmdbEpisode 对象。""" + if isinstance(episode, dict): + return episode.get(name, default) + return getattr(episode, name, default) + + +def _same_optional_season(season_number, subscribe_season) -> bool: + """按显式季号比较分集归属;S0 是合法季号,不能按空值处理。""" + return season_number is None or subscribe_season is None or season_number == subscribe_season + + +def target_episode_range(subscribe) -> list[int]: + """返回订阅目标集范围,按主程序 start_episode/total_episode 契约解释。""" + start_episode = subscribe.start_episode or 1 + total_episode = subscribe.total_episode or 0 + if total_episode < start_episode: + return [] + return list(range(start_episode, total_episode + 1)) + + +def resolve_airing_next_episode(subscribe, aggregate_episode, episodes: list, + as_of: Optional[date] = None): + """解析播出暂停使用的下一集,并限定为订阅范围内首个待下载集。 + + 聚合字段只有在属于当前季、明确晚于当天且集号匹配首待下载集时才可信; + 否则从当前 SeasonScope 分集表中寻找同一首待下载集,避免聚合字段为空或 + 仍停留在当天已播集时漏掉已经公开的后续排期。 + """ + today = as_of or date.today() + pending_episodes = pending_subscription_episodes(subscribe) + if not pending_episodes: + return None + first_pending = pending_episodes[0] + + def valid_candidate(episode) -> bool: + """判断候选集是否满足季、日期和首待下载集约束。""" + if not episode: + return False + season_number = episode_field(episode, "season_number") + subscribe_season = subscribe.season + if not _same_optional_season(season_number, subscribe_season): + return False + air_date = parse_date(episode_field(episode, "air_date")) + if air_date is None or air_date <= today: + return False + episode_number = episode_field(episode, "episode_number") + if episode_number is None: + return not episodes + if episode_number != first_pending: + return False + return True + + if valid_candidate(aggregate_episode): + return aggregate_episode + + candidates = [episode for episode in (episodes or []) if valid_candidate(episode)] + if not candidates: + candidates = resolve_inventory_next_episodes(subscribe, episodes, as_of=today) + if not candidates: + return None + return min( + candidates, + key=lambda episode: ( + parse_date(episode_field(episode, "air_date")), + episode_field(episode, "episode_number", 0), + ), + ) + + +def future_episode_candidates(subscribe, episodes: list, as_of: Optional[date] = None) -> list: + """返回当前季订阅目标范围内播出日期晚于当前日期的候选集。""" + today = as_of or date.today() + return episode_candidates_after(subscribe, episodes, today) + + +def unknown_tail_episode_count(subscribe, episodes: list) -> int: + """统计订阅尾部超出 TMDB 当前分集表的目标集数量。""" + target_episodes = target_episode_range(subscribe) + if not target_episodes: + return 0 + known_numbers = [] + for episode in (episodes or []): + season_number = episode_field(episode, "season_number") + if not _same_optional_season(season_number, subscribe.season): + continue + episode_number = episode_field(episode, "episode_number") + if episode_number is not None: + known_numbers.append(episode_number) + if not known_numbers: + return 0 + max_known = max(known_numbers) + return sum(1 for episode_number in target_episodes if episode_number > max_known) + + +def episode_candidates_after(subscribe, episodes: list, cutoff: date) -> list: + """返回当前季订阅目标范围内晚于指定日期的集候选。""" + target_episodes = set(target_episode_range(subscribe)) + candidates = [] + for episode in (episodes or []): + season_number = episode_field(episode, "season_number") + if not _same_optional_season(season_number, subscribe.season): + continue + episode_number = episode_field(episode, "episode_number") + if episode_number is None or (target_episodes and episode_number not in target_episodes): + continue + air = parse_date(episode_field(episode, "air_date")) + if air and air > cutoff: + candidates.append(episode) + return candidates + + +def resolve_inventory_next_episodes(subscribe, episodes: list, + as_of: Optional[date] = None) -> list: + """按媒体库实缺数量判断追更已到当前已播最新时,返回后续播出候选集。 + + ``note`` 只记录订阅链路下载历史,手动下载后整理入库不会补写;播出暂停需要判断 + 真实库存是否已经追到当前已播最新,因此参考主程序维护的 ``lack_episode``,并结合未播集数量判断。 + """ + futures = future_episode_candidates(subscribe, episodes, as_of=as_of) + if not futures: + return [] + lack_episode = subscribe.lack_episode + if lack_episode is None: + return [] + try: + lack_count = int(lack_episode) + except (TypeError, ValueError): + return [] + if lack_count < 0: + return [] + future_count = len(futures) + unknown_tail_episode_count(subscribe, episodes) + if lack_count == future_count: + return futures + return [] + + +def is_same_season(season_info: dict, season: int) -> bool: + """判断主季或剧集组 season_info 是否属于指定季。""" + return season_info.get("season_number") == season or season_info.get("order") == season + + +def get_tv_season_info(mediainfo, season: int) -> Optional[dict]: + """从 mediainfo.season_info 中获取指定季的信息。""" + for info in mediainfo.season_info or []: + if is_same_season(info, season): + return info + return None + + +def get_tv_season_episode_count(mediainfo, season: int, episodes: list | None = None) -> Optional[int]: + """解析当前季总集数;未知时返回 None,不把空查询当 0 集。""" + info = get_tv_season_info(mediainfo, season) + if info: + episode_count = info.get("episode_count") + if episode_count is not None: + try: + return int(episode_count) + except (TypeError, ValueError): + pass + raw_episodes = info.get("episodes") + if raw_episodes: + return len(raw_episodes) + if episodes: + return len([ + ep for ep in episodes + if episode_field(ep, "episode_number") is not None + ]) + return None + + +def get_tv_season_air_date(mediainfo, season: int) -> Optional[str]: + """获取指定季的开播日期。""" + info = get_tv_season_info(mediainfo, season) + if info: + return info.get("air_date") + return None + + +def first_available_scope_episode_air_date(subscribe, episodes: list) -> Optional[date]: + """返回当前季分集表中集号最早的有效播出日期;分集表由调用方按剧集组范围取得。""" + candidates = [] + for episode in episodes or []: + if not subscribe.episode_group: + season_number = episode_field(episode, "season_number") + if not _same_optional_season(season_number, subscribe.season): + continue + episode_number = episode_field(episode, "episode_number") + if episode_number is None: + continue + air = parse_date(episode_field(episode, "air_date")) + if air: + candidates.append((episode_number, air)) + if not candidates: + return None + return min(candidates, key=lambda item: item[0])[1] + + +def first_scope_episode_air_date(subscribe, episodes: list) -> Optional[date]: + """返回当前季分集表中首个可用播出日期。""" + return first_available_scope_episode_air_date(subscribe, episodes) + + +def count_aired_episodes(episodes: list, as_of: Optional[date] = None) -> int: + """统计目标范围内已播出的集数。""" + today = as_of or date.today() + count = 0 + for ep in episodes: + air = parse_date(ep.air_date) + if air and air <= today: + count += 1 + return count + + +def last_aired_episode(episodes: list, as_of: Optional[date] = None): + """返回目标范围内最后一个已播出的集。""" + today = as_of or date.today() + aired = [] + for ep in episodes: + air = parse_date(ep.air_date) + if air and air <= today: + aired.append((air, ep)) + if not aired: + return None + aired.sort(key=lambda x: x[0]) + return aired[-1][1] + + +def all_aired(episodes: list, as_of: Optional[date] = None) -> bool: + """判断目标范围内所有集是否都已播出。""" + if not episodes: + return False + today = as_of or date.today() + for ep in episodes: + air = parse_date(ep.air_date) + if not air or air > today: + return False + return True diff --git a/plugins.v3/subscribeassistantenhanced/shared/subscribe.py b/plugins.v3/subscribeassistantenhanced/shared/subscribe.py new file mode 100644 index 00000000..42c5b86e --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/subscribe.py @@ -0,0 +1,198 @@ +"""订阅匹配/格式化工具函数。""" +import json +from typing import List, Optional, Tuple + +from app.chain.subscribe import build_subscribe_meta as build_main_subscribe_meta +from app.log import logger +from app.schemas.types import MediaSource, MediaType + + +def resolve_subscribe_media_type(subscribe) -> MediaType: + """解析订阅媒体类型,非法或缺失时返回 UNKNOWN,供状态变更链路 fail-closed。""" + if not subscribe: + return MediaType.UNKNOWN + media_type = getattr(subscribe, "type", None) + if isinstance(media_type, MediaType): + return media_type + value = getattr(media_type, "value", media_type) + if isinstance(value, str): + value = value.strip() + if not value: + return MediaType.UNKNOWN + try: + return MediaType(value) + except ValueError: + return MediaType.UNKNOWN + + +def subscribe_media_identity(subscribe) -> tuple[str, str]: + """返回订阅的 V3 规范媒体身份;身份不完整时返回空值对。""" + if not subscribe: + return "", "" + media_source = str(subscribe.media_source or "").strip() + media_id = str(subscribe.media_id or "").strip() + if not media_source or not media_id: + return "", "" + return media_source, media_id + + +def subscribe_tmdb_id(subscribe) -> Optional[int]: + """仅把 TMDB 主身份转换为 TMDB API 所需整数 ID,其他来源不跨源猜测。""" + media_source, media_id = subscribe_media_identity(subscribe) + if media_source != MediaSource.TMDB.value: + return None + try: + tmdb_id = int(media_id) + except (TypeError, ValueError): + return None + return tmdb_id if tmdb_id > 0 else None + + +def is_full_best_version_subscribe(subscribe) -> bool: + """判断是否为真正洗版订阅:电影 best_version,或 best_version_full 的剧集 best_version。""" + if not subscribe or not subscribe.best_version: + return False + media_type = resolve_subscribe_media_type(subscribe) + if media_type == MediaType.MOVIE: + return True + return media_type == MediaType.TV and bool(subscribe.best_version_full) + + +def is_tv_episode_best_version_subscribe(subscribe) -> bool: + """判断是否为剧集分集洗版订阅。""" + if not subscribe or not subscribe.best_version: + return False + return resolve_subscribe_media_type(subscribe) == MediaType.TV and not bool(subscribe.best_version_full) + + +def build_subscribe_meta(subscribe, failure_context: str): + """按主程序订阅 MetaInfo 构造口径补齐缺集查询输入。""" + media_type = resolve_subscribe_media_type(subscribe) + if media_type not in (MediaType.MOVIE, MediaType.TV): + logger.warning(f"{failure_context}:{format_subscribe(subscribe)},订阅媒体类型无效:{subscribe.type}") + return None + return build_main_subscribe_meta(subscribe) + + +def format_subscribe(subscribe) -> str: + """格式化订阅为可读字符串。""" + name = subscribe.name + season = subscribe.season + return f"{name} S{season}" if season is not None else name + + +def format_subscribe_label(subscribe=None, subscribe_id=None) -> str: + """格式化日志订阅标签;对象可用时输出名称、季号和 ID。""" + if subscribe: + try: + sid = subscribe_id if subscribe_id is not None else subscribe.id + return f"{format_subscribe(subscribe)}(id={sid})" + except AttributeError: + try: + subscribe_id = subscribe.id + except AttributeError: + pass + if subscribe_id is not None: + return f"订阅 {subscribe_id}" + return "未知订阅" + + +def format_subscribe_desc(subscribe) -> str: + """格式化订阅描述信息。""" + parts = [format_subscribe(subscribe)] + total = subscribe.total_episode + lack = subscribe.lack_episode + if total: + parts.append(f"({total - lack}/{total})") + return " ".join(parts) + + +def pending_subscription_episodes(subscribe) -> List[int]: + """返回目标范围内尚未下载到任何版本的集数。 + + note 记录订阅下载历史;分集洗版还会把已取得版本的集写入 + episode_priority。正优先级表示该集已有可用版本,即使仍需继续洗版, + 也不属于从未下载的集。 + """ + start_episode = subscribe.start_episode or 1 + total_episode = subscribe.total_episode or 0 + if total_episode < start_episode: + return [] + downloaded = { + int(episode) for episode in (subscribe.note or []) + if isinstance(episode, int) or ( + isinstance(episode, str) and episode.lstrip("-").isdigit() + ) + } + for episode, priority in (subscribe.episode_priority or {}).items(): + if not str(episode).isdigit(): + continue + try: + if float(priority) > 0: + downloaded.add(int(episode)) + except (TypeError, ValueError): + continue + return [ + episode for episode in range(start_episode, total_episode + 1) + if episode not in downloaded + ] + + +def match_subscribe(subscribe, task: dict) -> bool: + """判断任务数据是否匹配指定订阅。""" + if not task: + return False + if task.get("id") != subscribe.id: + return False + if task.get("name") != subscribe.name: + return False + media_source, media_id = subscribe_media_identity(subscribe) + if task.get("media_source") != media_source or str(task.get("media_id") or "") != media_id: + return False + if task.get("season") != subscribe.season: + return False + task_group = task.get("episode_group") + sub_group = subscribe.episode_group + if task_group != sub_group: + return False + return True + + +def subscribe_identity(subscribe) -> dict: + """提取订阅实例的媒体身份,防止数据库 ID 复用时读取旧状态。""" + return { + "subscribe_id": subscribe.id, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, + "episode_group": subscribe.episode_group, + } + + +def identity_matches(identity: dict, subscribe) -> bool: + """判断持久化身份是否仍属于当前订阅实例。""" + return bool(identity) and identity == subscribe_identity(subscribe) + + +def subscribe_from_source(origin, subscribe_oper) -> Tuple[Optional[dict], Optional[object]]: + """从事件 origin/source 解析订阅。 + + origin 是主程序订阅来源约定 ``Subscribe|``(json 内含订阅 id)。解析失败、前缀不符或 + 缺 id 一律返回 ``(None, None)``,调用方据此跳过——避免把消息/手动等非订阅来源误当订阅处理。 + 返回 ``(subscribe_dict, subscribe)``:前者为事件来源携带的订阅快照,后者为订阅表最新对象 + (订阅已删除时为 None)。 + """ + if not origin or "|" not in str(origin): + return None, None + prefix, json_data = str(origin).split("|", 1) + if prefix != "Subscribe": + return None, None + try: + subscribe_dict = json.loads(json_data) + except (ValueError, TypeError): + return None, None + subscribe_id = subscribe_dict.get("id") + if not subscribe_id: + return None, None + subscribe = subscribe_oper.get(subscribe_id) if subscribe_oper else None + return subscribe_dict, subscribe diff --git a/plugins.v3/subscribeassistantenhanced/shared/task.py b/plugins.v3/subscribeassistantenhanced/shared/task.py new file mode 100644 index 00000000..ad351316 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/task.py @@ -0,0 +1,122 @@ +"""插件持久化数据管理,封装 get_data/save_data + per-key RLock。""" +import threading +from typing import Any, Callable + + +class TaskDataManager: + """线程安全的 JSON 数据读写,每个 key 独立 RLock。""" + + def __init__(self, get_data_fn: Callable, save_data_fn: Callable): + self._get = get_data_fn + self._save = save_data_fn + self._locks: dict[str, threading.RLock] = {} + self._meta_lock = threading.Lock() + + def _lock_for(self, key: str) -> threading.RLock: + """获取或创建指定 key 的 RLock,创建过程由 _meta_lock 保护。""" + with self._meta_lock: + if key not in self._locks: + self._locks[key] = threading.RLock() + return self._locks[key] + + def read(self, key: str) -> Any: + """线程安全读取,key 不存在时返回空 dict。""" + with self._lock_for(key): + return self._get(key) or {} + + def write(self, key: str, data: Any): + """线程安全写入。""" + with self._lock_for(key): + self._save(key, data) + + def update(self, key: str, updater: Callable[[Any], Any]): + """线程安全读-改-写。""" + with self._lock_for(key): + data = self._get(key) or {} + updated = updater(data) + self._save(key, updated) + return updated + + def reset(self, key: str): + """清空指定 key 的数据。""" + with self._lock_for(key): + self._save(key, {}) + + def reset_all(self, keys: list[str]): + """批量清空多个 key。""" + for key in keys: + self.reset(key) + + def clear_tasks(self, subscribe_id): + """清理某订阅的全部任务数据:先清订阅任务、再清其名下种子任务。 + + 固定 subscribes→torrents 顺序,避免中途失败留下"订阅没了但种子任务还在"的半清理态。 + 种子任务按 ``subscribe_id`` 归属匹配(统一 str 比较,兼容 JSON 落盘后 key 字符串化)。 + """ + sid = str(subscribe_id) + + def _clear_subscribe(data: dict) -> dict: + data.pop(sid, None) + return data + + def _clear_torrents(data: dict) -> dict: + for torrent_hash in list(data.keys()): + if str(data[torrent_hash].get("subscribe_id")) == sid: + del data[torrent_hash] + return data + + self.update("subscribes", _clear_subscribe) + self.update("torrents", _clear_torrents) + for key in ("volatility", "blocks", "releases", "site_evidence"): + self.update(key, _clear_subscribe) + + def clear_tasks_for_pause(self, subscribe_id, preserve_subscribe_keys: list[str] | None = None): + """清理暂停前无效任务,同时保留指定订阅任务字段。 + + 订阅暂停记录、防打回窗口等字段与下载待定、种子任务共用 ``subscribes`` 记录。 + 暂停流程需要清理旧下载任务和待定来源,但不能抹掉刚写入的暂停归因或恢复保护窗口。 + """ + sid = str(subscribe_id) + preserve_subscribe_keys = preserve_subscribe_keys or [] + + def _clear_subscribe(data: dict) -> dict: + task = data.get(sid, {}) + preserved = {key: task[key] for key in preserve_subscribe_keys if key in task} + if preserved: + data[sid] = preserved + else: + data.pop(sid, None) + return data + + def _clear_torrents(data: dict) -> dict: + for torrent_hash in list(data.keys()): + if str(data[torrent_hash].get("subscribe_id")) == sid: + del data[torrent_hash] + return data + + self.update("subscribes", _clear_subscribe) + self.update("torrents", _clear_torrents) + for key in ("volatility", "blocks", "releases", "site_evidence"): + self.update(key, _clear_subscribe) + + def clean_torrent_tasks(self, torrent_hash): + """按 hash 同步清理单个种子的任务记录:从 torrents 移除,并从各订阅的 torrent_tasks 移除。 + + 用于移动模式整理完成后作废残留下载任务。 + """ + if not torrent_hash: + return + + def _clear_torrents(data: dict) -> dict: + data.pop(torrent_hash, None) + return data + + def _clear_from_subscribes(data: dict) -> dict: + for sub_task in data.values(): + tasks = sub_task.get("torrent_tasks") + if tasks: + sub_task["torrent_tasks"] = [t for t in tasks if t.get("hash") != torrent_hash] + return data + + self.update("torrents", _clear_torrents) + self.update("subscribes", _clear_from_subscribes) diff --git a/plugins.v3/subscribeassistantenhanced/shared/update.py b/plugins.v3/subscribeassistantenhanced/shared/update.py new file mode 100644 index 00000000..4415d9e8 --- /dev/null +++ b/plugins.v3/subscribeassistantenhanced/shared/update.py @@ -0,0 +1,16 @@ +"""真实订阅写库辅助函数。""" + + +def subscribe_update_payload(payload: dict) -> dict: + """生成订阅更新 payload,保持调用方字段边界。""" + return dict(payload or {}) + + +def update_subscribe(subscribe_oper, subscribe_id, payload: dict): + """通过 SubscribeOper 更新订阅,并透传调用方声明的变更字段。""" + if not subscribe_oper: + return None + data = subscribe_update_payload(payload) + if not data: + return None + return subscribe_oper.update(subscribe_id, data) diff --git a/pytest.ini b/pytest.ini index ad0f2c38..d5a38878 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,15 +1,15 @@ [pytest] -# 仅在仓库根 tests/ 下发现用例;插件目录(plugins/、plugins.v2/)不再承载测试 +# 仅在仓库根 tests/ 下发现用例;插件源码目录不承载测试 testpaths = tests python_files = test_*.py addopts = --import-mode=importlib -# 测试统一使用 pytest 风格;所有插件都在 tests/v1 或 tests/v2 下按插件 ID 建独立子目录 +# 测试统一使用 pytest 风格;所有插件都按插件 ID 建独立子目录 python_classes = *Test Test* python_functions = test_* -# v1/v2 必须分会话运行(同名插件包冲突);marker 供后续按代筛选扩展使用 +# v2/v3 必须分会话运行(同名插件包冲突);两者均在 V3 后端运行 markers = - v1: v1 插件(plugins/)单测,需与 v2 分独立会话运行 - v2: v2 插件(plugins.v2/)单测,需与 v1 分独立会话运行 + v2: 兼容 V3 的 v2 插件(plugins.v2/)单测 + v3: V3 专用插件(plugins.v3/)单测 # 仅忽略主程序依赖链或三方库在 Python 3.12 下的已知弃用告警;插件仓自身告警应直接修复 filterwarnings = ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning diff --git a/scripts/check_new_plugin_tests.py b/scripts/check_new_plugin_tests.py index 72c7db36..9aaf2c24 100644 --- a/scripts/check_new_plugin_tests.py +++ b/scripts/check_new_plugin_tests.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """检查新增插件是否至少提交对应测试目录。 -该门禁只约束当前 PR 新增的插件目录,不追溯历史插件;A 档覆盖率仍由 +该门禁只约束当前 PR 新增的 V3 插件目录,不追溯历史插件;A 档覆盖率仍由 ``plugin_quality.json`` 显式声明。 """ @@ -26,8 +26,7 @@ class NewPlugin: @property def source_path(self) -> str: """插件源码目录。""" - base = "plugins.v2" if self.generation == "v2" else "plugins" - return f"{base}/{self.plugin}" + return f"plugins.v3/{self.plugin}" @property def test_path(self) -> Path: @@ -75,18 +74,15 @@ def _changed_files(base_ref: str) -> list[str]: def collect_new_plugins(base_ref: str) -> list[NewPlugin]: - """从 Git diff 收集当前分支新增的 v1/v2 插件目录。""" + """从 Git diff 收集当前分支新增的 V3 插件目录。""" plugins: dict[tuple[str, str], NewPlugin] = {} for file in _changed_files(base_ref): parts = Path(file).parts if len(parts) < 2: continue - if parts[0] == "plugins.v2": - generation = "v2" - elif parts[0] == "plugins": - generation = "v1" - else: + if parts[0] != "plugins.v3": continue + generation = "v3" plugin = parts[1] source_path = f"{parts[0]}/{plugin}" if _path_exists_in_ref(base_ref, source_path): diff --git a/scripts/plugin_coverage.py b/scripts/plugin_coverage.py index 6310cd41..04b90581 100644 --- a/scripts/plugin_coverage.py +++ b/scripts/plugin_coverage.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 """按插件统计单测覆盖率并执行 A 档插件质量门禁。 -插件仓包含多代插件和大量历史插件,仓库级覆盖率会把未测试的历史插件计为 0%,不适合 -作为协作门禁。这里按插件独立运行 pytest 和 coverage,并只对 ``plugin_quality.json`` -声明的插件执行硬阈值,新增插件可先接入 smoke gate,再按维护等级加入覆盖率门禁。 +插件仓保留历史实现,但发布与核心质量门禁只面向 V3。这里按插件独立运行 pytest 和 +coverage,并只对 ``plugin_quality.json`` 声明的 V3 插件执行硬阈值。 """ from __future__ import annotations @@ -52,8 +51,7 @@ class CoverageTarget: @property def source_path(self) -> Path: """插件源码目录。""" - base = "plugins.v2" if self.generation == "v2" else "plugins" - return REPO_ROOT / base / self.plugin + return REPO_ROOT / f"plugins.{self.generation}" / self.plugin @property def test_path(self) -> Path: @@ -274,7 +272,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--report-dir", type=Path, default=DEFAULT_REPORT_DIR, help="coverage JSON 输出目录") parser.add_argument("--base-ref", default=os.environ.get("PLUGIN_COVERAGE_BASE_REF"), help="新增行对比基准") parser.add_argument("--plugin", action="append", help="只运行指定插件 ID,可重复传入") - parser.add_argument("--generation", choices=("v1", "v2"), help="只运行指定代际") + parser.add_argument("--generation", choices=("v3",), help="只运行指定代际") return parser.parse_args() diff --git a/tests/README.md b/tests/README.md index 909e7f39..15458cfd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,29 +8,31 @@ ``` tests/ ├─ _bootstrap.py 薄壳 shim:定位同级 MoviePilot 后端入 sys.path,引导逻辑委托主程序 app/testing.bootstrap -├─ conftest.py pytest 引导:按本次运行目标选择 v1/v2 插件环境并注册网络守卫 -├─ v2/ v2 插件(plugins.v2/)单测;每个插件按插件 ID 建子目录 -│ └─ subscribeassistant/ -└─ v1/ v1 插件(plugins/)单测;每个插件按插件 ID 建子目录 +├─ conftest.py pytest 引导:按目标选择 v2/v3 插件环境并注册网络守卫 +├─ v3/ V3 专用实现(plugins.v3/)单测 +│ └─ subscribeassistantenhanced/ +└─ v2/ 仍兼容 V3 的旧实现(plugins.v2/)单测 + └─ brushmanager/ ``` ## 运行 需要 MoviePilot 后端置于插件仓**同级目录**(或设环境变量 `MOVIEPILOT_BACKEND_PATH`), -并使用带后端依赖的解释器(如 `/.venv/bin/python`)。 +并使用单测环境 `/.venv-test/bin/python`。两组插件测试都在 MoviePilot V3 后端运行。 ```bash -# 全量(推荐入口):v1/v2 各自独立会话依次跑,命令行参数透传给 pytest -/.venv/bin/python tests/run.py +# 全量(推荐入口):CI、V3 专用实现、兼容旧实现依次独立运行 +MOVIEPILOT_BACKEND_PATH=/MoviePilot /.venv-test/bin/python tests/run.py -# 也可按代单独跑(v1/v2 必须分会话,勿混跑) -/.venv/bin/python -m pytest tests/v2 -/.venv/bin/python -m pytest tests/v1 +# 也可按轨道单独跑(v2/v3 必须分会话,勿混跑) +MOVIEPILOT_BACKEND_PATH=/MoviePilot /.venv-test/bin/python -m pytest tests/v3 +MOVIEPILOT_BACKEND_PATH=/MoviePilot /.venv-test/bin/python -m pytest tests/v2/brushmanager ``` -`tests/run.py` 把 v1/v2 放在独立子进程依次运行、无用例的代自动跳过——两代存在同名 -插件包(如 `brushflowlowfreq`、`torrentclassifier`),同一解释器进程无法同时加载、混跑 -会相互覆盖。隔离 `CONFIG_DIR`、建表、`app.helper.sites` 垫片、插件目录注入、v1/v2 marker、 +`tests/run.py` 把 v2/v3 放在独立子进程运行。`tests/v3` 跑 V3 专用实现;`tests/v2` +按 `package.v2.json` 动态选择未声明 `v3:false` 且已有测试的旧实现。测试目录找不到索引条目时 +失败关闭,兼容名单不写死在 runner。两轨可能存在同名插件包,同一解释器进程混跑会相互覆盖。 +隔离 `CONFIG_DIR`、建表、`app.helper.sites` 垫片、插件目录注入、v2/v3 marker、 autouse 网络守卫等引导逻辑统一在主程序 `app/testing`(`bootstrap` / `network_guard`)维护一处; 本仓 `tests/_bootstrap.py` 仅是「定位后端入 `sys.path`」的薄壳 shim,故后端需为含 `app/testing/bootstrap` 的较新 MoviePilot。共享 harness(`stub_modules` 等)在 bootstrap 后可直接复用。 @@ -39,23 +41,27 @@ autouse 网络守卫等引导逻辑统一在主程序 `app/testing`(`bootstrap 先本地 `python tests/run.py` 跑**全量并确认通过**,再 push / 提 PR。 -## 新增插件最低测试门禁 +## 新增 V3 插件最低测试门禁 -PR 新增 `plugins/` 或 `plugins.v2/` 下的插件目录时,必须同时提交对应代际的测试目录, -并包含至少一个 `tests///test_*.py`。该门禁只约束新增插件,不追溯 -历史插件;新增插件不会自动加入 A 档覆盖率门禁,达到核心维护等级后再显式写入 +新插件统一进入 `plugins.v3/`,必须同时提交至少一个对应的 +`tests/v3//test_*.py`。该门禁不追溯历史兼容实现;新增插件不会自动加入 A 档覆盖率门禁,达到核心维护等级后再显式写入 `plugin_quality.json`。 +## 索引兼容语义 + +- `package.json` 的默认实现需要 `v2:true`(或显式 `v3:true`)才能进入 V3 回退链。 +- `package.v2.json` 的实现默认由 V3 继承;不兼容或已有 V3 专用副本时声明 `v3:false`。 +- `package.v3.json` 与 `plugins.v3/` 存放只面向 MoviePilot V3 的专用实现。 +- V1/V2 兼容实现仍可发布,但不再针对旧后端单独运行测试。 + ## 覆盖率门禁 -插件覆盖率按插件独立统计,不使用全仓聚合覆盖率。全仓插件数量多、历史插件维护等级不同, -把所有 `plugins/` 与 `plugins.v2/` 一次性纳入 coverage 会让未接入测试的历史插件以 0% -拉低整体指标,也无法反映当前变更风险。 +插件覆盖率按插件独立统计,不使用全仓聚合覆盖率。历史兼容实现数量多且维护等级不同, +全量纳入 coverage 会让未接入测试的插件以 0% 拉低整体指标,也无法反映当前变更风险。 `plugin_quality.json` 声明需要强制覆盖率门禁的 A 档插件。当前默认锁定: -- `v2/subscribeassistant` -- `v2/subscribeassistantenhanced` +- `v3/subscribeassistantenhanced` 门禁阈值: @@ -74,7 +80,7 @@ env -u CONFIG_DIR MOVIEPILOT_BACKEND_PATH=/MoviePilot \ ```bash env -u CONFIG_DIR MOVIEPILOT_BACKEND_PATH=/MoviePilot \ - /.venv-test/bin/python scripts/plugin_coverage.py --generation v2 --plugin subscribeassistantenhanced + /.venv-test/bin/python scripts/plugin_coverage.py --generation v3 --plugin subscribeassistantenhanced ``` CI 等价检查(包含新增/变更可执行行覆盖率): @@ -92,9 +98,9 @@ env -u CONFIG_DIR MOVIEPILOT_BACKEND_PATH=/MoviePilot \ ## 新增用例 -1. 放到对应代际的插件独立目录:`tests///`,例如 - `tests/v2/subscribeassistant/`;所有插件都按插件 ID 建目录,不把用例文件直接平铺在 - `tests/v1/` 或 `tests/v2/` 下;文件名使用 `test_*.py`,在插件独立目录内不再重复插件名前缀; +1. V3 专用实现放到 `tests/v3//`;兼容旧实现保留在 `tests/v2//`。 + 所有插件都按插件 ID 建目录,不把用例文件直接平铺在代际目录;文件名使用 `test_*.py`, + 在插件独立目录内不再重复插件名前缀; 2. 直接导入 `app.*` 与对应代际插件包;根 conftest 会按本次运行目标在用例导入前完成后端与插件目录注入; 3. 使用 pytest 风格编写测试:普通函数或测试类均可,断言使用 `assert`;不要新增 `unittest.TestCase`、`unittest.main()` 或 `if __name__ == "__main__"` 入口; diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py index eb441c0c..983818fe 100644 --- a/tests/_bootstrap.py +++ b/tests/_bootstrap.py @@ -1,7 +1,7 @@ """插件仓单测引导薄壳:定位同级 MoviePilot 后端并入 ``sys.path``,引导逻辑委托主程序 ``app.testing.bootstrap``。 chicken-egg:导入主程序共享引导之前,必须先由本仓定位后端、加入 ``sys.path``——这一步不可消除, -故每个插件仓只保留这层极薄 shim;隔离 CONFIG_DIR / 建表 / 插件目录注入 / v1·v2 marker 等 +故每个插件仓只保留这层极薄 shim;隔离 CONFIG_DIR / 建表 / 插件目录注入 / v2·v3 marker 等 实际逻辑均在主程序 ``app/testing`` 维护一处,所有插件仓行为与修复保持一致。 所有引导函数都必须在首次 ``import app.*`` 或导入任一插件包之前调用,否则隔离与路径注入不生效。 @@ -50,6 +50,18 @@ def _resolve_backend_path() -> Path: block_real_network = import_module("app.testing.network_guard").block_real_network +def _expose_runtime_plugin_namespace(source_dir: Path) -> None: + """让 ``app.plugins.`` 绝对导入解析到当前代际的仓库源码。 + + MoviePilot 运行时会把已安装插件放在 ``app.plugins`` 命名空间;插件源码内部因此可以使用 + 该绝对导入。单测直接加载市场仓源码时需复现同一命名空间,否则可能误用后端目录中的旧副本。 + """ + plugins_package = import_module("app.plugins") + source_path = str(source_dir) + if source_path not in plugins_package.__path__: + plugins_package.__path__.insert(0, source_path) + + def isolate_config_dir() -> str: """隔离 ``CONFIG_DIR`` 到进程私有临时目录(委托主程序共享实现)。""" return _bootstrap.isolate_config_dir() @@ -61,10 +73,12 @@ def prepare_backend() -> None: def prepare_v2_backend() -> None: - """v2 插件单测引导:后端 + 本仓 ``plugins.v2/``(委托主程序共享实现)。""" + """兼容 V3 的 v2 插件单测引导:V3 后端 + 本仓 ``plugins.v2/``。""" _bootstrap.prepare_v2_backend(_PLUGINS_REPO) + _expose_runtime_plugin_namespace(_PLUGINS_REPO / "plugins.v2") -def prepare_v1_backend() -> None: - """v1 插件单测引导:后端 + 本仓 ``plugins/``(委托主程序共享实现,与 v2 互斥)。""" - _bootstrap.prepare_v1_backend(_PLUGINS_REPO) +def prepare_v3_backend() -> None: + """V3 专用插件单测引导:V3 后端 + 本仓 ``plugins.v3/``。""" + _bootstrap.prepare_v3_backend(_PLUGINS_REPO) + _expose_runtime_plugin_namespace(_PLUGINS_REPO / "plugins.v3") diff --git a/tests/ci/test_new_plugin_test_gate.py b/tests/ci/test_new_plugin_test_gate.py index 789bc614..c4f5ceb7 100644 --- a/tests/ci/test_new_plugin_test_gate.py +++ b/tests/ci/test_new_plugin_test_gate.py @@ -1,4 +1,4 @@ -"""新增插件最低测试目录门禁。""" +"""新增 V3 插件最低测试目录门禁。""" from __future__ import annotations @@ -24,7 +24,7 @@ def _init_repo(repo: Path) -> None: def _copy_checker(repo: Path) -> None: - """把当前 checker 拷入临时仓库,便于按真实命令运行。""" + """把当前 checker 拷入临时仓库。""" target = repo / "scripts/check_new_plugin_tests.py" target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(CHECKER, target) @@ -41,11 +41,11 @@ def _run_checker(repo: Path, base_ref: str = "main") -> subprocess.CompletedProc ) -def test_new_v2_plugin_without_tests_is_rejected(tmp_path: Path) -> None: - """当前分支新增插件目录但没有对应测试时必须失败。""" +def test_new_v3_plugin_without_tests_is_rejected(tmp_path: Path) -> None: + """新增 V3 插件没有对应测试时必须失败。""" _init_repo(tmp_path) _copy_checker(tmp_path) - plugin_dir = tmp_path / "plugins.v2/newplugin" + plugin_dir = tmp_path / "plugins.v3/newplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("class NewPlugin:\n pass\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) @@ -53,18 +53,18 @@ def test_new_v2_plugin_without_tests_is_rejected(tmp_path: Path) -> None: result = _run_checker(tmp_path) assert result.returncode == 1 - assert "plugins.v2/newplugin" in result.stdout - assert "tests/v2/newplugin/test_*.py" in result.stdout + assert "plugins.v3/newplugin" in result.stdout + assert "tests/v3/newplugin/test_*.py" in result.stdout -def test_new_v2_plugin_with_test_file_is_accepted(tmp_path: Path) -> None: - """新增插件存在对应 test_*.py 时通过最低测试目录门禁。""" +def test_new_v3_plugin_with_test_file_is_accepted(tmp_path: Path) -> None: + """新增 V3 插件存在对应 test_*.py 时通过。""" _init_repo(tmp_path) _copy_checker(tmp_path) - (tmp_path / "plugins.v2/newplugin").mkdir(parents=True) - (tmp_path / "plugins.v2/newplugin/__init__.py").write_text("class NewPlugin:\n pass\n", encoding="utf-8") - (tmp_path / "tests/v2/newplugin").mkdir(parents=True) - (tmp_path / "tests/v2/newplugin/test_plugin.py").write_text("def test_smoke():\n assert True\n", encoding="utf-8") + (tmp_path / "plugins.v3/newplugin").mkdir(parents=True) + (tmp_path / "plugins.v3/newplugin/__init__.py").write_text("class NewPlugin:\n pass\n", encoding="utf-8") + (tmp_path / "tests/v3/newplugin").mkdir(parents=True) + (tmp_path / "tests/v3/newplugin/test_plugin.py").write_text("def test_smoke():\n assert True\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) result = _run_checker(tmp_path) @@ -72,54 +72,36 @@ def test_new_v2_plugin_with_test_file_is_accepted(tmp_path: Path) -> None: assert result.returncode == 0, result.stdout + result.stderr -def test_untracked_new_plugin_without_tests_is_rejected(tmp_path: Path) -> None: - """本地未暂存的新插件也应进入 preflight 检查。""" +def test_untracked_new_v3_plugin_without_tests_is_rejected(tmp_path: Path) -> None: + """本地未暂存的新 V3 插件也应进入 preflight 检查。""" _init_repo(tmp_path) _copy_checker(tmp_path) - plugin_dir = tmp_path / "plugins.v2/newplugin" + plugin_dir = tmp_path / "plugins.v3/newplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("class NewPlugin:\n pass\n", encoding="utf-8") result = _run_checker(tmp_path) assert result.returncode == 1 - assert "plugins.v2/newplugin" in result.stdout + assert "plugins.v3/newplugin" in result.stdout -def test_new_v1_plugin_without_tests_is_rejected(tmp_path: Path) -> None: - """v1 插件新增目录也必须提交 tests/v1 下的测试文件。""" +def test_new_legacy_plugin_is_outside_v3_gate(tmp_path: Path) -> None: + """兼容旧实现可继续发布,但新插件统一进入 V3 专用目录。""" _init_repo(tmp_path) _copy_checker(tmp_path) - plugin_dir = tmp_path / "plugins/newplugin" + plugin_dir = tmp_path / "plugins.v2/legacy" plugin_dir.mkdir(parents=True) - (plugin_dir / "__init__.py").write_text("class NewPlugin:\n pass\n", encoding="utf-8") + (plugin_dir / "__init__.py").write_text("class Legacy:\n pass\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) result = _run_checker(tmp_path) - assert result.returncode == 1 - assert "plugins/newplugin" in result.stdout - assert "tests/v1/newplugin/test_*.py" in result.stdout - - -def test_existing_plugin_without_tests_is_not_rejected(tmp_path: Path) -> None: - """base 分支已存在的历史插件不由新增插件门禁追溯补测。""" - _init_repo(tmp_path) - (tmp_path / "plugins.v2/oldplugin").mkdir(parents=True) - (tmp_path / "plugins.v2/oldplugin/__init__.py").write_text("class OldPlugin:\n pass\n", encoding="utf-8") - subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) - subprocess.run(["git", "commit", "-q", "-m", "add old plugin"], cwd=tmp_path, check=True) - subprocess.run(["git", "checkout", "-q", "-b", "feature2"], cwd=tmp_path, check=True) - _copy_checker(tmp_path) - subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) - - result = _run_checker(tmp_path, base_ref="feature") - assert result.returncode == 0, result.stdout + result.stderr def test_pr_workflow_runs_new_plugin_test_gate() -> None: - """PR Required Check 应执行新增插件最低测试目录门禁。""" + """PR Required Check 应执行新增 V3 插件最低测试门禁。""" workflow = PR_WORKFLOW.read_text(encoding="utf-8") assert "name: Check new plugin tests" in workflow @@ -128,9 +110,8 @@ def test_pr_workflow_runs_new_plugin_test_gate() -> None: def test_readme_documents_new_plugin_test_gate() -> None: - """测试说明应明确新增插件先进入最低测试目录门禁,而非直接 A 档覆盖率。""" + """测试说明应明确 V3 新插件最低测试目录。""" readme = (REPO_ROOT / "tests/README.md").read_text(encoding="utf-8") - assert "新增插件最低测试门禁" in readme - assert "tests///test_*.py" in readme - assert "不会自动加入 A 档覆盖率门禁" in readme + assert "新增 V3 插件最低测试门禁" in readme + assert "tests/v3//test_*.py" in readme diff --git a/tests/ci/test_plugin_coverage_gate.py b/tests/ci/test_plugin_coverage_gate.py index 7ec55b1d..44417e9e 100644 --- a/tests/ci/test_plugin_coverage_gate.py +++ b/tests/ci/test_plugin_coverage_gate.py @@ -24,16 +24,13 @@ def _load_coverage_module(): return module -def test_quality_config_targets_subscription_plugins() -> None: - """A 档覆盖率门禁只默认锁定两个已有稳定覆盖基线的订阅插件。""" +def test_quality_config_targets_v3_subscription_plugin() -> None: + """A 档覆盖率门禁只锁定仍支持 V3 的增强订阅插件。""" config = json.loads(QUALITY_CONFIG.read_text(encoding="utf-8")) targets = {(item["generation"], item["plugin"]) for item in config["coverage"]} - assert targets == { - ("v2", "subscribeassistant"), - ("v2", "subscribeassistantenhanced"), - } + assert targets == {("v3", "subscribeassistantenhanced")} for item in config["coverage"]: assert item["line"] == 90 assert item["method"] == 90 @@ -110,10 +107,10 @@ def test_changed_line_coverage_ignores_non_executable_lines() -> None: """新增行覆盖率只统计 coverage 认为可执行的新增/变更语句。""" module = _load_coverage_module() changed_lines = { - "plugins.v2/demo/plugin.py": {1, 2, 3, 4}, + "plugins.v3/demo/plugin.py": {1, 2, 3, 4}, } report_files = { - "plugins.v2/demo/plugin.py": { + "plugins.v3/demo/plugin.py": { "executed_lines": [2], "missing_lines": [4], } @@ -136,7 +133,7 @@ def fake_run(*_args, **_kwargs): monkeypatch.setattr(module.subprocess, "run", fake_run) try: - module.collect_changed_lines("origin/missing", ["plugins.v2/demo"]) + module.collect_changed_lines("origin/missing", ["plugins.v3/demo"]) except RuntimeError as err: assert "无法计算新增行覆盖率" in str(err) else: diff --git a/tests/ci/test_plugin_dependency_constraints.py b/tests/ci/test_plugin_dependency_constraints.py index bcdf0476..0bb20ec2 100644 --- a/tests/ci/test_plugin_dependency_constraints.py +++ b/tests/ci/test_plugin_dependency_constraints.py @@ -47,10 +47,7 @@ def _plugin_dependency_cases() -> list: """收集所有插件依赖中与主程序根依赖同名的约束。""" backend_versions = _backend_runtime_versions() cases = [] - requirement_files = [ - *sorted((REPO_ROOT / "plugins").glob("*/requirements.txt")), - *sorted((REPO_ROOT / "plugins.v2").glob("*/requirements.txt")), - ] + requirement_files = sorted((REPO_ROOT / "plugins.v3").glob("*/requirements.txt")) for requirements_file in requirement_files: generation = requirements_file.relative_to(REPO_ROOT).parts[0] plugin_id = f"{generation}/{requirements_file.parent.name}" diff --git a/tests/ci/test_plugin_release_directory.py b/tests/ci/test_plugin_release_directory.py index f7b51a51..6817716a 100644 --- a/tests/ci/test_plugin_release_directory.py +++ b/tests/ci/test_plugin_release_directory.py @@ -17,12 +17,23 @@ def _select(tmp_path: Path, package_file: str) -> subprocess.CompletedProcess[st ) -def test_release_directory_keeps_v1_and_v2_assets_separate(tmp_path: Path) -> None: +def test_release_directory_selects_each_generation_source(tmp_path: Path) -> None: (tmp_path / "plugins/example").mkdir(parents=True) (tmp_path / "plugins.v2/example").mkdir(parents=True) + (tmp_path / "plugins.v3/example").mkdir(parents=True) assert _select(tmp_path, "package.json").stdout.strip() == "plugins/example" assert _select(tmp_path, "package.v2.json").stdout.strip() == "plugins.v2/example" + assert _select(tmp_path, "package.v3.json").stdout.strip() == "plugins.v3/example" + + +def test_v3_release_directory_does_not_fall_back_to_v2_source(tmp_path: Path) -> None: + (tmp_path / "plugins.v2/example").mkdir(parents=True) + + result = _select(tmp_path, "package.v3.json") + + assert result.returncode != 0 + assert result.stdout == "" def test_v2_release_directory_does_not_fall_back_to_v1_source(tmp_path: Path) -> None: @@ -45,6 +56,12 @@ def test_release_workflow_uses_generation_aware_directory_selector() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert 'select_plugin_release_dir.sh "$pkg_file" "$plugin_id_lc"' in workflow + assert 'process_package "package.json"' in workflow + assert 'process_package "package.v2.json"' in workflow + assert 'process_package "package.v3.json"' in workflow + assert ".value.v3 == true or .value.v2 == true" in workflow + assert ".value.release == true and .value.v3 != false" in workflow + assert "Missing plugin directory" in workflow assert 'git rev-parse -q --verify "refs/tags/$tag"' in workflow assert 'prev_tag="$tag"' in workflow assert 'if [ -d "$dir2" ]; then plugin_dir="$dir2"; fi' not in workflow diff --git a/tests/ci/test_plugin_release_gate.py b/tests/ci/test_plugin_release_gate.py index 0086b514..f4cf7912 100644 --- a/tests/ci/test_plugin_release_gate.py +++ b/tests/ci/test_plugin_release_gate.py @@ -1,4 +1,4 @@ -"""验证插件版本校验在本地 push、PR 和 Release 三个入口保持一致。""" +"""验证面向 V3 的版本校验在本地 push、PR 和 Release 三个入口保持一致。""" from __future__ import annotations @@ -15,16 +15,38 @@ TEST_RUNNER = REPO_ROOT / "tests/run.py" -def _write_fixture(repo: Path, package_version: str, source_version: str) -> None: - """构造最小 v2 插件仓,隔离验证 checker 与 Hook 的退出码。""" - plugin_dir = repo / "plugins.v2/example" +def _write_fixture( + repo: Path, + *, + old_version: str = "1.2.3", + package_version: str = "1.3", + source_version: str = "1.3", + legacy_v3: bool = False, + history: dict | None = None, +) -> None: + """构造最小 V3 插件仓,隔离验证 checker 与 Hook 的退出码。""" + plugin_dir = repo / "plugins.v3/example" plugin_dir.mkdir(parents=True) - (repo / "package.json").write_text("{}\n", encoding="utf-8") (repo / "package.v2.json").write_text( json.dumps( { "Example": { + "name": "示例", + "version": old_version, + "v3": legacy_v3, + } + } + ), + encoding="utf-8", + ) + (repo / "package.v3.json").write_text( + json.dumps( + { + "Example": { + "name": "示例", "version": package_version, + "system_version": ">=3.0.0", + "history": history or {f"v{package_version}": "MoviePilot V3 版本示例插件"}, "release": True, } } @@ -41,12 +63,10 @@ def _write_fixture(repo: Path, package_version: str, source_version: str) -> Non shutil.copy2(CHECKER, checker_target) -def _run_checker(repo: Path, *package_files: Path | str) -> subprocess.CompletedProcess[str]: +def _run_checker(repo: Path, package_file: Path | str = "package.v3.json") -> subprocess.CompletedProcess[str]: """从指定目录运行 checker,便于覆盖 cwd 与 package 路径组合。""" - args = ["python3", str(CHECKER)] - args.extend(str(package_file) for package_file in package_files) return subprocess.run( - args, + ["python3", str(CHECKER), str(package_file)], cwd=repo, text=True, capture_output=True, @@ -54,11 +74,37 @@ def _run_checker(repo: Path, *package_files: Path | str) -> subprocess.Completed ) +def _write_legacy_fixture( + repo: Path, + *, + generation: str = "v2", + package_version: str = "1.2", + source_version: str = "1.2", + v3: bool | None = None, +) -> Path: + """构造一个可切换 V3 兼容位的旧代发布条目。""" + package_name = "package.v2.json" if generation == "v2" else "package.json" + source_base = "plugins.v2" if generation == "v2" else "plugins" + metadata = {"version": package_version, "release": True} + if v3 is not None: + metadata["v3"] = v3 + package_path = repo / package_name + package_path.write_text(json.dumps({"Example": metadata}), encoding="utf-8") + plugin_dir = repo / source_base / "example" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "class Example:\n" + f' plugin_version = "{source_version}"\n', + encoding="utf-8", + ) + return package_path + + def test_checker_rejects_mismatched_versions(tmp_path: Path) -> None: - """package 与源码版本不一致时必须返回失败,防止错误资产进入发布流程。""" - _write_fixture(tmp_path, package_version="2.0.0", source_version="1.0.0") + """V3 package 与源码版本不一致时必须失败。""" + _write_fixture(tmp_path, package_version="1.3", source_version="1.4") - result = _run_checker(tmp_path, "package.json", "package.v2.json") + result = _run_checker(tmp_path) assert result.returncode == 1 assert "版本不一致" in result.stdout @@ -67,69 +113,161 @@ def test_checker_rejects_mismatched_versions(tmp_path: Path) -> None: def test_checker_resolves_plugin_dir_relative_to_package_file(tmp_path: Path) -> None: """从其他 cwd 调用时,插件目录应相对 package 文件定位。""" repo = tmp_path / "repo" - _write_fixture(repo, package_version="2.0.0", source_version="2.0.0") + _write_fixture(repo) - result = _run_checker(tmp_path, repo / "package.json", repo / "package.v2.json") + result = _run_checker(tmp_path, repo / "package.v3.json") assert result.returncode == 0, result.stdout + result.stderr -def test_checker_reports_missing_release_plugin_dir(tmp_path: Path) -> None: - """release=true 的插件缺少源码目录时应失败,避免发布项被静默跳过。""" - repo = tmp_path / "repo" - (repo / ".github/scripts").mkdir(parents=True) - shutil.copy2(CHECKER, repo / ".github/scripts/check_plugin_versions.py") - (repo / "package.json").write_text("{}\n", encoding="utf-8") - (repo / "package.v2.json").write_text( - json.dumps({"MissingPlugin": {"version": "1.0.0", "release": True}}), - encoding="utf-8", - ) +def test_checker_reports_missing_v3_plugin_dir(tmp_path: Path) -> None: + """V3 索引插件缺少源码目录时应失败,避免发布项被静默跳过。""" + _write_fixture(tmp_path) + shutil.rmtree(tmp_path / "plugins.v3/example") - result = _run_checker(repo, "package.json", "package.v2.json") + result = _run_checker(tmp_path) assert result.returncode == 1 assert "缺少插件目录" in result.stdout - assert "plugins.v2/missingplugin" in result.stdout + assert "plugins.v3/example" in result.stdout def test_checker_reads_class_level_plugin_version_only(tmp_path: Path) -> None: - """只接受类级 plugin_version,避免函数内局部变量被误识别为插件版本。""" - repo = tmp_path / "repo" - _write_fixture(repo, package_version="1.2.3", source_version="0.0.0") - init_file = repo / "plugins.v2/example/__init__.py" - init_file.write_text( + """只接受类级 plugin_version,避免函数内局部变量被误识别。""" + _write_fixture(tmp_path) + (tmp_path / "plugins.v3/example/__init__.py").write_text( "def helper():\n" - " plugin_version = '1.2.3'\n" + " plugin_version = '1.3'\n" " return plugin_version\n", encoding="utf-8", ) - result = _run_checker(repo, "package.json", "package.v2.json") + result = _run_checker(tmp_path) assert result.returncode == 1 - assert "未在" in result.stdout assert "类级 plugin_version" in result.stdout def test_checker_accepts_annotated_class_level_plugin_version(tmp_path: Path) -> None: - """类级注解赋值的 plugin_version 也是有效插件版本声明。""" - repo = tmp_path / "repo" - _write_fixture(repo, package_version="1.2.3", source_version="0.0.0") - init_file = repo / "plugins.v2/example/__init__.py" - init_file.write_text( + """类级注解赋值的 plugin_version 也是有效声明。""" + _write_fixture(tmp_path) + (tmp_path / "plugins.v3/example/__init__.py").write_text( "class Example:\n" - " plugin_version: str = '1.2.3'\n", + " plugin_version: str = '1.3'\n", encoding="utf-8", ) - result = _run_checker(repo, "package.json", "package.v2.json") + result = _run_checker(tmp_path) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_checker_accepts_minor_version_increase() -> None: + """V3 沿用主版本并提升小版本时通过。""" + result = _run_checker(REPO_ROOT) + + assert result.returncode == 0, result.stdout + + +def test_checker_validates_compatible_legacy_release(tmp_path: Path) -> None: + """未声明 v3=false 的旧实现仍可发布,版本不一致必须失败。""" + package_path = _write_legacy_fixture(tmp_path, source_version="1.3") + + result = _run_checker(tmp_path, package_path) + + assert result.returncode == 1 + assert "版本不一致" in result.stdout + + +def test_checker_validates_default_release_opted_into_v2(tmp_path: Path) -> None: + """默认索引只有显式兼容 V2/V3 后才属于 V3 发布门禁。""" + package_path = _write_legacy_fixture( + tmp_path, + generation="v1", + source_version="1.3", + ) + metadata = json.loads(package_path.read_text(encoding="utf-8")) + metadata["Example"]["v2"] = True + package_path.write_text(json.dumps(metadata), encoding="utf-8") + + result = _run_checker(tmp_path, package_path) + + assert result.returncode == 1 + assert "版本不一致" in result.stdout + + +def test_checker_skips_default_release_without_v2_or_v3_opt_in(tmp_path: Path) -> None: + """普通 V1 条目不能仅因未声明 v3=false 就进入 V3 发布门禁。""" + package_path = _write_legacy_fixture( + tmp_path, + generation="v1", + source_version="9.9", + ) + + result = _run_checker(tmp_path, package_path) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_checker_skips_legacy_release_blocked_on_v3(tmp_path: Path) -> None: + """v3=false 的旧实现只保留历史索引,不再参与 V3 发布门禁。""" + package_path = _write_legacy_fixture(tmp_path, source_version="9.9", v3=False) + + result = _run_checker(tmp_path, package_path) assert result.returncode == 0, result.stdout + result.stderr +def test_default_index_plugins_all_have_a_v2_compatibility_path() -> None: + """每个默认索引条目都必须有 V2 专用实现或显式声明 v2=true。""" + default_package = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) + v2_package = json.loads((REPO_ROOT / "package.v2.json").read_text(encoding="utf-8")) + + missing = sorted( + plugin_id + for plugin_id, metadata in default_package.items() + if metadata.get("v2") is not True and plugin_id not in v2_package + ) + + assert missing == [] + + +def test_checker_rejects_major_version_increase(tmp_path: Path) -> None: + """V3 迁移绝不能提升主版本。""" + _write_fixture(tmp_path, old_version="1.2.3", package_version="2.0", source_version="2.0") + + result = _run_checker(tmp_path) + + assert result.returncode == 1 + assert "不得提升主版本" in result.stdout + + +def test_checker_requires_legacy_v3_block(tmp_path: Path) -> None: + """存在专用 V3 副本时旧索引必须阻止回退加载。""" + _write_fixture(tmp_path, legacy_v3=True) + + result = _run_checker(tmp_path) + + assert result.returncode == 1 + assert "必须声明 v3=false" in result.stdout + + +def test_checker_requires_single_current_history_entry(tmp_path: Path) -> None: + """V3 history 只允许当前版本一条标准迁移说明。""" + _write_fixture( + tmp_path, + history={"v1.3": "MoviePilot V3 版本示例插件", "v1.2.3": "旧记录"}, + ) + + result = _run_checker(tmp_path) + + assert result.returncode == 1 + assert "history 必须只保留当前版本" in result.stdout + + def test_pre_push_propagates_version_gate_failure(tmp_path: Path) -> None: - """pre-push 必须传播 checker 非零状态,确保 git push 在上传前被拒绝。""" - _write_fixture(tmp_path, package_version="2.0.0", source_version="1.0.0") + """pre-push 必须传播 checker 非零状态。""" + _write_fixture(tmp_path, package_version="1.3", source_version="1.4") hook_target = tmp_path / ".githooks/pre-push" hook_target.parent.mkdir(parents=True) shutil.copy2(PRE_PUSH, hook_target) @@ -148,8 +286,8 @@ def test_pre_push_propagates_version_gate_failure(tmp_path: Path) -> None: def test_pre_push_accepts_matching_versions(tmp_path: Path) -> None: - """版本一致时 pre-push 应允许上传,避免正常插件发布被误拦截。""" - _write_fixture(tmp_path, package_version="2.0.0", source_version="2.0.0") + """V3 元数据与源码一致时 pre-push 应允许上传。""" + _write_fixture(tmp_path) hook_target = tmp_path / ".githooks/pre-push" hook_target.parent.mkdir(parents=True) shutil.copy2(PRE_PUSH, hook_target) @@ -167,8 +305,8 @@ def test_pre_push_accepts_matching_versions(tmp_path: Path) -> None: assert "插件版本门禁通过" in result.stdout -def test_pr_workflow_runs_gate_for_every_main_pull_request() -> None: - """Required Check 不得使用 paths 过滤,否则部分 PR 会一直缺少强制状态。""" +def test_pr_workflow_runs_v3_gates_for_every_main_pull_request() -> None: + """Required Check 必须覆盖 V3 版本与真实测试,且不得使用 paths 过滤。""" workflow = PR_WORKFLOW.read_text(encoding="utf-8") assert "pull_request:" in workflow @@ -176,24 +314,15 @@ def test_pr_workflow_runs_gate_for_every_main_pull_request() -> None: assert "- main" in workflow assert "paths:" not in workflow assert "name: Plugin release gate" in workflow - assert "python .github/scripts/check_plugin_versions.py package.json package.v2.json" in workflow - - -def test_current_repository_passes_version_gate() -> None: - """启用 Ruleset 前真实 main 基线必须通过,否则所有 PR 都无法合并。""" - result = subprocess.run( - ["python3", str(CHECKER), "package.json", "package.v2.json"], - cwd=REPO_ROOT, - text=True, - capture_output=True, - check=False, - ) - - assert result.returncode == 0, result.stdout + assert "python .github/scripts/check_plugin_versions.py package.json package.v2.json package.v3.json" in workflow + assert "name: Plugin test gate" in workflow + assert "ref: v3" in workflow + assert "python tests/run.py" in workflow -def test_full_test_runner_includes_ci_gate_tests() -> None: - """push 前全量入口必须执行 CI 工具测试,防止门禁实现脱离常规回归。""" +def test_full_test_runner_includes_v3_and_compatible_v2_tests() -> None: + """全量入口执行 CI、V3 专用实现和动态筛选的兼容 v2 实现。""" runner = TEST_RUNNER.read_text(encoding="utf-8") - assert 'for generation in ("ci", "v2", "v1"):' in runner + assert 'for generation in ("ci", "v3", "v2"):' in runner + assert "compatible_v2_test_targets" in runner diff --git a/tests/ci/test_v3_test_selection.py b/tests/ci/test_v3_test_selection.py new file mode 100644 index 00000000..64331af4 --- /dev/null +++ b/tests/ci/test_v3_test_selection.py @@ -0,0 +1,52 @@ +"""V3 测试入口的代际与兼容插件筛选合同。""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tests.run import compatible_v2_test_targets + + +def _write_test(tests_dir: Path, plugin_id: str) -> None: + """创建一个最小插件测试目录。""" + plugin_dir = tests_dir / "v2" / plugin_id + plugin_dir.mkdir(parents=True) + (plugin_dir / "test_plugin.py").write_text("def test_smoke():\n assert True\n", encoding="utf-8") + + +def _write_package(path: Path, package: dict) -> None: + """写入最小 v2 插件索引。""" + path.write_text(json.dumps(package), encoding="utf-8") + + +def test_v2_selection_uses_manifest_compatibility_flag(tmp_path: Path) -> None: + """未阻断的旧实现自动进入 V3 回归,显式不兼容项自动排除。""" + tests_dir = tmp_path / "tests" + _write_test(tests_dir, "compatible") + _write_test(tests_dir, "blocked") + package_path = tmp_path / "package.v2.json" + _write_package( + package_path, + { + "Compatible": {"version": "1.0.0"}, + "Blocked": {"version": "1.0.0", "v3": False}, + }, + ) + + targets = compatible_v2_test_targets(tests_dir=tests_dir, package_path=package_path) + + assert [target.name for target in targets] == ["compatible"] + + +def test_v2_selection_rejects_test_directory_missing_from_manifest(tmp_path: Path) -> None: + """测试目录无法映射索引时失败关闭,避免兼容范围脱离市场元数据。""" + tests_dir = tmp_path / "tests" + _write_test(tests_dir, "orphan") + package_path = tmp_path / "package.v2.json" + _write_package(package_path, {}) + + with pytest.raises(RuntimeError, match="没有对应插件条目"): + compatible_v2_test_targets(tests_dir=tests_dir, package_path=package_path) diff --git a/tests/conftest.py b/tests/conftest.py index 0499fb5c..53315b3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ -"""pytest 全局引导:按目标选择插件代际,CI 工具测试不加载后端。 +"""pytest 全局引导:按目标选择 V3 实现或兼容 V3 的 v2 实现。 -``tests/run.py`` 会把 v1/v2 放到独立 pytest 进程中运行;这里据本次目标路径只注入对应 -插件目录,避免同一进程同时加载 ``plugins`` 与 ``plugins.v2`` 的同名包。``tests/ci`` -只校验仓库工具和 workflow,不需要 MoviePilot 运行时。 +``tests/run.py`` 会把 v2/v3 放到独立 pytest 进程中运行;这里据本次目标路径只注入对应 +插件目录,避免同一进程同时加载 ``plugins.v2`` 与 ``plugins.v3`` 的同名包。``tests/ci`` +只校验仓库工具和 workflow,不需要 MoviePilot 运行时。两代插件测试都使用 V3 后端。 """ from __future__ import annotations @@ -13,26 +13,26 @@ # 相对导入本仓薄壳,先定位同级 MoviePilot 后端并加入 ``sys.path``,再复用主程序共享引导。 from ._bootstrap import ( block_real_network, # noqa: F401 导入即注册主程序共享 autouse 网络守卫 - prepare_v1_backend, prepare_v2_backend, + prepare_v3_backend, ) def _selected_generation(config) -> str: - """根据 pytest 本次目标路径判断插件代际,禁止同一进程混跑 v1/v2。""" + """根据 pytest 本次目标路径判断插件代际,禁止同一进程混跑 v2/v3。""" generations = set() for arg in config.args: file_part = arg.split("::", 1)[0] path = Path(file_part).resolve().as_posix().replace("\\", "/") - if "tests/v2" in path: + if "tests/v3" in path: + generations.add("v3") + elif "tests/v2" in path: generations.add("v2") - elif "tests/v1" in path: - generations.add("v1") elif "tests/ci" in path: generations.add("ci") if len(generations) == 1: return next(iter(generations)) - raise RuntimeError("插件仓单测必须按 tests/run.py 分 v1/v2 独立会话运行,避免同名插件包冲突") + raise RuntimeError("插件仓单测必须按 tests/run.py 分 v2/v3 独立会话运行,避免同名插件包冲突") def pytest_configure(config) -> None: @@ -40,10 +40,10 @@ def pytest_configure(config) -> None: generation = _selected_generation(config) if generation == "ci": return - if generation == "v2": - prepare_v2_backend() - else: - prepare_v1_backend() + if generation == "v3": + prepare_v3_backend() + return + prepare_v2_backend() def _report_session_cleanup_error(session, name: str, err: Exception) -> None: diff --git a/tests/run.py b/tests/run.py index 03bb661a..a4db0d1a 100644 --- a/tests/run.py +++ b/tests/run.py @@ -1,9 +1,10 @@ -"""插件仓全量单测入口:CI 工具与 v1/v2 分别运行,命令行参数透传给 pytest。 +"""插件仓 V3 单测入口:CI 工具、V3 专用实现与兼容的 v2 实现分别运行。 -plugins/(v1)与 plugins.v2/(v2)存在同名插件包,同一进程无法同时加载,故各代在 -独立子进程运行;CI 工具测试不加载插件运行时。任一组非零退出码即整体失败,无用例的 -分组直接跳过。路径以 __file__ 推导,从任意目录调用均可。 +``package.v2.json`` 是旧实现能否在 V3 加载的唯一事实源:存在测试且未声明 ``v3: false`` +的 v2 插件继续使用 V3 后端回归。它们与 ``plugins.v3`` 的专用实现放在独立子进程中, +避免同名插件包冲突。CI 工具测试不加载插件运行时。任一组非零退出码即整体失败。 """ +import json import subprocess import sys from pathlib import Path @@ -11,15 +12,53 @@ # 本文件位于 tests/ 下:其父为 tests 目录,再上一级为插件仓根 _TESTS_DIR = Path(__file__).resolve().parent _REPO_ROOT = _TESTS_DIR.parent +_V2_PACKAGE = _REPO_ROOT / "package.v2.json" -def _run_generation(generation: str, extra_args: list) -> int: - """在独立子进程运行某一代(v1/v2)的全部用例;该代无用例则跳过、返回 0。""" +def _contains_tests(path: Path) -> bool: + """判断目录中是否存在 pytest 用例文件。""" + return path.is_dir() and any(path.rglob("test_*.py")) + + +def compatible_v2_test_targets( + tests_dir: Path = _TESTS_DIR, + package_path: Path = _V2_PACKAGE, +) -> list[Path]: + """按 v2 索引动态收集仍兼容 V3 的插件测试目录。 + + 测试目录必须能大小写无关地映射到索引插件 ID;缺少索引时失败关闭,避免新测试因 + 元数据遗漏而被错误纳入或排除。只有显式 ``v3: false`` 的插件会被跳过。 + """ + package = json.loads(package_path.read_text(encoding="utf-8")) + metadata_by_id = {plugin_id.casefold(): metadata for plugin_id, metadata in package.items()} + targets = [] + for test_dir in sorted((tests_dir / "v2").iterdir()): + if not _contains_tests(test_dir): + continue + metadata = metadata_by_id.get(test_dir.name.casefold()) + if metadata is None: + raise RuntimeError(f"tests/v2/{test_dir.name} 在 package.v2.json 中没有对应插件条目") + if metadata.get("v3") is False: + continue + targets.append(test_dir) + return targets + + +def _generation_targets(generation: str) -> list[Path]: + """返回某个独立 pytest 会话的测试目标。""" + if generation == "v2": + return compatible_v2_test_targets() target = _TESTS_DIR / generation - if not list(target.rglob("test_*.py")): + return [target] if _contains_tests(target) else [] + + +def _run_generation(generation: str, extra_args: list) -> int: + """在独立子进程运行一个测试分组;该组无用例则跳过。""" + targets = _generation_targets(generation) + if not targets: return 0 return subprocess.call( - [sys.executable, "-m", "pytest", str(target), *extra_args], + [sys.executable, "-m", "pytest", *(str(target) for target in targets), *extra_args], cwd=str(_REPO_ROOT), ) @@ -27,8 +66,8 @@ def _run_generation(generation: str, extra_args: list) -> int: if __name__ == "__main__": extra = sys.argv[1:] exit_code = 0 - # CI 工具与 v1/v2 分会话运行;保留首个非零退出码作为整体结果。 - for generation in ("ci", "v2", "v1"): + # CI 工具、V3 专用实现与兼容 V3 的旧实现分会话运行。 + for generation in ("ci", "v3", "v2"): rc = _run_generation(generation, extra) exit_code = exit_code or rc sys.exit(exit_code) diff --git a/tests/v2/brushflowlowfreq/test_torrent_info.py b/tests/v2/brushflowlowfreq/test_torrent_info.py deleted file mode 100644 index 4b46dbff..00000000 --- a/tests/v2/brushflowlowfreq/test_torrent_info.py +++ /dev/null @@ -1,45 +0,0 @@ -"""BrushFlowLowFreq 种子信息映射测试。""" -from pathlib import Path -from unittest.mock import PropertyMock, patch - -from brushflowlowfreq import BrushFlowLowFreq -from ..torrent_sdk_fixtures import force_transmission_plugin, make_tr_legacy_torrent, make_tr_v7_torrent - - -def _call(torrent): - plugin = force_transmission_plugin(object.__new__(BrushFlowLowFreq)) - with patch.object(BrushFlowLowFreq, "service_info", new_callable=PropertyMock, return_value=object()): - return plugin._BrushFlowLowFreq__get_torrent_info(torrent) - - -class TestTransmissionTorrentInfo: - """TR 新旧 SDK 字段都应可转换为刷流统计信息。""" - - def test_transmission_rpc_v7_fields(self): - info = _call(make_tr_v7_torrent()) - - assert info["hash"] == "tr_hash_1" - assert info["seeding_time"] > 0 - assert info["dltime"] > 0 - assert info["iatime"] > 0 - assert info["add_on"] == 900 - assert info["tags"] == ["tag1"] - assert info["tracker"] == "https://tracker/announce" - - def test_legacy_transmission_fields(self): - info = _call(make_tr_legacy_torrent()) - - assert info["hash"] == "tr_hash_1" - assert info["seeding_time"] > 0 - assert info["dltime"] > 0 - assert info["iatime"] > 0 - assert info["add_on"] == 900 - assert info["tags"] == ["tag1"] - assert info["tracker"] == "https://tracker/announce" - - def test_v1_source_keeps_original_transmission_fields(self): - """v2 已有插件不改 v1,#258 带入的 v1 字段替换应回滚。""" - source = (Path(__file__).resolve().parents[3] / "plugins" / "brushflowlowfreq" / "__init__.py").read_text() - - assert "torrent.done_date" not in source - assert "torrent.date_done" in source diff --git a/tests/v2/plexmatch/test_transfer_history_query.py b/tests/v2/plexmatch/test_transfer_history_query.py deleted file mode 100644 index 5da438a1..00000000 --- a/tests/v2/plexmatch/test_transfer_history_query.py +++ /dev/null @@ -1,72 +0,0 @@ -from sqlalchemy import Boolean, Integer, column, table -from sqlalchemy.dialects import postgresql, sqlite -from sqlalchemy.orm import Session - -import plexmatch - - -_TRANSFER_HISTORY = table( - "transferhistory", - column("tmdbid", Integer), - column("status", Boolean), -) - - -class _V2153TransferHistory: - """保留 MoviePilot v2.15.3 中查询依赖的整理记录字段契约。""" - - tmdbid = _TRANSFER_HISTORY.c.tmdbid - status = _TRANSFER_HISTORY.c.status - - -class _RecordingQuery: - """记录插件提交给 ORM 的筛选条件,不访问真实数据库。""" - - def __init__(self) -> None: - self.criterion = None - - def filter(self, criterion): - self.criterion = criterion - return self - - def all(self) -> list: - return [] - - -class _RecordingSession(Session): - """提供满足 db_query 契约的 Session,并暴露生成的查询条件。""" - - def __init__(self) -> None: - super().__init__() - self.recorded_query = _RecordingQuery() - - def query(self, *entities, **kwargs): - return self.recorded_query - - -def _compile_history_filter(monkeypatch, dialect) -> str: - monkeypatch.setattr(plexmatch, "TransferHistory", _V2153TransferHistory) - session = _RecordingSession() - try: - plexmatch.PlexMatch._PlexMatch__list_transfer_histories(db=session) - criterion = session.recorded_query.criterion - assert criterion is not None - return str(criterion.compile(dialect=dialect, compile_kwargs={"literal_binds": True})) - finally: - session.close() - - -def test_history_filter_uses_numeric_comparison_on_postgresql(monkeypatch) -> None: - sql = _compile_history_filter(monkeypatch, postgresql.dialect()) - - assert "tmdbid IS NOT NULL" in sql - assert "tmdbid != 0" in sql - assert "tmdbid IS NOT 0" not in sql - - -def test_history_filter_keeps_sqlite_compatibility(monkeypatch) -> None: - sql = _compile_history_filter(monkeypatch, sqlite.dialect()) - - assert "tmdbid IS NOT NULL" in sql - assert "tmdbid != 0" in sql - assert "tmdbid IS NOT 0" not in sql diff --git a/tests/v3/brushflowlowfreq/test_torrent_info.py b/tests/v3/brushflowlowfreq/test_torrent_info.py new file mode 100644 index 00000000..4eaff210 --- /dev/null +++ b/tests/v3/brushflowlowfreq/test_torrent_info.py @@ -0,0 +1,96 @@ +"""BrushFlowLowFreq 种子信息与媒体识别合同测试。""" +from types import SimpleNamespace +from unittest.mock import MagicMock, PropertyMock, patch + +from brushflowlowfreq import BrushFlowLowFreq +from app.schemas.types import MediaSource, MediaType +from .torrent_sdk_fixtures import force_transmission_plugin, make_tr_legacy_torrent, make_tr_v7_torrent + + +def _call(torrent): + plugin = force_transmission_plugin(object.__new__(BrushFlowLowFreq)) + with patch.object(BrushFlowLowFreq, "service_info", new_callable=PropertyMock, return_value=object()): + return plugin._BrushFlowLowFreq__get_torrent_info(torrent) + + +class TestTransmissionTorrentInfo: + """TR 新旧 SDK 字段都应可转换为刷流统计信息。""" + + def test_transmission_rpc_v7_fields(self): + info = _call(make_tr_v7_torrent()) + + assert info["hash"] == "tr_hash_1" + assert info["seeding_time"] > 0 + assert info["dltime"] > 0 + assert info["iatime"] > 0 + assert info["add_on"] == 900 + assert info["tags"] == ["tag1"] + assert info["tracker"] == "https://tracker/announce" + + def test_legacy_transmission_fields(self): + info = _call(make_tr_legacy_torrent()) + + assert info["hash"] == "tr_hash_1" + assert info["seeding_time"] > 0 + assert info["dltime"] > 0 + assert info["iatime"] > 0 + assert info["add_on"] == 900 + assert info["tags"] == ["tag1"] + assert info["tracker"] == "https://tracker/announce" + +def test_subscribe_recognition_uses_v3_media_identity_contract(): + """订阅识别必须以来源和原生 ID 成对调用 V3 媒体识别合同。""" + plugin = object.__new__(BrushFlowLowFreq) + plugin._brush_config = SimpleNamespace(except_subscribe=True) + plugin._subscribe_infos = {} + plugin.subscribe_oper = MagicMock() + plugin.subscribe_oper.list.return_value = [ + SimpleNamespace( + id=1, + name="测试电影", + year="2026", + season=None, + type="电影", + media_source=MediaSource.TMDB, + media_id="12345", + ) + ] + plugin.chain = MagicMock() + plugin.chain.recognize_media.return_value = SimpleNamespace( + names=["Test Movie"], + to_dict=lambda: {}, + ) + + titles = plugin._BrushFlowLowFreq__get_subscribe_titles() + + assert titles == {"测试电影", "Test Movie"} + kwargs = plugin.chain.recognize_media.call_args.kwargs + assert kwargs["media_source"] == MediaSource.TMDB + assert kwargs["media_id"] == "12345" + assert "tmdbid" not in kwargs + assert "doubanid" not in kwargs + + +def test_subscribe_recognition_ignores_non_video_media(): + """旧实现复用于 V3 时仍只处理电影和电视剧订阅。""" + plugin = object.__new__(BrushFlowLowFreq) + plugin._brush_config = SimpleNamespace(except_subscribe=True) + plugin._subscribe_infos = {} + plugin.subscribe_oper = MagicMock() + plugin.subscribe_oper.list.return_value = [ + SimpleNamespace( + id=2, + name="测试音乐", + type=MediaType.MUSIC.value, + ) + ] + plugin.chain = MagicMock() + + titles = plugin._BrushFlowLowFreq__get_subscribe_titles() + + assert titles == set() + plugin.chain.recognize_media.assert_not_called() + + +def test_v3_plugin_version_increments_minor_version(): + assert BrushFlowLowFreq.plugin_version == "4.5" diff --git a/tests/v3/brushflowlowfreq/torrent_sdk_fixtures.py b/tests/v3/brushflowlowfreq/torrent_sdk_fixtures.py new file mode 100644 index 00000000..f58628e5 --- /dev/null +++ b/tests/v3/brushflowlowfreq/torrent_sdk_fixtures.py @@ -0,0 +1,70 @@ +"""BrushFlowLowFreq 下载器 SDK 字段测试夹具。""" +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +from transmission_rpc import Torrent + + +def make_tr_v7_torrent(**overrides): + """构造 transmission-rpc 7.x 的真实 Torrent 对象。""" + fields = { + "id": 1, + "name": "TR.Test", + "hashString": "tr_hash_1", + "doneDate": 1000, + "addedDate": 900, + "activityDate": 1100, + "totalSize": 4096000, + "sizeWhenDone": 4096000, + "percentDone": 1.0, + "downloadedEver": 4096000, + "uploadedEver": 8192000, + "uploadRatio": 2.0, + "secondsDownloading": 100, + "secondsSeeding": 200, + "rateUpload": 300, + "status": 6, + "labels": ["tag1"], + "trackers": [{"announce": "https://tracker/announce"}], + "trackerStats": [ + {"tier": 0, "lastAnnounceResult": "OK"}, + {"tier": -1, "lastAnnounceResult": "SKIP"}, + ], + } + fields.update(overrides) + return Torrent(fields=fields) + + +def make_tr_legacy_torrent(**overrides): + """构造旧 transmission-rpc 风格的 Torrent 替身。""" + torrent = SimpleNamespace( + hashString="tr_hash_1", + name="TR.Test", + date_done=datetime.fromtimestamp(1000, timezone.utc), + date_added=datetime.fromtimestamp(900, timezone.utc), + date_active=datetime.fromtimestamp(1100, timezone.utc), + total_size=4096000, + progress=100, + ratio=2.0, + status="seeding", + labels=["tag1"], + trackers=[SimpleNamespace(announce="https://tracker/announce")], + tracker_stats=[ + SimpleNamespace(tier=0, last_announce_result="OK"), + SimpleNamespace(tier=-1, last_announce_result="SKIP"), + ], + fields={"size_when_done": 4096000}, + **overrides, + ) + torrent.get = lambda key, default=None: getattr(torrent, key, default) + if not hasattr(torrent, "size_when_done"): + torrent.size_when_done = torrent.fields.get("size_when_done", torrent.total_size) + return torrent + + +def force_transmission_plugin(plugin): + """将绕过初始化的插件实例固定到 Transmission 分支。""" + plugin.downloader_helper = MagicMock() + plugin.downloader_helper.is_downloader.return_value = False + return plugin diff --git a/tests/v3/plexedition/test_transfer_history_query.py b/tests/v3/plexedition/test_transfer_history_query.py new file mode 100644 index 00000000..2bd86767 --- /dev/null +++ b/tests/v3/plexedition/test_transfer_history_query.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, call + +import plexedition +from app.core.event import Event +from app.schemas.types import EventType, MediaSource, MediaType + + +def test_process_item_queries_transfer_history_by_v3_media_identity(monkeypatch) -> None: + """Plex TMDB GUID 应转换为 V3 规范媒体身份后查询整理记录。""" + source_path = "/media/Movies/Fight.Club.1999.mkv" + item = SimpleNamespace( + type="movie", + title="Fight Club", + ratingKey="1", + editionTitle=None, + locations=[source_path], + fields=[], + guids=[SimpleNamespace(id="tmdb://550")], + ) + plugin = object.__new__(plexedition.PlexEdition) + plugin.history_oper = MagicMock() + plugin.history_oper.get_by.return_value = [] + plugin.history_oper.get_by_title.return_value = [] + plugin._lock = False + + monkeypatch.setattr(plexedition, "is_anime", lambda _path: False) + monkeypatch.setattr( + plexedition, + "MetaVideo", + lambda *_args, **_kwargs: SimpleNamespace(edition=None), + ) + + plugin._PlexEdition__process_items(item) + + assert plugin.history_oper.get_by.call_args == call( + media_source=MediaSource.TMDB, + media_id="550", + mtype="电影", + dest=source_path, + ) + + +def test_v3_plugin_version() -> None: + assert plexedition.PlexEdition.plugin_version == "1.3" + + +def test_transfer_event_ignores_non_movie_media() -> None: + """PlexEdition 的事件入口只接受电影,不处理电视剧或音乐。""" + plugin = object.__new__(plexedition.PlexEdition) + plugin._enabled = True + plugin._execute_transfer = True + plugin._scheduler = MagicMock() + event = Event( + EventType.TransferComplete, + { + "mediainfo": SimpleNamespace(type=MediaType.MUSIC), + "meta": SimpleNamespace(), + }, + ) + + plugin.after_transfer(event) + + plugin._scheduler.add_job.assert_not_called() diff --git a/tests/v3/plexmatch/test_transfer_history_query.py b/tests/v3/plexmatch/test_transfer_history_query.py new file mode 100644 index 00000000..82da2b42 --- /dev/null +++ b/tests/v3/plexmatch/test_transfer_history_query.py @@ -0,0 +1,237 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from sqlalchemy import Boolean, String, column, table +from sqlalchemy.dialects import postgresql, sqlite +from sqlalchemy.orm import Session + +import plexmatch +from app.core.event import Event +from app.schemas.types import EventType, MediaSource, MediaType + + +_TRANSFER_HISTORY = table( + "transferhistory", + column("type", String), + column("media_source", String), + column("media_id", String), + column("status", Boolean), +) + + +class _V3TransferHistory: + """提供 MoviePilot V3 整理记录的统一媒体身份字段契约。""" + + type = _TRANSFER_HISTORY.c.type + media_source = _TRANSFER_HISTORY.c.media_source + media_id = _TRANSFER_HISTORY.c.media_id + status = _TRANSFER_HISTORY.c.status + + +class _RecordingQuery: + """记录插件提交给 ORM 的筛选条件,不访问真实数据库。""" + + def __init__(self) -> None: + self.criterion = None + + def filter(self, criterion): + self.criterion = criterion + return self + + def all(self) -> list: + return [] + + +class _RecordingSession(Session): + """提供满足 db_query 契约的 Session,并暴露生成的查询条件。""" + + def __init__(self) -> None: + super().__init__() + self.recorded_query = _RecordingQuery() + + def query(self, *entities, **kwargs): + return self.recorded_query + + +def _compile_history_filter(monkeypatch, dialect) -> str: + monkeypatch.setattr(plexmatch, "TransferHistory", _V3TransferHistory) + session = _RecordingSession() + try: + plexmatch.PlexMatch._PlexMatch__list_transfer_histories(db=session) + criterion = session.recorded_query.criterion + assert criterion is not None + return str(criterion.compile(dialect=dialect, compile_kwargs={"literal_binds": True})) + finally: + session.close() + + +def _make_transfer_event( + media_source: MediaSource, + media_id: str, + media_type: MediaType = MediaType.TV, +) -> Event: + """构造满足 PlexMatch 入库事件契约的最小测试事件。""" + return Event( + EventType.TransferComplete, + { + "mediainfo": SimpleNamespace( + title="测试剧", + title_year="测试剧 (2026)", + type=media_type, + media_source=media_source, + media_id=media_id, + ), + "meta": SimpleNamespace(season_episode="S01E01"), + "transferinfo": SimpleNamespace( + target_item=SimpleNamespace(path="/media/测试剧/Season 1/S01E01.mkv") + ), + }, + ) + + +def _make_plugin() -> plexmatch.PlexMatch: + """绕过主程序服务初始化,仅构造当前插件逻辑所需实例。""" + return object.__new__(plexmatch.PlexMatch) + + +def test_plugin_declares_v3_minor_version() -> None: + assert plexmatch.PlexMatch.plugin_version == "1.4" + + +def test_history_filter_selects_valid_tmdb_identity_on_postgresql(monkeypatch) -> None: + sql = _compile_history_filter(monkeypatch, postgresql.dialect()) + + assert "media_source = 'themoviedb'" in sql + assert "type IN ('电影', '电视剧')" in sql + assert "media_id IS NOT NULL" in sql + assert "media_id != ''" in sql + assert "media_id != '0'" in sql + assert "tmdbid" not in sql + + +def test_history_filter_keeps_sqlite_compatibility(monkeypatch) -> None: + sql = _compile_history_filter(monkeypatch, sqlite.dialect()) + + assert "media_source = 'themoviedb'" in sql + assert "type IN ('电影', '电视剧')" in sql + assert "media_id IS NOT NULL" in sql + assert "media_id != ''" in sql + assert "media_id != '0'" in sql + assert "tmdbid" not in sql + + +def test_history_completion_writes_tmdb_media_id(monkeypatch) -> None: + plugin = _make_plugin() + history = SimpleNamespace( + title="测试剧", + media_source=MediaSource.TMDB.value, + media_id="12345", + dest="/media/测试剧/Season 1/S01E01.mkv", + type=MediaType.TV.value, + ) + monkeypatch.setattr( + plugin, + "_PlexMatch__list_transfer_histories", + Mock(return_value=[history]), + ) + add_plexmatch = Mock(return_value=True) + monkeypatch.setattr(plugin, "_PlexMatch__add_plexmatch_file", add_plexmatch) + + plugin._PlexMatch__complete_by_history() + + add_plexmatch.assert_called_once_with( + title="测试剧", + tmdb_id="12345", + file_path="/media/测试剧/Season 1/S01E01.mkv", + mtype=MediaType.TV, + ) + + +def test_add_plexmatch_file_writes_tmdb_hint(tmp_path) -> None: + plugin = _make_plugin() + plugin._overwrite = False + media_file = tmp_path / "测试电影 (2026)" / "测试电影.mkv" + media_file.parent.mkdir() + media_file.touch() + + created = plugin._PlexMatch__add_plexmatch_file( + title="测试电影", + tmdb_id="12345", + file_path=str(media_file), + mtype=MediaType.MOVIE, + ) + + assert created is True + assert (media_file.parent / ".plexmatch").read_text(encoding="utf-8") == ( + "tmdbid: 12345 #测试电影 TMDB编号" + ) + + +def test_transfer_event_writes_tmdb_media_id(monkeypatch) -> None: + plugin = _make_plugin() + plugin._enabled = True + add_plexmatch = Mock(return_value=True) + monkeypatch.setattr(plugin, "_PlexMatch__add_plexmatch_file", add_plexmatch) + + plugin.execute_transfer(_make_transfer_event(MediaSource.TMDB, "12345")) + + add_plexmatch.assert_called_once_with( + title="测试剧", + tmdb_id="12345", + file_path="/media/测试剧/Season 1/S01E01.mkv", + mtype=MediaType.TV, + ) + + +def test_transfer_event_ignores_non_tmdb_identity(monkeypatch) -> None: + plugin = _make_plugin() + plugin._enabled = True + add_plexmatch = Mock(return_value=True) + monkeypatch.setattr(plugin, "_PlexMatch__add_plexmatch_file", add_plexmatch) + + plugin.execute_transfer(_make_transfer_event(MediaSource.Douban, "12345")) + + add_plexmatch.assert_not_called() + + +def test_transfer_event_ignores_zero_tmdb_id(monkeypatch) -> None: + plugin = _make_plugin() + plugin._enabled = True + add_plexmatch = Mock(return_value=True) + monkeypatch.setattr(plugin, "_PlexMatch__add_plexmatch_file", add_plexmatch) + + plugin.execute_transfer(_make_transfer_event(MediaSource.TMDB, "0")) + + add_plexmatch.assert_not_called() + + +def test_transfer_event_ignores_non_video_media(monkeypatch) -> None: + """PlexMatch 只为电影和电视剧写入匹配文件。""" + plugin = _make_plugin() + plugin._enabled = True + add_plexmatch = Mock(return_value=True) + monkeypatch.setattr(plugin, "_PlexMatch__add_plexmatch_file", add_plexmatch) + + plugin.execute_transfer( + _make_transfer_event(MediaSource.MusicBrainz, "recording-id", MediaType.MUSIC) + ) + + add_plexmatch.assert_not_called() + + +def test_add_plexmatch_file_rejects_non_video_media(tmp_path) -> None: + """文件写入边界自身也拒绝非影视媒体,避免绕过事件入口。""" + plugin = _make_plugin() + plugin._overwrite = False + media_file = tmp_path / "music.flac" + media_file.touch() + + created = plugin._PlexMatch__add_plexmatch_file( + title="测试音乐", + tmdb_id="12345", + file_path=str(media_file), + mtype=MediaType.MUSIC, + ) + + assert created is False + assert not (tmp_path / ".plexmatch").exists() diff --git a/tests/v2/plexpersonmeta/test_cache_scheduler.py b/tests/v3/plexpersonmeta/test_cache_scheduler.py similarity index 69% rename from tests/v2/plexpersonmeta/test_cache_scheduler.py rename to tests/v3/plexpersonmeta/test_cache_scheduler.py index 1a659f1e..b675b9a4 100644 --- a/tests/v2/plexpersonmeta/test_cache_scheduler.py +++ b/tests/v3/plexpersonmeta/test_cache_scheduler.py @@ -1,8 +1,5 @@ """PlexPersonMeta 缓存清理任务的调度与执行测试。""" -import importlib.util -import sys -from pathlib import Path from types import ModuleType from unittest.mock import MagicMock, call, patch @@ -10,22 +7,11 @@ from app.testing import stub_modules -def _load_source_module(): - """直接加载插件源码,避免测试误用后端运行时副本。""" - source_path = Path(__file__).parents[3] / "plugins.v2" / "plexpersonmeta" / "scrape.py" - module_name = "_plexpersonmeta_source_scrape" - spec = importlib.util.spec_from_file_location(module_name, source_path) - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - - _pypinyin = ModuleType("pypinyin") _pypinyin.lazy_pinyin = lambda *_args, **_kwargs: [] with stub_modules({"pypinyin": _pypinyin}): - _scrape = _load_source_module() + from app.plugins.plexpersonmeta import scrape as _scrape def test_clear_cache_can_be_registered_as_a_no_argument_job(): diff --git a/tests/v3/plexpersonmeta/test_v3_migration.py b/tests/v3/plexpersonmeta/test_v3_migration.py new file mode 100644 index 00000000..0b87f433 --- /dev/null +++ b/tests/v3/plexpersonmeta/test_v3_migration.py @@ -0,0 +1,86 @@ +"""PlexPersonMeta V3 媒体身份与专用数据源边界测试。""" + +from types import ModuleType +from unittest.mock import MagicMock + +from app.schemas.types import MediaSource, MediaType +from app.testing import stub_modules + + +_pypinyin = ModuleType("pypinyin") +_pypinyin.lazy_pinyin = lambda *_args, **_kwargs: [] + +with stub_modules({"pypinyin": _pypinyin}): + from app.plugins import plexpersonmeta as _plugin + from app.plugins.plexpersonmeta import scrape as _scrape + + +def test_v3_plugin_version_increments_minor_version(): + """V3 插件入口沿用现有主版本并提升小版本。""" + assert _plugin.PlexPersonMeta.plugin_version == "2.4" + + +def test_tmdb_media_uses_unified_source_identity(): + """通用媒体识别必须用 TMDB 来源与原生 ID 组成显式身份。""" + helper = object.__new__(_scrape.ScrapeHelper) + helper.chain = MagicMock() + expected = object() + helper.chain.recognize_media.return_value = expected + + result = _scrape.ScrapeHelper.get_tmdb_media.__wrapped__( + helper, + tmdbid=550, + title="搏击俱乐部", + mtype=MediaType.MOVIE, + ) + + assert result is expected + helper.chain.recognize_media.assert_called_once_with( + mtype=MediaType.MOVIE, + media_source=MediaSource.TMDB, + media_id="550", + ) + + +def test_tmdb_media_rejects_non_video_media(): + """人物刮削只支持电影和电视剧,不把 V3 音乐身份传入影视识别链。""" + helper = object.__new__(_scrape.ScrapeHelper) + helper.chain = MagicMock() + + result = _scrape.ScrapeHelper.get_tmdb_media.__wrapped__( + helper, + tmdbid=550, + title="测试音乐", + mtype=MediaType.MUSIC, + ) + + assert result is None + helper.chain.recognize_media.assert_not_called() + + +def test_tmdb_person_detail_keeps_dedicated_tmdb_chain(): + """人物详情属于 TMDB 专用能力,不经过通用媒体识别链。""" + helper = object.__new__(_scrape.ScrapeHelper) + helper.tmdb_chain = MagicMock() + expected = object() + helper.tmdb_chain.person_detail.return_value = expected + + result = _scrape.ScrapeHelper.get_tmdb_person_detail.__wrapped__( + helper, + person_tmdbid=287, + ) + + assert result is expected + helper.tmdb_chain.person_detail.assert_called_once_with(287) + + +def test_plex_tmdb_guid_is_only_used_to_extract_external_identity(): + """Plex 外部 GUID 仅负责提取 TMDB ID,不承担媒体识别。""" + item = { + "Guid": [ + {"id": "imdb://tt0137523"}, + {"id": "tmdb://550"}, + ], + } + + assert _scrape.ScrapeHelper.get_tmdb_id(item) == 550 diff --git a/tests/v2/subscribeassistantenhanced/conftest.py b/tests/v3/subscribeassistantenhanced/conftest.py similarity index 96% rename from tests/v2/subscribeassistantenhanced/conftest.py rename to tests/v3/subscribeassistantenhanced/conftest.py index 1151fa9d..e59ba4a1 100644 --- a/tests/v2/subscribeassistantenhanced/conftest.py +++ b/tests/v3/subscribeassistantenhanced/conftest.py @@ -28,7 +28,8 @@ def make_subscribe(): """构建 Subscribe 模拟对象。""" def _make(**kwargs): defaults = dict( - id=1, name="测试剧", tmdbid=12345, doubanid=None, + id=1, name="测试剧", + media_source="themoviedb", media_id="12345", year=None, season=1, episode_group=None, type="电视剧", state="R", best_version=0, current_priority=0, best_version_full=0, episode_priority={}, diff --git a/tests/v2/subscribeassistantenhanced/fixtures/biaoren_runtime.json b/tests/v3/subscribeassistantenhanced/fixtures/biaoren_runtime.json similarity index 100% rename from tests/v2/subscribeassistantenhanced/fixtures/biaoren_runtime.json rename to tests/v3/subscribeassistantenhanced/fixtures/biaoren_runtime.json diff --git a/tests/v2/subscribeassistantenhanced/fixtures/biaoren_tmdb.json b/tests/v3/subscribeassistantenhanced/fixtures/biaoren_tmdb.json similarity index 100% rename from tests/v2/subscribeassistantenhanced/fixtures/biaoren_tmdb.json rename to tests/v3/subscribeassistantenhanced/fixtures/biaoren_tmdb.json diff --git a/tests/v2/subscribeassistantenhanced/frontend/setup.ts b/tests/v3/subscribeassistantenhanced/frontend/setup.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/setup.ts rename to tests/v3/subscribeassistantenhanced/frontend/setup.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/components/__tests__/Config.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/components/__tests__/Config.spec.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/src/components/__tests__/Config.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/components/__tests__/Config.spec.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts similarity index 93% rename from tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts index 55d7f23d..ebe384ef 100644 --- a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts +++ b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/api.spec.ts @@ -16,7 +16,11 @@ describe('summary API helper', () => { pending_count: 3, monitored_torrents: 5, } - const get = vi.fn().mockResolvedValue(payload) + const get = vi.fn().mockResolvedValue({ + success: true, + message: '', + data: payload, + }) const api: PluginApi = { get } const result = await loadSummary(api) diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/draft.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/draft.spec.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/draft.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/draft.spec.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/i18n.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/i18n.spec.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/i18n.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/i18n.spec.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/presentation.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/presentation.spec.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/presentation.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/presentation.spec.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/values.spec.ts b/tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/values.spec.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/src/config/__tests__/values.spec.ts rename to tests/v3/subscribeassistantenhanced/frontend/src/config/__tests__/values.spec.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/support/factories/config.ts b/tests/v3/subscribeassistantenhanced/frontend/support/factories/config.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/support/factories/config.ts rename to tests/v3/subscribeassistantenhanced/frontend/support/factories/config.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/support/host.ts b/tests/v3/subscribeassistantenhanced/frontend/support/host.ts similarity index 87% rename from tests/v2/subscribeassistantenhanced/frontend/support/host.ts rename to tests/v3/subscribeassistantenhanced/frontend/support/host.ts index 7d8fb4b2..4154df8d 100644 --- a/tests/v2/subscribeassistantenhanced/frontend/support/host.ts +++ b/tests/v3/subscribeassistantenhanced/frontend/support/host.ts @@ -16,7 +16,7 @@ export function createSummary(overrides: Partial = {}): SummaryP /** 构造宿主注入的认证 API 边界,并暴露调用记录供行为断言。 */ export function createHostApi(payload: SummaryPayload = createSummary()) { - const get = vi.fn().mockResolvedValue(payload) + const get = vi.fn().mockResolvedValue({ success: true, message: '', data: payload }) const api: PluginApi = { get } return { api, get } } diff --git a/tests/v2/subscribeassistantenhanced/frontend/support/msw/server.ts b/tests/v3/subscribeassistantenhanced/frontend/support/msw/server.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/support/msw/server.ts rename to tests/v3/subscribeassistantenhanced/frontend/support/msw/server.ts diff --git a/tests/v2/subscribeassistantenhanced/frontend/support/render.ts b/tests/v3/subscribeassistantenhanced/frontend/support/render.ts similarity index 100% rename from tests/v2/subscribeassistantenhanced/frontend/support/render.ts rename to tests/v3/subscribeassistantenhanced/frontend/support/render.ts diff --git a/tests/v2/subscribeassistantenhanced/test_biaoren_fixtures.py b/tests/v3/subscribeassistantenhanced/test_biaoren_fixtures.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_biaoren_fixtures.py rename to tests/v3/subscribeassistantenhanced/test_biaoren_fixtures.py index 3ccf95ea..9c7bdd77 100644 --- a/tests/v2/subscribeassistantenhanced/test_biaoren_fixtures.py +++ b/tests/v3/subscribeassistantenhanced/test_biaoren_fixtures.py @@ -73,6 +73,8 @@ def _mediainfo(tmdb_fixture: dict): def _subscribe(runtime_fixture: dict, key: str): """从 DB 脱敏订阅 fixture 还原订阅对象。""" data = dict(runtime_fixture["subscriptions"][key]) + data["media_source"] = "themoviedb" + data["media_id"] = str(data.pop("tmdbid")) data.setdefault("type", "电视剧") data.setdefault("episode_group", None) data.setdefault("best_version_full", 0) diff --git a/tests/v2/subscribeassistantenhanced/test_cadence.py b/tests/v3/subscribeassistantenhanced/test_cadence.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_cadence.py rename to tests/v3/subscribeassistantenhanced/test_cadence.py diff --git a/tests/v2/subscribeassistantenhanced/test_cleanup.py b/tests/v3/subscribeassistantenhanced/test_cleanup.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_cleanup.py rename to tests/v3/subscribeassistantenhanced/test_cleanup.py diff --git a/tests/v2/subscribeassistantenhanced/test_config.py b/tests/v3/subscribeassistantenhanced/test_config.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_config.py rename to tests/v3/subscribeassistantenhanced/test_config.py diff --git a/tests/v2/subscribeassistantenhanced/test_config_parsing.py b/tests/v3/subscribeassistantenhanced/test_config_parsing.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_config_parsing.py rename to tests/v3/subscribeassistantenhanced/test_config_parsing.py diff --git a/tests/v2/subscribeassistantenhanced/test_converter.py b/tests/v3/subscribeassistantenhanced/test_converter.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_converter.py rename to tests/v3/subscribeassistantenhanced/test_converter.py diff --git a/tests/v2/subscribeassistantenhanced/test_dashboard.py b/tests/v3/subscribeassistantenhanced/test_dashboard.py similarity index 88% rename from tests/v2/subscribeassistantenhanced/test_dashboard.py rename to tests/v3/subscribeassistantenhanced/test_dashboard.py index 7455795b..31a47d7c 100644 --- a/tests/v2/subscribeassistantenhanced/test_dashboard.py +++ b/tests/v3/subscribeassistantenhanced/test_dashboard.py @@ -1,4 +1,5 @@ """前端入口 smoke:只读概览 API 保留;详情页与仪表盘已下线。""" +from app import schemas from subscribeassistantenhanced import SubscribeAssistantEnhanced @@ -12,7 +13,8 @@ def _plugin(self): def test_get_api_exposes_summary(self): apis = self._plugin().get_api() - assert any(a["path"] == "/summary" for a in apis) + route = next(a for a in apis if a["path"] == "/summary") + assert route["response_model"] == schemas.Response[dict] def test_api_summary_shape(self): summary = self._plugin()._api_summary() diff --git a/tests/v2/subscribeassistantenhanced/test_deletes.py b/tests/v3/subscribeassistantenhanced/test_deletes.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_deletes.py rename to tests/v3/subscribeassistantenhanced/test_deletes.py diff --git a/tests/v2/subscribeassistantenhanced/test_docs_text.py b/tests/v3/subscribeassistantenhanced/test_docs_text.py similarity index 96% rename from tests/v2/subscribeassistantenhanced/test_docs_text.py rename to tests/v3/subscribeassistantenhanced/test_docs_text.py index ca8c8a91..5e27f5d1 100644 --- a/tests/v2/subscribeassistantenhanced/test_docs_text.py +++ b/tests/v3/subscribeassistantenhanced/test_docs_text.py @@ -2,7 +2,7 @@ from pathlib import Path -README_PATH = Path(__file__).resolve().parents[3] / "plugins.v2" / "subscribeassistantenhanced" / "README.md" +README_PATH = Path(__file__).resolve().parents[3] / "plugins.v3" / "subscribeassistantenhanced" / "README.md" def test_readme_documents_completion_guard_and_download_check_boundaries(): diff --git a/tests/v2/subscribeassistantenhanced/test_event_handlers.py b/tests/v3/subscribeassistantenhanced/test_event_handlers.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_event_handlers.py rename to tests/v3/subscribeassistantenhanced/test_event_handlers.py diff --git a/tests/v2/subscribeassistantenhanced/test_events.py b/tests/v3/subscribeassistantenhanced/test_events.py similarity index 90% rename from tests/v2/subscribeassistantenhanced/test_events.py rename to tests/v3/subscribeassistantenhanced/test_events.py index d8847bfb..1c2d5ea9 100644 --- a/tests/v2/subscribeassistantenhanced/test_events.py +++ b/tests/v3/subscribeassistantenhanced/test_events.py @@ -2,6 +2,10 @@ from types import SimpleNamespace from unittest.mock import MagicMock, call +import pytest + +from app.schemas.types import MediaType + from subscribeassistantenhanced.events import EventProxy from subscribeassistantenhanced.lifecycle import LifecycleResult @@ -11,7 +15,8 @@ def _sub(**kwargs): defaults = dict( id=1, name="测试剧", - tmdbid=100, + media_source="themoviedb", + media_id="100", season=1, episode_group=None, state="R", @@ -92,12 +97,13 @@ def test_episodes_refresh_label_uses_media_when_subscribe_missing(self): current_total_episode=15, subscribe_id=32, season=1, - tmdbid=325228, + media_source="themoviedb", + media_id="325228", mediainfo={"title": "镖人", "year": 2023}, scene="refresh", ) - assert EventProxy._format_episodes_refresh_label(data) == "镖人 (2023) S1(id=32, tmdbid=325228, scene=refresh)" + assert EventProxy._format_episodes_refresh_label(data) == "镖人 (2023) S1(id=32, media=themoviedb:325228, scene=refresh)" def test_episodes_refresh_label_uses_mediainfo_contract(self): """集数刷新标签支持主程序 MediaInfo 对象。""" @@ -108,11 +114,14 @@ def test_episodes_refresh_label_uses_mediainfo_contract(self): current_total_episode=15, subscribe_id=32, season=1, - mediainfo=MediaInfo(title="镖人", year="2023", tmdb_id=325228), + mediainfo=MediaInfo( + title="镖人", year="2023", + media_source="themoviedb", media_id="325228", + ), scene="refresh", ) - assert EventProxy._format_episodes_refresh_label(data) == "镖人 (2023) S1(id=32, tmdbid=325228, scene=refresh)" + assert EventProxy._format_episodes_refresh_label(data) == "镖人 (2023) S1(id=32, media=themoviedb:325228, scene=refresh)" def test_download_added_registers_monitor_then_lifecycle_and_notifies_once(self): """DownloadAdded 先登记下载事实,再按 lifecycle 结果发送一次恢复通知。""" @@ -428,7 +437,13 @@ def test_subscribe_complete_triggers_snapshot(self): proxy = EventProxy(verifier=verifier) event = SimpleNamespace(event_data={ "subscribe_id": 5, - "subscribe_info": {"tmdbid": 100, "season": 1, "name": "测试"}, + "subscribe_info": { + "type": "电视剧", + "media_source": "themoviedb", + "media_id": "100", + "season": 1, + "name": "测试", + }, }) proxy.on_subscribe_complete(event) verifier.snapshot.assert_called_once() @@ -456,6 +471,114 @@ def test_no_monitor_no_error(self): origin='Subscribe|{"id": 1}', context=None, episodes=[], cancel=False)) proxy.on_resource_download(event) + @pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV]) + def test_supported_media_completion_check_reaches_guard(self, media_type): + """电影和电视剧完成检查继续交给完成守卫。""" + guard = MagicMock() + event = SimpleNamespace(event_data=SimpleNamespace(subscribe=_sub(type=media_type))) + + EventProxy(guard=guard).on_completion_check(event) + + guard.handle.assert_called_once_with(event) + + def test_music_entry_points_skip_all_downstream_modules(self): + """音乐订阅在各外层入口拒绝,不进入生命周期、识别、清理或下载状态链路。""" + subscribe = _sub(type=MediaType.MUSIC) + subscribe_oper = MagicMock() + subscribe_oper.get.return_value = subscribe + guard = MagicMock() + lifecycle = MagicMock() + priority = MagicMock() + verifier = MagicMock() + task_manager = MagicMock() + recognition_guard = MagicMock() + deletes_store = MagicMock() + monitor = MagicMock() + cleanup = MagicMock() + orchestrator = MagicMock() + volatility = MagicMock() + site_refresh = MagicMock() + pending_refresh = MagicMock() + mediainfo_from_dict = MagicMock(return_value=SimpleNamespace()) + detect_backfill = MagicMock(return_value=[1]) + proxy = EventProxy( + subscribe_oper=subscribe_oper, + guard=guard, + lifecycle=lifecycle, + priority_manager=priority, + verifier=verifier, + task_manager=task_manager, + recognition_guard=recognition_guard, + deletes_store=deletes_store, + download_monitor=monitor, + subscription_cleanup=cleanup, + orchestrator=orchestrator, + volatility=volatility, + site_refresh=site_refresh, + pending_refresh=pending_refresh, + mediainfo_from_dict=mediainfo_from_dict, + detect_backfill_episodes_fn=detect_backfill, + ) + + proxy.on_completion_check(SimpleNamespace( + event_data=SimpleNamespace(subscribe=subscribe) + )) + from app.schemas.event import SubscribeEpisodesRefreshEventData + proxy.on_episodes_refresh(SimpleNamespace(event_data=SubscribeEpisodesRefreshEventData( + subscribe_id=1, + current_total_episode=12, + ))) + proxy.on_subscribe_added(SimpleNamespace(event_data={ + "subscribe_id": 1, + "mediainfo": {"type": "音乐"}, + })) + proxy.on_subscribe_modified(SimpleNamespace(event_data={ + "subscribe_id": 1, + "fields": ["state", "best_version"], + "subscribe_info": {"state": "R", "best_version": 1}, + "old_subscribe_info": {"state": "S", "best_version": 0}, + })) + proxy.on_subscribe_complete(SimpleNamespace(event_data={ + "subscribe_id": 1, + "subscribe_info": subscribe.__dict__, + "mediainfo": {"type": "音乐"}, + })) + selection_data = SimpleNamespace( + origin='Subscribe|{"id": 1}', + contexts=[SimpleNamespace(torrent_info=SimpleNamespace())], + updated=False, + updated_contexts=None, + source="", + ) + proxy.on_resource_selection(SimpleNamespace(event_data=selection_data)) + proxy.on_resource_download(SimpleNamespace(event_data=SimpleNamespace( + origin='Subscribe|{"id": 1}', + context=SimpleNamespace(torrent_info=SimpleNamespace(enclosure="music")), + episodes=[], + cancel=False, + ))) + proxy.on_download_added(SimpleNamespace(event_data={ + "source": 'Subscribe|{"id": 1}', + "hash": "music-hash", + })) + + guard.handle.assert_not_called() + lifecycle.assert_not_called() + priority.assert_not_called() + verifier.assert_not_called() + task_manager.assert_not_called() + recognition_guard.assert_not_called() + deletes_store.assert_not_called() + monitor.assert_not_called() + cleanup.assert_not_called() + orchestrator.assert_not_called() + volatility.assert_not_called() + site_refresh.assert_not_called() + pending_refresh.assert_not_called() + mediainfo_from_dict.assert_not_called() + detect_backfill.assert_not_called() + assert selection_data.updated is False + class TestSubscribeLifecycle: """订阅删除/修改事件:任务清理与状态变更时的暂停重置。""" diff --git a/tests/v2/subscribeassistantenhanced/test_form.py b/tests/v3/subscribeassistantenhanced/test_form.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_form.py rename to tests/v3/subscribeassistantenhanced/test_form.py diff --git a/tests/v2/subscribeassistantenhanced/test_guard.py b/tests/v3/subscribeassistantenhanced/test_guard.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_guard.py rename to tests/v3/subscribeassistantenhanced/test_guard.py diff --git a/tests/v2/subscribeassistantenhanced/test_integration.py b/tests/v3/subscribeassistantenhanced/test_integration.py similarity index 98% rename from tests/v2/subscribeassistantenhanced/test_integration.py rename to tests/v3/subscribeassistantenhanced/test_integration.py index f8cc6b07..c676349f 100644 --- a/tests/v2/subscribeassistantenhanced/test_integration.py +++ b/tests/v3/subscribeassistantenhanced/test_integration.py @@ -46,7 +46,8 @@ def _mi(status="Returning Series", next_ep=None, last_ep=None, seasons=None): def _sub(sid=1, season=1, episode_group=None, best_version=0, state="R", total_episode=12, **kw): defaults = dict( - id=sid, tmdbid=100, season=season, episode_group=episode_group, + id=sid, media_source="themoviedb", media_id="100", + season=season, episode_group=episode_group, best_version=best_version, state=state, type="电视剧", name="测试剧", total_episode=total_episode, lack_episode=0, episode_priority={}, current_priority=0, @@ -347,7 +348,8 @@ def test_rebuild_deletes_bv(self): tm, store = _store() oper = MagicMock() bv = SimpleNamespace( - id=99, tmdbid=100, season=1, episode_group=None, + id=99, media_source="themoviedb", media_id="100", + season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, total_episode=12, name="测试剧", save_path=None, sites=None, filter=None, filter_groups=[], diff --git a/tests/v2/subscribeassistantenhanced/test_lifecycle.py b/tests/v3/subscribeassistantenhanced/test_lifecycle.py similarity index 95% rename from tests/v2/subscribeassistantenhanced/test_lifecycle.py rename to tests/v3/subscribeassistantenhanced/test_lifecycle.py index 15292af3..77f6d7c7 100644 --- a/tests/v2/subscribeassistantenhanced/test_lifecycle.py +++ b/tests/v3/subscribeassistantenhanced/test_lifecycle.py @@ -88,7 +88,7 @@ def test_lifecycle_result_defaults(): def test_pending_from_judge_schedules_search_before_pending_for_new_subscribe(fake_lifecycle): - subscribe = SimpleNamespace(id=1, state="N", tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=1, state="N", media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.pending_judge.should_enter_pending.return_value = (True, "开播日期未知") result = fake_lifecycle.coordinator.enter_pending_from_judge(subscribe, object(), []) @@ -111,7 +111,7 @@ def test_subscribe_added_auto_user_pause_stops_lifecycle(fake_lifecycle): def test_subscribe_added_pre_air_stops_before_pending(fake_lifecycle): - subscribe = SimpleNamespace(id=11, state="N", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=11, state="N", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) record = PauseRecord(reason="pre_air", since=1.0, detail="开播日期未知") fake_lifecycle.airing.check_pre_air.return_value = record @@ -124,7 +124,7 @@ def test_subscribe_added_pre_air_stops_before_pending(fake_lifecycle): def test_subscribe_added_pending_for_new_state_schedules_search_once(fake_lifecycle): - subscribe = SimpleNamespace(id=12, state="N", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=12, state="N", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.pending_judge.should_enter_pending.return_value = (True, "开播日期未知") result = fake_lifecycle.coordinator.handle_subscribe_added(subscribe, object()) @@ -134,7 +134,7 @@ def test_subscribe_added_pending_for_new_state_schedules_search_once(fake_lifecy def test_subscribe_added_uses_episode_group_scope(fake_lifecycle): - subscribe = SimpleNamespace(id=13, state="R", best_version=False, tmdbid=100, season=1, episode_group="eg-1") + subscribe = SimpleNamespace(id=13, state="R", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group="eg-1") fake_lifecycle.pending_judge.should_enter_pending.return_value = (False, "") fake_lifecycle.coordinator.handle_subscribe_added(subscribe, SimpleNamespace(next_episode_to_air=None)) @@ -143,7 +143,7 @@ def test_subscribe_added_uses_episode_group_scope(fake_lifecycle): def test_subscribe_added_non_tv_skips_tv_pending_flow(fake_lifecycle): - subscribe = SimpleNamespace(id=14, state="R", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=14, state="R", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.coordinator._is_tv = lambda _mediainfo: False result = fake_lifecycle.coordinator.handle_subscribe_added(subscribe, SimpleNamespace(next_episode_to_air=None)) @@ -157,7 +157,7 @@ def test_subscribe_added_non_tv_skips_tv_pending_flow(fake_lifecycle): def test_subscribe_added_new_subscription_skips_airing_gap_when_not_pending(fake_lifecycle): - subscribe = SimpleNamespace(id=15, state="N", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=15, state="N", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.pending_judge.should_enter_pending.return_value = (False, "") fake_lifecycle.airing.check.return_value = PauseRecord(reason="airing_gap", detail="下一集距今 7 天") @@ -171,7 +171,7 @@ def test_subscribe_added_new_subscription_skips_airing_gap_when_not_pending(fake def test_subscribe_added_running_subscription_pauses_after_library_update(fake_lifecycle): - subscribe = SimpleNamespace(id=16, state="R", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=16, state="R", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.pending_judge.should_enter_pending.return_value = (False, "") record = PauseRecord(reason="airing_gap", detail="下一集距今 7 天") fake_lifecycle.airing.check.return_value = record @@ -192,7 +192,7 @@ def test_subscribe_added_running_subscription_pauses_after_library_update(fake_l def test_meta_check_refreshes_same_pause_silently(fake_lifecycle): - subscribe = SimpleNamespace(id=20, state="S", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=20, state="S", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) existing = PauseRecord(reason="pre_air", since=1.0, detail="旧原因") current = PauseRecord(reason="pre_air", since=2.0, detail="新原因") fake_lifecycle.pause_manager.get_pause_record.return_value = existing @@ -220,7 +220,7 @@ def test_meta_check_restores_orphan_p_before_pause(fake_lifecycle): def test_meta_check_reports_p_when_pending_exit_leaves_another_source(fake_lifecycle): """元数据巡检只释放一个待定来源时,返回状态必须仍是待定(P)。""" - subscribe = SimpleNamespace(id=25, state="P", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=25, state="P", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.pending_state.has_active.return_value = True fake_lifecycle.pending_judge.check_exit.return_value = True fake_lifecycle.recognize.return_value = object() @@ -269,7 +269,7 @@ def test_restore_owned_states_before_reset_recovers_pending_and_airing_pause(fak def test_subscribe_added_full_best_version_stops_before_pending(fake_lifecycle): subscribe = SimpleNamespace(id=17, state="R", best_version=True, best_version_full=True, type="电视剧", - tmdbid=100, season=1, episode_group=None) + media_source="themoviedb", media_id="100", season=1, episode_group=None) result = fake_lifecycle.coordinator.handle_subscribe_added(subscribe, object()) @@ -356,7 +356,7 @@ def test_download_added_missing_pause_record_is_adopted_as_external(fake_lifecyc def test_library_updated_checks_airing_gap_only_for_active_tv(fake_lifecycle): - subscribe = SimpleNamespace(id=32, state="R", best_version=False, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace(id=32, state="R", best_version=False, media_source="themoviedb", media_id="100", season=1, episode_group=None) fake_lifecycle.subscribe_oper.get.return_value = subscribe mediainfo = SimpleNamespace(next_episode_to_air=None) fake_lifecycle.recognize.return_value = mediainfo diff --git a/tests/v2/subscribeassistantenhanced/test_monitor.py b/tests/v3/subscribeassistantenhanced/test_monitor.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_monitor.py rename to tests/v3/subscribeassistantenhanced/test_monitor.py diff --git a/tests/v2/subscribeassistantenhanced/test_orchestrator.py b/tests/v3/subscribeassistantenhanced/test_orchestrator.py similarity index 98% rename from tests/v2/subscribeassistantenhanced/test_orchestrator.py rename to tests/v3/subscribeassistantenhanced/test_orchestrator.py index 319c078b..373d0100 100644 --- a/tests/v2/subscribeassistantenhanced/test_orchestrator.py +++ b/tests/v3/subscribeassistantenhanced/test_orchestrator.py @@ -19,7 +19,7 @@ def _mediainfo(): def _sub(ep_priority=None, episode_group=None, **kwargs): defaults = dict( - id=1, name="测试剧", tmdbid=100, season=1, + id=1, name="测试剧", media_source="themoviedb", media_id="100", season=1, episode_priority=ep_priority or {}, current_priority=0, episode_group=episode_group, save_path="/media", sites="site1", filter="rule1", filter_groups=["group1"], @@ -44,7 +44,9 @@ def test_includes_subscribe_fields(self): orch = BestVersionOrchestrator(priority_manager=MagicMock(spec=PriorityManager)) payload = orch.build_payload(_sub()) assert "name" in payload - assert "tmdbid" in payload + assert payload["media_source"] == "themoviedb" + assert payload["media_id"] == "100" + assert "tmdbid" not in payload assert "season" in payload assert "save_path" in payload assert payload["filter"] == "rule1" diff --git a/tests/v2/subscribeassistantenhanced/test_pause.py b/tests/v3/subscribeassistantenhanced/test_pause.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_pause.py rename to tests/v3/subscribeassistantenhanced/test_pause.py diff --git a/tests/v2/subscribeassistantenhanced/test_paused_probe.py b/tests/v3/subscribeassistantenhanced/test_paused_probe.py similarity index 95% rename from tests/v2/subscribeassistantenhanced/test_paused_probe.py rename to tests/v3/subscribeassistantenhanced/test_paused_probe.py index f0a75850..caa689ef 100644 --- a/tests/v2/subscribeassistantenhanced/test_paused_probe.py +++ b/tests/v3/subscribeassistantenhanced/test_paused_probe.py @@ -2,6 +2,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +from app.schemas.types import MediaType + from subscribeassistantenhanced.engine.types import PauseRecord from subscribeassistantenhanced.pause.probe import ( PROBE_LAST_SCHEDULED_AT, @@ -33,15 +35,16 @@ def fire(self): self.callback() -def _sub(sid=1, state="S", best_version=0): +def _sub(sid=1, state="S", best_version=0, media_type=MediaType.TV): """构造暂停订阅替身。""" return SimpleNamespace( id=sid, name=f"测试{sid}", - tmdbid=100 + sid, + media_source="themoviedb", + media_id=str(100 + sid), season=1, episode_group=None, - type="电视剧", + type=media_type, state=state, best_version=best_version, best_version_full=0, @@ -145,6 +148,24 @@ def test_empty_reasons_adopts_external_but_does_not_schedule(): assert data["subscribes"] == {} +def test_music_subscription_is_rejected_before_pause_adoption(): + """音乐订阅不进入暂停登记、下载探测或补搜调度。""" + music = _sub(media_type=MediaType.MUSIC) + coordinator, data, _oper, chain, pause, monitor = _coordinator( + [music], + {music.id: None}, + ) + + coordinator.run() + + pause.get_pause_record.assert_not_called() + pause.adopt_external.assert_not_called() + monitor.has_active_downloads.assert_not_called() + chain.search.assert_not_called() + assert FakeTimer.instances == [] + assert data["subscribes"] == {} + + def test_no_download_after_pause_and_interval_schedules_probe(): """暂停满最小天数且超过间隔时写入调度字段并启动 Timer。""" sub = _sub() diff --git a/tests/v2/subscribeassistantenhanced/test_pending.py b/tests/v3/subscribeassistantenhanced/test_pending.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_pending.py rename to tests/v3/subscribeassistantenhanced/test_pending.py index 6e6fcd0f..27aa76ff 100644 --- a/tests/v2/subscribeassistantenhanced/test_pending.py +++ b/tests/v3/subscribeassistantenhanced/test_pending.py @@ -23,7 +23,8 @@ def _sub(sid=1, season=1, state="R", episode_group=None, total_episode=12, id=sid, name="测试剧", type=media_type, - tmdbid=100, + media_source="themoviedb", + media_id="100", season=season, state=state, episode_group=episode_group, diff --git a/tests/v2/subscribeassistantenhanced/test_pending_state.py b/tests/v3/subscribeassistantenhanced/test_pending_state.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_pending_state.py rename to tests/v3/subscribeassistantenhanced/test_pending_state.py diff --git a/tests/v2/subscribeassistantenhanced/test_pipeline.py b/tests/v3/subscribeassistantenhanced/test_pipeline.py similarity index 98% rename from tests/v2/subscribeassistantenhanced/test_pipeline.py rename to tests/v3/subscribeassistantenhanced/test_pipeline.py index 92fc9e0d..fbdc83c6 100644 --- a/tests/v2/subscribeassistantenhanced/test_pipeline.py +++ b/tests/v3/subscribeassistantenhanced/test_pipeline.py @@ -37,7 +37,8 @@ def _make_tracker(stable=True, direction="up"): tracker = VolatilityTracker(mgr, window_days=7) if not stable: subscribe = SimpleNamespace( - id=1, tmdbid=100, season=1, episode_group=None + id=1, media_source="themoviedb", media_id="100", + season=1, episode_group=None, ) if direction == "down": tracker.record(total=15, subscribe=subscribe) @@ -52,7 +53,7 @@ def _sub(sid=1, season=1, episode_group=None, best_version=0, name="测试剧", start_episode=1, total_episode=12, state="R", manual_total_episode=False, stype="电视剧", best_version_full=0): return SimpleNamespace( - id=sid, name=name, tmdbid=100, season=season, + id=sid, name=name, media_source="themoviedb", media_id="100", season=season, episode_group=episode_group, best_version=best_version, start_episode=start_episode, total_episode=total_episode, state=state, manual_total_episode=manual_total_episode, @@ -107,7 +108,8 @@ def _site(kind, candidate_total=12, current_total=10, now=None, site_total=None, defaults = { "kind": kind, "confidence": "medium" if kind != "site_complete_pack" else "low", - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": "", "type": "电视剧", @@ -1101,7 +1103,14 @@ def test_subscribe_id_none_skips_f(self): """创建场景 subscribe_id=None → F 跳过。""" eps = [_ep(1)] sig = _primary( - subscribe=SimpleNamespace(id=None, tmdbid=100, season=1, episode_group=None, best_version=0), + subscribe=SimpleNamespace( + id=None, + media_source="themoviedb", + media_id="100", + season=1, + episode_group=None, + best_version=0, + ), mediainfo=_mi(status="Ended"), tmdb_episodes_fn=_tmdb_fn(eps), volatility_tracker=_make_tracker(stable=False), diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_entry.py b/tests/v3/subscribeassistantenhanced/test_plugin_entry.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_plugin_entry.py rename to tests/v3/subscribeassistantenhanced/test_plugin_entry.py index 28710880..a1642a0e 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_entry.py +++ b/tests/v3/subscribeassistantenhanced/test_plugin_entry.py @@ -106,7 +106,7 @@ def test_recognition_external_failure_logs_are_sanitized(self, monkeypatch): plugin = SubscribeAssistantEnhanced() plugin.chain = SimpleNamespace( recognize_media=MagicMock(side_effect=RuntimeError( - "request failed token=SECRET /Users/chengyu/媒体库/测试剧.mkv" + "request failed token=SECRET /Users/example/媒体库/测试剧.mkv" )) ) monkeypatch.setattr(plugin_module.logger, "warning", messages.append) diff --git a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py b/tests/v3/subscribeassistantenhanced/test_plugin_integration.py similarity index 98% rename from tests/v2/subscribeassistantenhanced/test_plugin_integration.py rename to tests/v3/subscribeassistantenhanced/test_plugin_integration.py index 9bcb727a..8a99bc7e 100644 --- a/tests/v2/subscribeassistantenhanced/test_plugin_integration.py +++ b/tests/v3/subscribeassistantenhanced/test_plugin_integration.py @@ -3,7 +3,7 @@ import json from datetime import date, timedelta from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from pathlib import Path from packaging.specifiers import SpecifierSet @@ -22,12 +22,8 @@ def _sub(**kwargs): id=1, name="测试", year=None, - tmdbid=100, - doubanid=None, - bangumiid=None, - anilistid=None, - media_source=None, - media_id=None, + media_source="themoviedb", + media_id="100", season=1, episode_group=None, type="电视剧", @@ -465,7 +461,7 @@ def test_best_version_check_logs_actionable_recognition_failure(monkeypatch): plugin = SubscribeAssistantEnhanced() plugin._config = SimpleNamespace(best_version_type="all") sub = _sub(id=42, name="识别失败剧", best_version=1, best_version_full=0, - tmdbid=100, season=1, type="电视剧") + media_source="themoviedb", media_id="100", season=1, type="电视剧") plugin._subscribe_oper = MagicMock() plugin._subscribe_oper.list.return_value = [sub] plugin._recognize_mediainfo = MagicMock(return_value=None) @@ -479,12 +475,32 @@ def test_best_version_check_logs_actionable_recognition_failure(monkeypatch): assert any( "媒体识别失败" in message and "订阅ID:42" in message - and "TMDB:100" in message - and "建议检查订阅名称、年份、TMDB ID、媒体类型和季号" in message + and "媒体身份:themoviedb:100" in message + and "建议检查订阅名称、年份、媒体来源和媒体 ID、媒体类型和季号" in message for message in messages ) +def test_meta_check_skips_music_subscriptions_before_lifecycle(): + """定时订阅遍历只把电影和电视剧交给生命周期协调器。""" + movie = _sub(id=1, type=MediaType.MOVIE, season=None) + tv = _sub(id=2, type=MediaType.TV) + music = _sub(id=3, type=MediaType.MUSIC, season=None) + plugin = SubscribeAssistantEnhanced() + plugin.init_plugin({}) + plugin._subscribe_oper = MagicMock() + plugin._subscribe_oper.list.return_value = [movie, tv, music] + lifecycle = plugin._modules["lifecycle"] + lifecycle.handle_meta_check_subscription = MagicMock() + + plugin.run_meta_check() + + assert lifecycle.handle_meta_check_subscription.call_args_list == [ + call(movie), + call(tv), + ] + + def test_episode_to_full_skips_when_missing_info_uses_relative_episode_numbers(monkeypatch): """媒体库缺集返回相对集号时,绝对集号订阅不能被误判为已全覆盖。""" sub = _sub( @@ -2545,10 +2561,21 @@ def test_get_transfer_histories_passes_episode_when_provided(): plugin._transferhistory_oper = MagicMock() plugin._transferhistory_oper.get_by.return_value = [] - plugin._get_transfer_histories(tmdbid=100, mtype="电视剧", season="S01", episode="E02") + plugin._get_transfer_histories( + media_source="themoviedb", + media_id="100", + mtype="电视剧", + season="S01", + episode="E02", + ) plugin._transferhistory_oper.get_by.assert_called_once_with( - tmdbid=100, mtype="电视剧", season="S01", episode="E02") + media_source="themoviedb", + media_id="100", + mtype="电视剧", + season="S01", + episode="E02", + ) class TestPluginWiring: @@ -2852,7 +2879,8 @@ def test_completion_rebuilder_is_wired_into_verifier(self): def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subscription(self): """同身份分集洗版订阅未覆盖最新总集数时保留快照,不删除重建。""" snap = { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, @@ -2868,7 +2896,7 @@ def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subsc plugin._modules["verifier"]._tmdb_fn = MagicMock(return_value=[object()] * 13) plugin._modules["verifier"]._subscribe_oper = MagicMock() plugin._modules["verifier"]._subscribe_oper.list.return_value = [ - _sub(id=7, tmdbid=100, season=1, best_version=1, best_version_full=0) + _sub(id=7, media_id="100", season=1, best_version=1, best_version_full=0) ] plugin._modules["verifier"]._rebuild_subscribe = MagicMock(return_value=True) @@ -2881,7 +2909,8 @@ def test_completion_verify_keeps_snapshot_for_lagging_episode_best_version_subsc def test_completion_verify_keeps_snapshot_for_lagging_normal_subscription(self): """同身份普通订阅未覆盖最新总集数时保留快照,不重复重建。""" snap = { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, @@ -2897,7 +2926,7 @@ def test_completion_verify_keeps_snapshot_for_lagging_normal_subscription(self): plugin._modules["verifier"]._tmdb_fn = MagicMock(return_value=[object()] * 13) plugin._modules["verifier"]._subscribe_oper = MagicMock() plugin._modules["verifier"]._subscribe_oper.list.return_value = [ - _sub(id=7, tmdbid=100, season=1, best_version=0, best_version_full=0) + _sub(id=7, media_id="100", season=1, best_version=0, best_version_full=0) ] plugin._modules["verifier"]._rebuild_subscribe = MagicMock(return_value=True) @@ -2910,7 +2939,8 @@ def test_completion_verify_keeps_snapshot_for_lagging_normal_subscription(self): def test_completion_verify_replaces_existing_full_best_version_subscription(self): """同身份真正洗版订阅已存在时,完成快照可删除旧订阅并按新增集重建。""" snap = { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, @@ -2926,7 +2956,7 @@ def test_completion_verify_replaces_existing_full_best_version_subscription(self plugin._modules["verifier"]._tmdb_fn = MagicMock(return_value=[object()] * 13) plugin._modules["verifier"]._subscribe_oper = MagicMock() plugin._modules["verifier"]._subscribe_oper.list.return_value = [ - _sub(id=7, tmdbid=100, season=1, best_version=1, best_version_full=1) + _sub(id=7, media_id="100", season=1, best_version=1, best_version_full=1) ] plugin._modules["verifier"]._rebuild_subscribe = MagicMock(return_value=True) plugin._modules["verifier"]._notify = MagicMock() @@ -2946,7 +2976,8 @@ def test_completion_verify_replaces_existing_full_best_version_subscription(self def test_completion_verify_replaces_existing_movie_best_version_subscription(self): """电影洗版订阅已存在时,完成快照可删除旧订阅并按新增目标重建。""" snap = { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": None, "episode_group_id": None, "total_at_completion": 1, @@ -2964,7 +2995,7 @@ def test_completion_verify_replaces_existing_movie_best_version_subscription(sel plugin._modules["verifier"]._subscribe_oper.list.return_value = [ _sub( id=8, - tmdbid=100, + media_id="100", season=None, type=MediaType.MOVIE, total_episode=1, @@ -2997,7 +3028,8 @@ def test_restore_subscribe_from_snapshot_unlocks_manual_total_episode(self): { "id": 7, "name": "测试剧", - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "manual_total_episode": 92, }, @@ -3259,7 +3291,8 @@ def test_low_confidence_guard_timeout_release_allows_next_completion(self, monke "total_episode": 2, "identity": { "subscribe_id": 1, - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": None, }, @@ -3428,7 +3461,7 @@ def _plugin_with_overdue_subscribe(subscribe, mediainfo, action): def test_recognize_mediainfo_skips_unknown_media_type(self): """未知订阅类型不默认当电影识别,避免脏数据进入错误媒体链路。""" - subscribe = _sub(type=MediaType.UNKNOWN, name="测试", year="2025", season=1, tmdbid=100) + subscribe = _sub(type=MediaType.UNKNOWN, name="测试", year="2025", season=1) plugin = SubscribeAssistantEnhanced() plugin.init_plugin({}) plugin.chain = MagicMock() @@ -3440,7 +3473,7 @@ def test_recognize_mediainfo_skips_unknown_media_type(self): def test_last_download_date_queries_tv_history_and_returns_latest_date(self): """剧集按媒体信息和季查询下载历史,并返回最近下载日期。""" - subscribe = _sub(type="电视剧", name="测试", year="2025", season=1, tmdbid=100) + subscribe = _sub(type="电视剧", name="测试", year="2025", season=1) plugin = SubscribeAssistantEnhanced() plugin.init_plugin({}) plugin._downloadhistory_oper = MagicMock() @@ -3457,12 +3490,13 @@ def test_last_download_date_queries_tv_history_and_returns_latest_date(self): title="测试", year="2025", season="S01", - tmdbid=100, + media_source="themoviedb", + media_id="100", ) def test_last_download_date_returns_none_when_history_query_fails(self): """下载历史查询异常时安全返回 None。""" - subscribe = _sub(type="电影", name="测试", year="2025", tmdbid=100) + subscribe = _sub(type="电影", name="测试", year="2025") plugin = SubscribeAssistantEnhanced() plugin.init_plugin({}) plugin._downloadhistory_oper = MagicMock() @@ -3473,7 +3507,8 @@ def test_last_download_date_returns_none_when_history_query_fails(self): mtype="电影", title="测试", year="2025", - tmdbid=100, + media_source="themoviedb", + media_id="100", ) def test_related_episode_download_histories_filters_full_pack_and_source(self): @@ -3484,27 +3519,27 @@ def test_related_episode_download_histories_filters_full_pack_and_source(self): name="测试", year="2025", season=1, - tmdbid=100, + media_id="100", total_episode=12, date="2025-01-01 00:00:00", ) episode_download = SimpleNamespace( date="2025-02-01 00:00:00", - note={"source": 'Subscribe|{"id":18,"tmdbid":100,"year":"2025","season":1}'}, + note={"source": 'Subscribe|{"id":18,"media_source":"themoviedb","media_id":"100","year":"2025","season":1}'}, episode_group=None, torrent_name="测试 S01E01", torrent_description="", ) full_pack = SimpleNamespace( date="2025-02-02 00:00:00", - note={"source": 'Subscribe|{"id":18,"tmdbid":100,"year":"2025","season":1}'}, + note={"source": 'Subscribe|{"id":18,"media_source":"themoviedb","media_id":"100","year":"2025","season":1}'}, episode_group=None, torrent_name="测试 S01", torrent_description="Complete 12 Episodes", ) other_subscribe = SimpleNamespace( date="2025-02-03 00:00:00", - note={"source": 'Subscribe|{"id":99,"tmdbid":100,"year":"2025","season":1}'}, + note={"source": 'Subscribe|{"id":99,"media_source":"themoviedb","media_id":"100","year":"2025","season":1}'}, episode_group=None, torrent_name="测试 S01E02", torrent_description="", diff --git a/tests/v2/subscribeassistantenhanced/test_priority.py b/tests/v3/subscribeassistantenhanced/test_priority.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_priority.py rename to tests/v3/subscribeassistantenhanced/test_priority.py diff --git a/tests/v2/subscribeassistantenhanced/test_proximity.py b/tests/v3/subscribeassistantenhanced/test_proximity.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_proximity.py rename to tests/v3/subscribeassistantenhanced/test_proximity.py diff --git a/tests/v2/subscribeassistantenhanced/test_rebuilder.py b/tests/v3/subscribeassistantenhanced/test_rebuilder.py similarity index 88% rename from tests/v2/subscribeassistantenhanced/test_rebuilder.py rename to tests/v3/subscribeassistantenhanced/test_rebuilder.py index a0ffe697..5d37ff10 100644 --- a/tests/v2/subscribeassistantenhanced/test_rebuilder.py +++ b/tests/v3/subscribeassistantenhanced/test_rebuilder.py @@ -10,7 +10,8 @@ def _sub(**kwargs): """构造重建结果校验所需的稳定订阅字段。""" values = { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": None, "best_version": 0, @@ -46,7 +47,8 @@ def test_rebuild_uses_non_full_default_mode_and_preserves_media_config(): result = rebuilder.rebuild( { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group_id": "eg-1", "subscribe_config": {"best_version": 0, "best_version_full": 0}, @@ -69,7 +71,9 @@ def test_rebuild_uses_non_full_default_mode_and_preserves_media_config(): assert call["title"] == "测试" assert call["year"] == "2026" assert call["mtype"] == MediaType.TV - assert call["tmdbid"] == 100 + assert call["media_source"] == "themoviedb" + assert call["media_id"] == "100" + assert "tmdbid" not in call assert call["season"] == 1 assert call["episode_group"] == "eg-1" assert call["quality"] == "WEB-DL" @@ -93,7 +97,8 @@ def test_full_default_restores_snapshot_episode_mode(): assert rebuilder.rebuild( { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "subscribe_config": {"best_version": 1, "best_version_full": 0}, }, @@ -107,11 +112,12 @@ def test_full_default_downgrades_full_or_legacy_snapshot_to_normal(): """全集或旧快照在全集默认规则下回退普通订阅。""" for snap in ( { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "subscribe_config": {"best_version": 1, "best_version_full": 1}, }, - {"tmdbid": 100, "season": 1}, + {"media_source": "themoviedb", "media_id": "100", "season": 1}, ): rebuilder, chain, _, _ = _rebuilder( default_config={"best_version": 1, "best_version_full": 1}, @@ -139,19 +145,19 @@ def test_rebuild_rejects_wrong_identity_returned_by_exist_ok(): rebuilder, _, oper, _ = _rebuilder( rebuilt=_sub(episode_group="eg-old"), ) - snap = {"tmdbid": 100, "season": 1, "episode_group_id": "eg-new"} + snap = {"media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": "eg-new"} config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} assert rebuilder.rebuild(snap, config) is False - oper.get.return_value = _sub(tmdbid=101, episode_group="eg-new") + oper.get.return_value = _sub(media_id="101", episode_group="eg-new") assert rebuilder.rebuild(snap, config) is False def test_rebuild_rejects_result_without_requested_episode_range(): """回读订阅未覆盖完整新增集区间时保留快照重试。""" rebuilder, _, oper, _ = _rebuilder(rebuilt=_sub(total_episode=14)) - snap = {"tmdbid": 100, "season": 1} + snap = {"media_source": "themoviedb", "media_id": "100", "season": 1} config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} assert rebuilder.rebuild(snap, config) is False @@ -166,7 +172,7 @@ def test_rebuild_rejects_full_or_wrong_resolved_mode(): default_config={"best_version": 1, "best_version_full": 0}, rebuilt=_sub(best_version=1, best_version_full=1), ) - snap = {"tmdbid": 100, "season": 1} + snap = {"media_source": "themoviedb", "media_id": "100", "season": 1} config = {"name": "测试", "start_episode": 13, "total_episode": 15, "lack_episode": 3} assert rebuilder.rebuild(snap, config) is False @@ -180,7 +186,7 @@ def test_validate_checks_existing_subscription_against_added_episode_range(): rebuilder, _, _, _ = _rebuilder( default_config={"best_version": 1, "best_version_full": 0}, ) - snap = {"tmdbid": 100, "season": 1, "total_at_completion": 12} + snap = {"media_source": "themoviedb", "media_id": "100", "season": 1, "total_at_completion": 12} assert rebuilder.validate(_sub(best_version=1), snap, current_total=15) is True assert rebuilder.validate(None, snap, current_total=15) is False @@ -199,7 +205,7 @@ def test_rebuild_fails_closed_without_dependencies_or_when_chain_raises(): rebuilder, chain, _, _ = _rebuilder() chain.add.side_effect = RuntimeError("subscribe chain failed") assert rebuilder.rebuild( - {"tmdbid": 100, "season": 1}, + {"media_source": "themoviedb", "media_id": "100", "season": 1}, {"name": "测试", "start_episode": 13, "total_episode": 15}, ) is False diff --git a/tests/v2/subscribeassistantenhanced/test_recognition_config.py b/tests/v3/subscribeassistantenhanced/test_recognition_config.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_recognition_config.py rename to tests/v3/subscribeassistantenhanced/test_recognition_config.py index 32838c79..e05af3b9 100644 --- a/tests/v2/subscribeassistantenhanced/test_recognition_config.py +++ b/tests/v3/subscribeassistantenhanced/test_recognition_config.py @@ -8,7 +8,7 @@ from subscribeassistantenhanced.shared.config import PluginConfig -README_PATH = Path(__file__).resolve().parents[3] / "plugins.v2" / "subscribeassistantenhanced" / "README.md" +README_PATH = Path(__file__).resolve().parents[3] / "plugins.v3" / "subscribeassistantenhanced" / "README.md" def test_recognition_guard_defaults_are_upgrade_safe(): diff --git a/tests/v2/subscribeassistantenhanced/test_recognition_events.py b/tests/v3/subscribeassistantenhanced/test_recognition_events.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_recognition_events.py rename to tests/v3/subscribeassistantenhanced/test_recognition_events.py index 6c3c1a8b..b0d0a69e 100644 --- a/tests/v2/subscribeassistantenhanced/test_recognition_events.py +++ b/tests/v3/subscribeassistantenhanced/test_recognition_events.py @@ -34,8 +34,8 @@ def _sub(**kwargs): id=1, name="测试剧", year="2026", - tmdbid=100, - doubanid=None, + media_source="themoviedb", + media_id="100", season=1, episode_group=None, type="电视剧", diff --git a/tests/v2/subscribeassistantenhanced/test_recognition_guard.py b/tests/v3/subscribeassistantenhanced/test_recognition_guard.py similarity index 99% rename from tests/v2/subscribeassistantenhanced/test_recognition_guard.py rename to tests/v3/subscribeassistantenhanced/test_recognition_guard.py index 46fe3cd5..924fb78b 100644 --- a/tests/v2/subscribeassistantenhanced/test_recognition_guard.py +++ b/tests/v3/subscribeassistantenhanced/test_recognition_guard.py @@ -26,7 +26,8 @@ def _sub(**kwargs): defaults = dict( id=1, name="测试剧", - tmdbid=100, + media_source="themoviedb", + media_id="100", doubanid=None, year="2026", season=1, @@ -148,7 +149,7 @@ def test_candidate_summary_redacts_cookie_and_local_paths(): torrent = SimpleNamespace( enclosure="", page_url="", - title="资源 Cookie: uid=SECRET /Users/chengyu/Downloads/private.torrent", + title="资源 Cookie: uid=SECRET /Users/example/Downloads/private.torrent", description="本地路径 /media/private/movie.mkv password=DESC_PASS", site_name="站点A", ) @@ -195,7 +196,7 @@ def test_candidate_summary_redacts_unicode_local_paths(): torrent = SimpleNamespace( enclosure="", page_url="", - title="资源 /Users/chengyu/Library/CloudStorage/OneDrive-个人/媒体库/剧集/测试剧.mkv", + title="资源 /Users/example/Library/CloudStorage/ExampleDrive/媒体库/剧集/测试剧.mkv", description="整理路径 /volume1/影视库/国漫/测试剧/第01集.mkv", site_name="站点A", ) @@ -204,7 +205,7 @@ def test_candidate_summary_redacts_unicode_local_paths(): assert "/Users/" not in summary assert "/volume1/" not in summary - assert "OneDrive-个人" not in summary + assert "ExampleDrive" not in summary assert "影视库" not in summary assert "测试剧.mkv" not in summary assert "[redacted-path]" in summary @@ -221,7 +222,7 @@ def test_keyword_reason_is_sanitized_in_audit_and_notification(): context = SimpleNamespace( torrent_info=SimpleNamespace( title=f"测试剧 {sensitive_rule}", - description="候选说明 /Users/chengyu/private/file.torrent", + description="候选说明 /Users/example/private/file.torrent", site_name="站点A", category="TV", ), diff --git a/tests/v2/subscribeassistantenhanced/test_recognition_keywords.py b/tests/v3/subscribeassistantenhanced/test_recognition_keywords.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_recognition_keywords.py rename to tests/v3/subscribeassistantenhanced/test_recognition_keywords.py diff --git a/tests/v2/subscribeassistantenhanced/test_recognition_samples.py b/tests/v3/subscribeassistantenhanced/test_recognition_samples.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_recognition_samples.py rename to tests/v3/subscribeassistantenhanced/test_recognition_samples.py diff --git a/tests/v2/subscribeassistantenhanced/test_refresh.py b/tests/v3/subscribeassistantenhanced/test_refresh.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_refresh.py rename to tests/v3/subscribeassistantenhanced/test_refresh.py diff --git a/tests/v2/subscribeassistantenhanced/test_scope.py b/tests/v3/subscribeassistantenhanced/test_scope.py similarity index 92% rename from tests/v2/subscribeassistantenhanced/test_scope.py rename to tests/v3/subscribeassistantenhanced/test_scope.py index 7ab9870d..7932b5e4 100644 --- a/tests/v2/subscribeassistantenhanced/test_scope.py +++ b/tests/v3/subscribeassistantenhanced/test_scope.py @@ -17,7 +17,7 @@ class TestBuildScope: def test_main_season_scope(self, make_subscribe, make_mediainfo): episodes = [_ep(1), _ep(2), _ep(3)] - sub = make_subscribe(tmdbid=100, season=1, episode_group=None) + sub = make_subscribe(media_id="100", season=1, episode_group=None) def fake_tmdb_episodes(tmdbid, season, episode_group=None): assert episode_group is None @@ -33,7 +33,7 @@ def fake_tmdb_episodes(tmdbid, season, episode_group=None): def test_episode_group_scope(self, make_subscribe, make_mediainfo): group_eps = [_ep(51), _ep(52)] - sub = make_subscribe(tmdbid=100, season=1, episode_group="eg-abc") + sub = make_subscribe(media_id="100", season=1, episode_group="eg-abc") def fake_tmdb_episodes(tmdbid, season, episode_group=None): assert episode_group == "eg-abc" @@ -44,6 +44,18 @@ def fake_tmdb_episodes(tmdbid, season, episode_group=None): assert scope.episode_group_id == "eg-abc" assert scope.total == 2 + def test_non_tmdb_identity_does_not_call_tmdb(self, make_subscribe, make_mediainfo): + """非 TMDB 订阅没有可靠转换结果时不得把来源原生 ID 传给 TMDB API。""" + calls = [] + sub = make_subscribe(media_source="douban", media_id="100", season=1) + + scope = build_scope(sub, make_mediainfo(), lambda *args, **kwargs: calls.append((args, kwargs))) + + assert scope.source == "tmdb_unavailable" + assert scope.tmdbid is None + assert scope.episodes == [] + assert calls == [] + class TestDetectHighRisk: """detect_high_risk 三条件检测。""" diff --git a/tests/v2/subscribeassistantenhanced/test_shared.py b/tests/v3/subscribeassistantenhanced/test_shared.py similarity index 94% rename from tests/v2/subscribeassistantenhanced/test_shared.py rename to tests/v3/subscribeassistantenhanced/test_shared.py index 0c14340e..443b01b4 100644 --- a/tests/v2/subscribeassistantenhanced/test_shared.py +++ b/tests/v3/subscribeassistantenhanced/test_shared.py @@ -183,23 +183,27 @@ def test_broken_subscribe_falls_back_to_id(self): class TestMatchSubscribe: def test_match(self): - sub = SimpleNamespace(id=1, name="测试", tmdbid=100, season=1, episode_group=None) - task = {"id": 1, "name": "测试", "tmdbid": 100, "season": 1} + sub = SimpleNamespace(id=1, name="测试", media_source="themoviedb", media_id="100", + season=1, episode_group=None) + task = {"id": 1, "name": "测试", "media_source": "themoviedb", "media_id": "100", "season": 1} assert match_subscribe(sub, task) is True def test_mismatch_id(self): - sub = SimpleNamespace(id=1, name="测试", tmdbid=100, season=1, episode_group=None) - task = {"id": 2, "name": "测试", "tmdbid": 100, "season": 1} + sub = SimpleNamespace(id=1, name="测试", media_source="themoviedb", media_id="100", + season=1, episode_group=None) + task = {"id": 2, "name": "测试", "media_source": "themoviedb", "media_id": "100", "season": 1} assert match_subscribe(sub, task) is False def test_empty_task(self): - sub = SimpleNamespace(id=1, name="测试", tmdbid=100, season=1, episode_group=None) + sub = SimpleNamespace(id=1, name="测试", media_source="themoviedb", media_id="100", + season=1, episode_group=None) assert match_subscribe(sub, {}) is False assert match_subscribe(sub, None) is False def test_episode_group_mismatch(self): - sub = SimpleNamespace(id=1, name="测试", tmdbid=100, season=1, episode_group="eg-1") - task = {"id": 1, "name": "测试", "tmdbid": 100, "season": 1} + sub = SimpleNamespace(id=1, name="测试", media_source="themoviedb", media_id="100", + season=1, episode_group="eg-1") + task = {"id": 1, "name": "测试", "media_source": "themoviedb", "media_id": "100", "season": 1} assert match_subscribe(sub, task) is False @@ -213,7 +217,9 @@ def test_resolve_media_type_accepts_enum_string_and_invalid(self): def test_identity_matches_current_subscribe(self): """持久化身份必须完整匹配当前订阅,避免 ID 复用串状态。""" - subscribe = SimpleNamespace(id=1, tmdbid=100, season=1, episode_group="eg-1") + subscribe = SimpleNamespace( + id=1, media_source="themoviedb", media_id="100", season=1, episode_group="eg-1", + ) identity = subscribe_identity(subscribe) assert identity_matches(identity, subscribe) is True diff --git a/tests/v2/subscribeassistantenhanced/test_signals.py b/tests/v3/subscribeassistantenhanced/test_signals.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_signals.py rename to tests/v3/subscribeassistantenhanced/test_signals.py diff --git a/tests/v2/subscribeassistantenhanced/test_site_evidence.py b/tests/v3/subscribeassistantenhanced/test_site_evidence.py similarity index 93% rename from tests/v2/subscribeassistantenhanced/test_site_evidence.py rename to tests/v3/subscribeassistantenhanced/test_site_evidence.py index b22158ad..ed2f979a 100644 --- a/tests/v2/subscribeassistantenhanced/test_site_evidence.py +++ b/tests/v3/subscribeassistantenhanced/test_site_evidence.py @@ -24,8 +24,8 @@ def _sub(total_episode=12, **kwargs): defaults = { "id": 1, "name": "测试剧", - "tmdbid": 100, - "doubanid": None, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": None, "type": "电视剧", @@ -55,6 +55,8 @@ def _ctx( end_season=None, match_source="tmdbid", media_info_is_target=False, + media_source="themoviedb", + media_id="100", ) -> SimpleNamespace: return SimpleNamespace( meta_info=SimpleNamespace( @@ -68,8 +70,13 @@ def _ctx( total_episode=site_total, tmdbid=explicit_tmdbid, doubanid=explicit_doubanid, + media_source=media_source, + media_id=media_id, + ), + media_info=SimpleNamespace( + tmdb_id=tmdbid, douban_id=doubanid, media_source=media_source, + media_id=media_id, season=season, type="电视剧", ), - media_info=SimpleNamespace(tmdb_id=tmdbid, douban_id=doubanid, season=season, type="电视剧"), torrent_info=SimpleNamespace(title=title, description=""), resource_source="rss", match_source=match_source, @@ -100,7 +107,8 @@ def _site_total_ahead(site_total=12, current_total=10, now=None, **kwargs): defaults = { "kind": "site_total_ahead", "confidence": "medium", - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": "", "type": "电视剧", @@ -124,7 +132,8 @@ def _site_complete_total(site_total=12, now=None, **kwargs): defaults = { "kind": "site_complete_total", "confidence": "medium", - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "season": 1, "episode_group": "", "type": "电视剧", @@ -285,8 +294,8 @@ def test_lower_candidate_diagnostic_does_not_mask_larger_strict_site_total_ahead def test_explicit_id_conflict_becomes_conflict_even_when_tmdb_matches(): evidence = classify_site_contexts( - _sub(total_episode=12, tmdbid=100, doubanid="douban-a"), - [_ctx(tmdbid=100, doubanid="douban-b", site_total=12)], + _sub(total_episode=12, media_source="douban", media_id="douban-a"), + [_ctx(tmdbid=100, doubanid="douban-b", media_source="douban", media_id="douban-b", site_total=12)], now=_now(), ) @@ -297,8 +306,9 @@ def test_explicit_id_conflict_becomes_conflict_even_when_tmdb_matches(): def test_meta_explicit_id_conflict_becomes_conflict_even_when_media_tmdb_matches(): evidence = classify_site_contexts( - _sub(total_episode=12, tmdbid=100, doubanid="douban-a"), - [_ctx(tmdbid=100, doubanid=None, explicit_doubanid="douban-b", site_total=12)], + _sub(total_episode=12, media_source="douban", media_id="douban-a"), + [_ctx(tmdbid=100, doubanid=None, explicit_doubanid="douban-b", + media_source="douban", media_id="douban-b", site_total=12)], now=_now(), ) @@ -308,7 +318,7 @@ def test_meta_explicit_id_conflict_becomes_conflict_even_when_media_tmdb_matches def test_candidate_extra_id_does_not_conflict_when_subscribe_lacks_that_id(): evidence = classify_site_contexts( - _sub(total_episode=12, tmdbid=100, doubanid=None), + _sub(total_episode=12), [_ctx(tmdbid=100, explicit_doubanid="douban-extra", site_total=12)], now=_now(), ) @@ -508,8 +518,8 @@ def test_applied_marker_clears_when_manual_total_takes_over(): def test_snapshot_ignores_row_with_stale_identity(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=200, season=1, total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, total_episode=12) + stale_subscribe = _sub(id=7, media_id="200", season=1, total_episode=12) store.save_snapshot(stale_subscribe, SiteEvidence.no_evidence(stale_subscribe, now=_now())) assert store.read_snapshot(subscribe) is None @@ -517,27 +527,27 @@ def test_snapshot_ignores_row_with_stale_identity(): def test_snapshot_ignores_row_with_stale_episode_group_identity(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, episode_group="group-a", total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=100, season=1, episode_group="group-b", total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, episode_group="group-a", total_episode=12) + stale_subscribe = _sub(id=7, media_id="100", season=1, episode_group="group-b", total_episode=12) store.save_snapshot(stale_subscribe, SiteEvidence.no_evidence(stale_subscribe, now=_now())) assert store.read_snapshot(subscribe) is None -def test_applied_marker_clears_when_douban_identity_changes(): +def test_applied_marker_clears_when_media_identity_changes(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, doubanid="douban-a", total_episode=12) + subscribe = _sub(id=7, media_source="douban", media_id="douban-a", total_episode=12) store.mark_applied(subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") - subscribe.doubanid = "douban-b" + subscribe.media_id = "douban-b" assert store.read_applied(subscribe) is None def test_applied_marker_ignores_row_with_stale_identity(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=200, season=1, total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, total_episode=12) + stale_subscribe = _sub(id=7, media_id="200", season=1, total_episode=12) store.mark_applied(stale_subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") assert store.read_applied(subscribe) is None @@ -545,8 +555,8 @@ def test_applied_marker_ignores_row_with_stale_identity(): def test_saving_snapshot_for_new_identity_drops_stale_applied_marker(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=200, season=1, total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, total_episode=12) + stale_subscribe = _sub(id=7, media_id="200", season=1, total_episode=12) store.mark_applied(stale_subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") store.save_snapshot(subscribe, SiteEvidence.no_evidence(subscribe, now=_now())) @@ -557,8 +567,8 @@ def test_saving_snapshot_for_new_identity_drops_stale_applied_marker(): def test_marking_applied_for_new_identity_drops_stale_snapshot(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=200, season=1, total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, total_episode=12) + stale_subscribe = _sub(id=7, media_id="200", season=1, total_episode=12) store.save_snapshot(stale_subscribe, SiteEvidence.no_evidence(stale_subscribe, now=_now())) store.mark_applied(subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") @@ -569,8 +579,8 @@ def test_marking_applied_for_new_identity_drops_stale_snapshot(): def test_saving_snapshot_for_new_season_identity_drops_stale_applied_marker(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=2, total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=100, season=1, total_episode=12) + subscribe = _sub(id=7, media_id="100", season=2, total_episode=12) + stale_subscribe = _sub(id=7, media_id="100", season=1, total_episode=12) store.mark_applied(stale_subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") store.save_snapshot(subscribe, SiteEvidence.no_evidence(subscribe, now=_now())) @@ -581,8 +591,8 @@ def test_saving_snapshot_for_new_season_identity_drops_stale_applied_marker(): def test_saving_snapshot_for_new_type_identity_drops_stale_applied_marker(): store = SiteEvidenceStore(_task_manager()) - subscribe = _sub(id=7, tmdbid=100, season=1, type="电视剧", total_episode=12) - stale_subscribe = _sub(id=7, tmdbid=100, season=1, type="电影", total_episode=12) + subscribe = _sub(id=7, media_id="100", season=1, type="电视剧", total_episode=12) + stale_subscribe = _sub(id=7, media_id="100", season=1, type="电影", total_episode=12) store.mark_applied(stale_subscribe, applied_total=12, applied_base_total=10, reason="site_total_ahead") store.save_snapshot(subscribe, SiteEvidence.no_evidence(subscribe, now=_now())) @@ -840,7 +850,8 @@ def test_refresh_handler_skips_create_event_without_subscribe(): data = SubscribeEpisodesRefreshEventData( current_total_episode=12, subscribe_id=None, - tmdbid=312849, + media_source="themoviedb", + media_id="312849", season=1, scene="create", ) @@ -1005,8 +1016,8 @@ def test_refresh_handler_ignores_expired_or_identity_mismatch_evidence(): assert expired_data.updated is False mismatch_store = SiteEvidenceStore(_task_manager()) - stale_subscribe = _sub(id=7, tmdbid=200, total_episode=10) - mismatch_store.save_snapshot(stale_subscribe, _site_total_ahead(site_total=12, tmdbid=200)) + stale_subscribe = _sub(id=7, media_id="200", total_episode=10) + mismatch_store.save_snapshot(stale_subscribe, _site_total_ahead(site_total=12, media_id="200")) mismatch_data = SubscribeEpisodesRefreshEventData(current_total_episode=10, subscribe_id=7, season=1) _handler(subscribe, mismatch_store).handle_refresh(mismatch_data) diff --git a/tests/v2/subscribeassistantenhanced/test_subscription_cleanup.py b/tests/v3/subscribeassistantenhanced/test_subscription_cleanup.py similarity index 91% rename from tests/v2/subscribeassistantenhanced/test_subscription_cleanup.py rename to tests/v3/subscribeassistantenhanced/test_subscription_cleanup.py index fb0eeafe..0604ecff 100644 --- a/tests/v2/subscribeassistantenhanced/test_subscription_cleanup.py +++ b/tests/v3/subscribeassistantenhanced/test_subscription_cleanup.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + from app.schemas.types import MediaType from subscribeassistantenhanced.cleanup.subscription import SubscriptionCleanup @@ -11,7 +13,7 @@ def _sub(**kwargs): """构造完整订阅替身,默认包含 Subscribe 固定字段。""" defaults = dict( - id=1, name="测试剧", tmdbid=100, season=1, + id=1, name="测试剧", media_source="themoviedb", media_id="100", season=1, episode_group=None, state="R", type="电视剧", best_version=1, best_version_full=1, @@ -58,11 +60,16 @@ def _history(self, hid, src_fi, dest_fi, src, dl_hash, episodes="E01"): "episodes": episodes}, ) - def _transfer_event(self, episode=1, tmdb_id=100, season=1): + def _transfer_event(self, episode=1, media_source="themoviedb", media_id="100", season=1): """构造带目标文件集数的整理拦截事件。""" return SimpleNamespace(event_data=SimpleNamespace( cancel=False, - mediainfo=SimpleNamespace(tmdb_id=tmdb_id, type=MediaType.TV, season=season), + mediainfo=SimpleNamespace( + media_source=media_source, + media_id=media_id, + type=MediaType.TV, + season=season, + ), meta=SimpleNamespace(begin_season=season, episode_list=[episode]), fileitem=SimpleNamespace(path=f"/src/测试剧 S01E{episode:02d}.mkv"), target_path=f"/dest/测试剧 S01E{episode:02d}.mkv", @@ -73,7 +80,7 @@ def test_resource_download_clear_deletes_src_and_emits_event(self): notifies = [] orch, store, deletes, events, hist_deletes = self._orch_clear() orch._notify = lambda title, text=None, **kwargs: notifies.append((title, text, kwargs)) - orch._get_histories = lambda tmdbid, mtype, season=None: [h] + orch._get_histories = lambda media_source, media_id, mtype, season=None: [h] sub = _sub(name="X", total_episode=1, lack_episode=0) orch.handle_resource_download_history_clear(sub, episodes=[1]) assert deletes == [{"path": "/src/a.mkv"}] @@ -81,7 +88,8 @@ def test_resource_download_clear_deletes_src_and_emits_event(self): assert hist_deletes == ["1"] assert len(store["subscription_cleanup_histories"]) == 1 task = next(iter(store["subscription_cleanup_histories"].values())) - assert task["tmdbid"] == 100 + assert task["media_source"] == "themoviedb" + assert task["media_id"] == "100" assert task["target_episodes"] == [] assert notifies[0][0].endswith("即将开始洗版下载,已处理 1 条整理记录对应的源文件") assert notifies[0][1] is None @@ -90,6 +98,47 @@ def test_resource_download_clear_deletes_src_and_emits_event(self): assert notifies[0][2]["image"] == "subscribe.jpg" assert task["subscribe_image"] == "subscribe.jpg" + def test_migrate_snapshot_identities_is_idempotent(self): + """旧 TMDB 快照只迁移一次,已有 V3 身份和无法识别的记录保持原样。""" + store = {"subscription_cleanup_histories": { + "legacy": {"tmdbid": 100, "season": "S01"}, + "canonical": {"media_source": "douban", "media_id": "200", "season": "S01"}, + "unknown": {"season": "S01"}, + }} + saves = [] + + def update(key, updater): + store[key] = updater(store.get(key, {})) + saves.append(dict(store[key])) + return store[key] + + orch = SubscriptionCleanup(task_data_update=update) + + assert orch.migrate_snapshot_identities() == 1 + assert store["subscription_cleanup_histories"] == { + "legacy": {"media_source": "themoviedb", "media_id": "100", "season": "S01"}, + "canonical": {"media_source": "douban", "media_id": "200", "season": "S01"}, + "unknown": {"season": "S01"}, + } + assert orch.migrate_snapshot_identities() == 0 + assert len(saves) == 2 + + def test_migrate_snapshot_identity_save_failure_keeps_legacy_data(self): + """底层持久化失败时不得先删除旧快照。""" + store = {"subscription_cleanup_histories": {"legacy": {"tmdbid": 100}}} + + def update(key, updater): + original = store[key] + updated = updater({task_key: dict(task) for task_key, task in original.items()}) + assert updated["legacy"]["media_id"] == "100" + raise RuntimeError("save failed") + + orch = SubscriptionCleanup(task_data_update=update) + + with pytest.raises(RuntimeError, match="save failed"): + orch.migrate_snapshot_identities() + assert store == {"subscription_cleanup_histories": {"legacy": {"tmdbid": 100}}} + def test_full_best_version_cleans_entire_season_after_cover_guard_passes(self): """全集洗版资源覆盖目标范围后,旧整理记录按同季整体清理。""" histories = [ @@ -406,7 +455,7 @@ def test_tv_clear_type_processes_tv_subscription(self): orch.handle_resource_download_history_clear(sub, episodes=FULL_SEASON_EPISODES) - orch._get_histories.assert_called_once_with(100, "电视剧", "S01") + orch._get_histories.assert_called_once_with("themoviedb", "100", "电视剧", "S01") def test_tv_history_clear_skips_when_season_is_missing(self): """剧集缺少季号时不得退化为查询并清理同一 TMDB 的全部季。""" @@ -600,7 +649,8 @@ def test_same_tmdb_episode_tasks_do_not_overwrite_each_other(self): def test_normal_subscription_multi_episode_snapshot_consumes_current_episode_only(self): """普通订阅多集快照按本次文件级整理集数消费,未整理集保留在快照中。""" store = {"subscription_cleanup_histories": {"task-e1-e2": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -627,7 +677,8 @@ def test_normal_subscription_multi_episode_snapshot_consumes_current_episode_onl def test_episode_best_version_multi_episode_snapshot_consumes_current_episode_only(self): """分集洗版多集快照按当前整理集消费,不扩大到整季清理。""" store = {"subscription_cleanup_histories": {"task-e1-e2": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "best_version_episode", @@ -654,7 +705,8 @@ def test_episode_best_version_multi_episode_snapshot_consumes_current_episode_on def test_normal_subscription_directory_intercept_without_meta_is_ignored(self): """普通订阅目录级整理事件没有文件 meta 时不得用路径兜底消费快照。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -667,7 +719,9 @@ def test_normal_subscription_directory_intercept_without_meta_is_ignored(self): orch, _store, deletes, _events, _histories = self._orch_clear(store) event = SimpleNamespace(event_data=SimpleNamespace( cancel=False, - mediainfo=SimpleNamespace(tmdb_id=100, type=MediaType.TV, season=1), + mediainfo=SimpleNamespace( + media_source="themoviedb", media_id="100", type=MediaType.TV, season=1, + ), fileitem=SimpleNamespace(path="/src/测试剧 S01E01-E02/"), target_path="/dest/测试剧 S01E01-E02/", )) @@ -680,7 +734,8 @@ def test_normal_subscription_directory_intercept_without_meta_is_ignored(self): def test_normal_subscription_claims_episode_before_deleting_dest(self): """目标文件删除前必须先占用快照记录,避免并发整理重复消费同一集。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -720,7 +775,8 @@ def delete_media_file(fileitem): def test_dest_delete_failure_keeps_snapshot_and_skips_notification(self): """目标文件删除失败时不得消费快照或发送已处理通知。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -748,7 +804,8 @@ def test_dest_delete_failure_keeps_snapshot_and_skips_notification(self): def test_dest_delete_exception_keeps_snapshot_and_skips_notification(self): """目标文件删除抛错时按失败处理,恢复快照且不发送已处理通知。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -780,7 +837,8 @@ def delete_media_file(_fileitem): def test_history_clear_restores_claim_when_dest_clear_raises(self): """目标文件清理过程出现未捕获异常时,已 claim 的快照必须恢复。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -806,7 +864,8 @@ def clear_transfer_dest_histories(_task): def test_partial_dest_delete_failure_restores_only_failed_history(self): """同一整理事件部分目标删除失败时,仅失败记录回到快照。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -841,7 +900,8 @@ def delete_media_file(fileitem): def test_failed_episode_restore_merges_with_remaining_snapshot(self): """按集 claim 后若删除失败,应把失败集并回仍保留的其他集快照。""" store = {"subscription_cleanup_histories": {"task-e1-e2": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -873,7 +933,8 @@ def test_failed_episode_restore_merges_with_remaining_snapshot(self): def test_restore_failed_histories_keeps_multiple_no_id_files(self): """无整理记录 id 时按文件路径区分记录,避免同集多文件恢复时被误去重。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "normal", @@ -939,7 +1000,8 @@ def test_history_identity_uses_id_or_serialized_paths(self): def test_episode_best_version_directory_intercept_without_meta_is_ignored(self): """分集洗版目录级整理事件没有文件 meta 时不得用路径兜底消费快照。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "best_version_episode", @@ -952,7 +1014,9 @@ def test_episode_best_version_directory_intercept_without_meta_is_ignored(self): orch, _store, deletes, _events, _histories = self._orch_clear(store) event = SimpleNamespace(event_data=SimpleNamespace( cancel=False, - mediainfo=SimpleNamespace(tmdb_id=100, type=MediaType.TV, season=1), + mediainfo=SimpleNamespace( + media_source="themoviedb", media_id="100", type=MediaType.TV, season=1, + ), fileitem=SimpleNamespace(path="/src/测试剧 S01E01/"), target_path="/dest/测试剧 S01E01/", )) @@ -966,7 +1030,8 @@ def test_transfer_intercept_out_of_order_episode_consumes_matching_task(self): """整理事件乱序到达时不得把 S01E02 路径中的 S01 误当作 E01 消费。""" store = {"subscription_cleanup_histories": { "task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "target_episodes": [1], @@ -976,7 +1041,8 @@ def test_transfer_intercept_out_of_order_episode_consumes_matching_task(self): "time": time.time(), }, "task-e2": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "target_episodes": [2], @@ -996,7 +1062,8 @@ def test_transfer_intercept_out_of_order_episode_consumes_matching_task(self): def test_transfer_intercept_special_season_zero_requires_matching_season(self): """S00 清理事务必须只被 S00 整理事件消费,不能混入主季。""" store = {"subscription_cleanup_histories": {"task-s0": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S00", "target_episodes": [1], @@ -1018,7 +1085,8 @@ def test_transfer_intercept_special_season_zero_requires_matching_season(self): def test_transfer_intercept_prefers_meta_special_season_zero(self): """整理事件 meta 明确为 S0 时,不得被 mediainfo 的默认主季覆盖。""" store = {"subscription_cleanup_histories": {"task-s0": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S00", "target_episodes": [1], @@ -1030,7 +1098,9 @@ def test_transfer_intercept_prefers_meta_special_season_zero(self): orch, _store, deletes, _events, _histories = self._orch_clear(store) event = SimpleNamespace(event_data=SimpleNamespace( cancel=False, - mediainfo=SimpleNamespace(tmdb_id=100, type=MediaType.TV, season=1), + mediainfo=SimpleNamespace( + media_source="themoviedb", media_id="100", type=MediaType.TV, season=1, + ), meta=SimpleNamespace(begin_season=0), fileitem=SimpleNamespace(path="/src/测试剧 S00E01.mkv"), target_path="/dest/测试剧 S00E01.mkv", @@ -1044,7 +1114,8 @@ def test_transfer_intercept_prefers_meta_special_season_zero(self): def test_transfer_intercept_tv_task_requires_matching_season(self): """剧集清理事务有季号时,整理事件缺少季号不得降级消费。""" store = {"subscription_cleanup_histories": {"task-e1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "target_episodes": [1], @@ -1056,7 +1127,9 @@ def test_transfer_intercept_tv_task_requires_matching_season(self): orch, _store, deletes, _events, _histories = self._orch_clear(store) event = SimpleNamespace(event_data=SimpleNamespace( cancel=False, - mediainfo=SimpleNamespace(tmdb_id=100, type=MediaType.TV), + mediainfo=SimpleNamespace( + media_source="themoviedb", media_id="100", type=MediaType.TV, + ), fileitem=SimpleNamespace(path="/src/测试剧 S01E01.mkv"), target_path="/dest/测试剧 S01E01.mkv", )) @@ -1069,7 +1142,8 @@ def test_transfer_intercept_tv_task_requires_matching_season(self): def test_transfer_intercept_clear_deletes_dest_and_removes_snapshot(self): notifies = [] store = {"subscription_cleanup_histories": {"task-1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "best_version", @@ -1096,7 +1170,8 @@ def test_transfer_intercept_best_version_empty_target_consumes_whole_snapshot(se """洗版按季清理快照不绑定集数,命中同季整理事件后整体消费。""" notifies = [] store = {"subscription_cleanup_histories": {"task-full": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "scene": "best_version", @@ -1124,14 +1199,17 @@ def test_transfer_intercept_without_snapshot_returns_false(self): """无订阅清理快照时整理拦截不产生日志噪音。""" orch, _store, _deletes, _events, _hist = self._orch_clear({}) event = SimpleNamespace(event_data=SimpleNamespace( - cancel=False, mediainfo=SimpleNamespace(tmdb_id=100))) + cancel=False, + mediainfo=SimpleNamespace(media_source="themoviedb", media_id="100"), + )) assert orch.handle_history_clear(event) is False def test_transfer_intercept_clear_returns_true(self): """命中清理快照并完成清理时返回 True,供事件层输出结果日志。""" store = {"subscription_cleanup_histories": {"task-1": { - "tmdbid": 100, "type": "电视剧", "season": "S01", + "media_source": "themoviedb", "media_id": "100", + "type": "电视剧", "season": "S01", "target_episodes": [1], "subscribe_desc": "X", "mode_label": "洗版", "histories": [], "time": time.time(), }}} @@ -1143,7 +1221,8 @@ def test_transfer_intercept_clear_returns_true(self): def test_transfer_intercept_drops_expired_history_without_deleting_dest(self): """超过 36 小时的清理事务失效,不得删除旧媒体库目标文件。""" store = {"subscription_cleanup_histories": {"task-1": { - "tmdbid": 100, + "media_source": "themoviedb", + "media_id": "100", "type": "电视剧", "season": "S01", "target_episodes": [1], diff --git a/tests/v2/subscribeassistantenhanced/test_task.py b/tests/v3/subscribeassistantenhanced/test_task.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_task.py rename to tests/v3/subscribeassistantenhanced/test_task.py diff --git a/tests/v2/subscribeassistantenhanced/test_timeout.py b/tests/v3/subscribeassistantenhanced/test_timeout.py similarity index 98% rename from tests/v2/subscribeassistantenhanced/test_timeout.py rename to tests/v3/subscribeassistantenhanced/test_timeout.py index 5f4718dd..5bb00505 100644 --- a/tests/v2/subscribeassistantenhanced/test_timeout.py +++ b/tests/v3/subscribeassistantenhanced/test_timeout.py @@ -19,10 +19,14 @@ def _store_mgr(store=None): ) -def _sub(tmdbid, sid=1, season=1, episode_group=None): +def _sub(media_id, sid=1, season=1, episode_group=None): """构造带完整媒体身份的订阅。""" return SimpleNamespace( - id=sid, tmdbid=tmdbid, season=season, episode_group=episode_group + id=sid, + media_source="themoviedb", + media_id=str(media_id), + season=season, + episode_group=episode_group, ) @@ -70,7 +74,8 @@ def test_reused_id_replaces_mismatched_observation_identity(self): time.sleep(0.001) mgr.record_observation(new) - assert store["blocks"]["1"]["identity"]["tmdbid"] == 200 + assert store["blocks"]["1"]["identity"]["media_source"] == "themoviedb" + assert store["blocks"]["1"]["identity"]["media_id"] == "200" assert store["blocks"]["1"]["blocked_at"] > old_time diff --git a/tests/v2/subscribeassistantenhanced/test_torrent.py b/tests/v3/subscribeassistantenhanced/test_torrent.py similarity index 93% rename from tests/v2/subscribeassistantenhanced/test_torrent.py rename to tests/v3/subscribeassistantenhanced/test_torrent.py index 1077d8bd..0985c6ad 100644 --- a/tests/v2/subscribeassistantenhanced/test_torrent.py +++ b/tests/v3/subscribeassistantenhanced/test_torrent.py @@ -2,10 +2,40 @@ from types import SimpleNamespace from qbittorrentapi.torrents import TorrentInfoList +from transmission_rpc import Torrent from transmission_rpc.torrent import Status from subscribeassistantenhanced.download.torrent import TorrentAdapter, TorrentInfo -from ..torrent_sdk_fixtures import make_tr_v7_torrent + + +def make_tr_v7_torrent(**overrides): + """构造 transmission-rpc 7.x 的真实 Torrent 对象。""" + fields = { + "id": 1, + "name": "TR.Test", + "hashString": "tr_hash_1", + "doneDate": 1000, + "addedDate": 900, + "activityDate": 1100, + "totalSize": 4096000, + "sizeWhenDone": 4096000, + "percentDone": 1.0, + "downloadedEver": 4096000, + "uploadedEver": 8192000, + "uploadRatio": 2.0, + "secondsDownloading": 100, + "secondsSeeding": 200, + "rateUpload": 300, + "status": 6, + "labels": ["tag1"], + "trackers": [{"announce": "https://tracker/announce"}], + "trackerStats": [ + {"tier": 0, "lastAnnounceResult": "OK"}, + {"tier": -1, "lastAnnounceResult": "SKIP"}, + ], + } + fields.update(overrides) + return Torrent(fields=fields) class TestTorrentInfoHelpers: diff --git a/tests/v2/subscribeassistantenhanced/test_types.py b/tests/v3/subscribeassistantenhanced/test_types.py similarity index 100% rename from tests/v2/subscribeassistantenhanced/test_types.py rename to tests/v3/subscribeassistantenhanced/test_types.py diff --git a/tests/v2/subscribeassistantenhanced/test_verifier.py b/tests/v3/subscribeassistantenhanced/test_verifier.py similarity index 83% rename from tests/v2/subscribeassistantenhanced/test_verifier.py rename to tests/v3/subscribeassistantenhanced/test_verifier.py index b9327303..0fb58172 100644 --- a/tests/v2/subscribeassistantenhanced/test_verifier.py +++ b/tests/v3/subscribeassistantenhanced/test_verifier.py @@ -7,9 +7,9 @@ from subscribeassistantenhanced.engine.types import SeasonScope -def _sub(tmdbid=100, season=1, episode_group=None, total=12, best_version=0, best_version_full=0): +def _sub(media_id="100", season=1, episode_group=None, total=12, best_version=0, best_version_full=0): return SimpleNamespace( - id=1, tmdbid=tmdbid, season=season, episode_group=episode_group, + id=1, media_source="themoviedb", media_id=str(media_id), season=season, episode_group=episode_group, total_episode=total, best_version=best_version, best_version_full=best_version_full, name="测试剧", year="2026", type="电视剧", keyword="测试关键字", save_path="/media", sites="site1", downloader="qbittorrent", @@ -51,7 +51,8 @@ def test_saves_snapshot(self): v.snapshot(_sub(best_version=1, best_version_full=1), None, scope) snaps = store.get("snapshots", {}).get("list", []) assert len(snaps) == 1 - assert snaps[0]["tmdbid"] == 100 + assert snaps[0]["media_source"] == "themoviedb" + assert snaps[0]["media_id"] == "100" assert snaps[0]["total_at_completion"] == 12 assert snaps[0]["subscribe_config"]["filter"] == "rule1" assert snaps[0]["subscribe_config"]["filter_groups"] == ["group1"] @@ -90,7 +91,7 @@ def test_snapshot_falls_back_to_subscribe_image(self): assert store["snapshots"]["list"][0]["subscribe_image"] == "subscribe.jpg" def test_dedup_by_key(self): - """同 (tmdbid, season, episode_group_id) 幂等去重。""" + """同 (media_source, media_id, season, episode_group_id) 幂等去重。""" store = {} v = _verifier(store) scope = SeasonScope(tmdbid=100, season=1, source="main_season") @@ -121,7 +122,7 @@ class TestVerifyAll: def test_no_change_keeps_snapshot(self): """total 不变 → 保留快照。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {}, }]}} @@ -132,7 +133,7 @@ def test_no_change_keeps_snapshot(self): def test_increase_triggers_rebuild(self): """total 增加 → 重建 + 移除快照。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_image": "subscribe.jpg", "subscribe_config": {"name": "测试剧", "season": 1}, @@ -152,7 +153,7 @@ def test_increase_triggers_rebuild(self): def test_rebuild_failure_keeps_snapshot_for_retry(self): """真实重建失败时必须保留快照,避免丢失后续补救机会。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } @@ -169,7 +170,7 @@ def test_rebuild_failure_keeps_snapshot_for_retry(self): def test_expired_removed(self): """超过保留期 → 移除。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time() - 100 * 86400, "subscribe_config": {}, @@ -183,13 +184,13 @@ def test_cleanup_expired_uses_configured_retention_without_tmdb(self): now = time.time() store = {"snapshots": {"list": [ { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": now - 31 * 86400, "subscribe_config": {}, }, { - "tmdbid": 101, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "101", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": now - 29 * 86400, "subscribe_config": {}, @@ -200,13 +201,13 @@ def test_cleanup_expired_uses_configured_retention_without_tmdb(self): assert v.cleanup_expired() == 1 - assert [snap["tmdbid"] for snap in store["snapshots"]["list"]] == [101] + assert [snap["media_id"] for snap in store["snapshots"]["list"]] == ["101"] tmdb_fn.assert_not_called() def test_scope_aware_group_verification(self): """group scope 快照用 group 集数验证。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": "eg-1", + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": "eg-1", "total_at_completion": 16, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} @@ -224,12 +225,12 @@ def tmdb_fn(tmdbid, season, episode_group=None): def test_rebuild_deletes_best_version(self): """重建时删除已有洗版订阅。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} existing_bv = SimpleNamespace( - id=99, tmdbid=100, season=1, episode_group=None, + id=99, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, total_episode=12, name="测试剧", save_path=None, sites=None, filter=None, filter_groups=[], @@ -253,13 +254,13 @@ def test_rebuild_deletes_best_version(self): def test_deleted_full_subscribe_rebuild_failure_keeps_snapshot(self): """全集洗版删除后重建失败时保留快照,供后续巡检重试。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } store = {"snapshots": {"list": [snap]}} full_subscribe = SimpleNamespace( - id=99, tmdbid=100, season=1, episode_group=None, + id=99, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, total_episode=15, name="测试剧", ) @@ -278,12 +279,12 @@ def test_deleted_full_subscribe_rebuild_failure_keeps_snapshot(self): def test_rebuild_exception_keeps_snapshot_and_continues_next_snapshot(self): """单条重建异常不能消费快照,也不能阻断后续快照。""" first = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "第一部"}, } second = { - "tmdbid": 101, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "101", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "第二部"}, } @@ -297,24 +298,24 @@ def test_rebuild_exception_keeps_snapshot_and_continues_next_snapshot(self): v.verify_all() - assert [snap["tmdbid"] for snap in store["snapshots"]["list"]] == [100] + assert [snap["media_id"] for snap in store["snapshots"]["list"]] == ["100"] assert rebuild.call_count == 2 def test_delete_exception_keeps_snapshot_and_continues_next_snapshot(self): """单条全集洗版删除异常不能阻断其他完成快照的纠错。""" first = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "第一部"}, } second = { - "tmdbid": 101, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "101", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "第二部"}, } store = {"snapshots": {"list": [first, second]}} first_full = SimpleNamespace( - id=99, tmdbid=100, season=1, episode_group=None, + id=99, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, total_episode=15, name="第一部", ) @@ -328,23 +329,23 @@ def test_delete_exception_keeps_snapshot_and_continues_next_snapshot(self): v.verify_all() - assert [snap["tmdbid"] for snap in store["snapshots"]["list"]] == [100] + assert [snap["media_id"] for snap in store["snapshots"]["list"]] == ["100"] def test_mixed_full_and_normal_subscribes_rebuilds_after_removing_only_full(self): """同范围混合订阅时只删除全集洗版,并仍执行重建接管校验。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } store = {"snapshots": {"list": [snap]}} full_subscribe = SimpleNamespace( - id=99, tmdbid=100, season=1, episode_group=None, + id=99, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", best_version=1, best_version_full=1, total_episode=15, name="测试剧", ) normal_subscribe = SimpleNamespace( - id=100, tmdbid=100, season=1, episode_group=None, + id=100, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", best_version=0, best_version_full=0, total_episode=15, name="测试剧", ) @@ -365,12 +366,12 @@ def test_mixed_full_and_normal_subscribes_rebuilds_after_removing_only_full(self def test_rebuild_does_not_touch_different_episode_group(self): """同 TMDB 同季但不同剧集组不是同一目标范围。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": "eg-new", + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": "eg-new", "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} other_group = SimpleNamespace( - id=99, tmdbid=100, season=1, + id=99, media_source="themoviedb", media_id="100", season=1, episode_group="eg-old", type="电视剧", best_version=1, best_version_full=1, save_path=None, sites=None, filter=None, filter_groups=[], ) @@ -388,7 +389,7 @@ def test_rebuild_does_not_touch_different_episode_group(self): def test_rebuild_sends_notification(self): store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试剧"}, }]}} @@ -403,13 +404,13 @@ def test_rebuild_sends_notification(self): def test_covered_active_normal_subscribe_consumes_snapshot(self): """已有普通订阅覆盖最新 TMDB 总集数时,完成快照已完成交接。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } store = {"snapshots": {"list": [snap]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, + id=50, media_source="themoviedb", media_id="100", season=1, episode_group=None, total_episode=15, best_version=0, best_version_full=0, ) validate = MagicMock(return_value=True) @@ -427,13 +428,13 @@ def test_covered_active_normal_subscribe_consumes_snapshot(self): def test_covered_subscribe_without_validator_keeps_snapshot(self): """校验依赖缺失时必须保留快照,不能退回仅按总集数判断成功。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } store = {"snapshots": {"list": [snap]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, + id=50, media_source="themoviedb", media_id="100", season=1, episode_group=None, total_episode=15, best_version=0, best_version_full=0, ) v = _verifier(store, tmdb_fn=lambda *a, **kw: [object()] * 15) @@ -446,12 +447,12 @@ def test_covered_subscribe_without_validator_keeps_snapshot(self): def test_lagging_active_normal_subscribe_keeps_snapshot(self): """已有普通订阅未覆盖最新 TMDB 总集数时,不得误判纠错成功。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, + id=50, media_source="themoviedb", media_id="100", season=1, episode_group=None, total_episode=12, best_version=0, best_version_full=0, ) rebuild = MagicMock(return_value=True) @@ -471,13 +472,13 @@ def test_lagging_active_normal_subscribe_keeps_snapshot(self): def test_covered_subscribe_with_wrong_resolved_mode_keeps_snapshot(self): """重试轮次不能仅凭集数覆盖消费模式不符的既有订阅。""" snap = { - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, } store = {"snapshots": {"list": [snap]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, + id=50, media_source="themoviedb", media_id="100", season=1, episode_group=None, total_episode=15, best_version=0, best_version_full=0, ) validate = MagicMock(return_value=False) @@ -496,12 +497,12 @@ def test_covered_subscribe_with_wrong_resolved_mode_keeps_snapshot(self): def test_covered_full_best_version_is_replaced_for_added_episodes(self): """全集洗版即使总集数已同步,也必须重建才能接管新增集数。""" store = {"snapshots": {"list": [{ - "tmdbid": 100, "season": 1, "episode_group_id": None, + "media_source": "themoviedb", "media_id": "100", "season": 1, "episode_group_id": None, "total_at_completion": 12, "completed_at": time.time(), "subscribe_config": {"name": "测试"}, }]}} existing = SimpleNamespace( - id=50, tmdbid=100, season=1, episode_group=None, + id=50, media_source="themoviedb", media_id="100", season=1, episode_group=None, type="电视剧", total_episode=15, best_version=1, best_version_full=1, ) diff --git a/tests/v2/subscribeassistantenhanced/test_volatility.py b/tests/v3/subscribeassistantenhanced/test_volatility.py similarity index 92% rename from tests/v2/subscribeassistantenhanced/test_volatility.py rename to tests/v3/subscribeassistantenhanced/test_volatility.py index 1e5211b4..8328bf62 100644 --- a/tests/v2/subscribeassistantenhanced/test_volatility.py +++ b/tests/v3/subscribeassistantenhanced/test_volatility.py @@ -165,8 +165,12 @@ def test_recent_change_detail_without_subscribe_id_returns_none(self): def test_recent_change_detail_rejects_reused_subscribe_identity(self): """订阅 ID 被新媒体复用时,旧媒体的变化明细不能继续展示。""" now = time.time() - old = SimpleNamespace(id=41, tmdbid=100, season=1, episode_group=None) - new = SimpleNamespace(id=41, tmdbid=200, season=1, episode_group=None) + old = SimpleNamespace( + id=41, media_source="themoviedb", media_id="100", season=1, episode_group=None + ) + new = SimpleNamespace( + id=41, media_source="themoviedb", media_id="200", season=1, episode_group=None + ) self.tracker.record(total=10, subscribe=old) self.tracker.record(total=12, subscribe=old) self.store["volatility"]["41"]["last_total_changed_at"] = now @@ -269,7 +273,9 @@ def test_legacy_recent_total_change_survives_after_next_sample(self): def test_legacy_recent_total_change_survives_subscribe_object_migration(self): """带订阅对象写入 list 结构记录时,也要写入标准结构并保留窗口内变化状态。""" now = time.time() - subscribe = SimpleNamespace(id=41, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace( + id=41, media_source="themoviedb", media_id="100", season=1, episode_group=None + ) self.store["volatility"] = { "41": [ {"total": 36, "ts": now - 3600}, @@ -281,7 +287,8 @@ def test_legacy_recent_total_change_survives_subscribe_object_migration(self): self.tracker.record(total=33, subscribe=subscribe) entry = self.store["volatility"]["41"] - assert entry["identity"]["tmdbid"] == 100 + assert entry["identity"]["media_source"] == "themoviedb" + assert entry["identity"]["media_id"] == "100" assert entry["last_total_changed_at"] is not None assert entry["unstable_until"] is not None assert self.tracker.is_stable(subscribe=subscribe) is False @@ -289,7 +296,9 @@ def test_legacy_recent_total_change_survives_subscribe_object_migration(self): def test_legacy_recent_total_change_can_be_read_with_subscribe_object(self): """只读旧 list 记录时,订阅对象路径不能把旧窗口直接删除。""" now = time.time() - subscribe = SimpleNamespace(id=41, tmdbid=100, season=1, episode_group=None) + subscribe = SimpleNamespace( + id=41, media_source="themoviedb", media_id="100", season=1, episode_group=None + ) self.store["volatility"] = { "41": [ {"total": 36, "ts": now - 3600}, @@ -310,13 +319,18 @@ def test_multiple_subscribes_independent(self): def test_reused_id_with_different_media_starts_new_history(self): """同一数据库 ID 被新媒体复用时不得继承旧媒体的总集数变化。""" - old = SimpleNamespace(id=41, tmdbid=100, season=1, episode_group=None) - new = SimpleNamespace(id=41, tmdbid=200, season=2, episode_group=None) + old = SimpleNamespace( + id=41, media_source="themoviedb", media_id="100", season=1, episode_group=None + ) + new = SimpleNamespace( + id=41, media_source="themoviedb", media_id="200", season=2, episode_group=None + ) self.tracker.record(total=10, subscribe=old) self.tracker.record(total=15, subscribe=new) assert self.tracker.is_stable(subscribe=new) is True entry = self.store["volatility"]["41"] - assert entry["identity"]["tmdbid"] == 200 + assert entry["identity"]["media_source"] == "themoviedb" + assert entry["identity"]["media_id"] == "200" assert [record["total"] for record in entry["records"]] == [15] diff --git a/tests/v2/subscribeassistantenhanced/test_vue_config_contract.py b/tests/v3/subscribeassistantenhanced/test_vue_config_contract.py similarity index 96% rename from tests/v2/subscribeassistantenhanced/test_vue_config_contract.py rename to tests/v3/subscribeassistantenhanced/test_vue_config_contract.py index 7eaf46a5..7d2aa025 100644 --- a/tests/v2/subscribeassistantenhanced/test_vue_config_contract.py +++ b/tests/v3/subscribeassistantenhanced/test_vue_config_contract.py @@ -18,10 +18,10 @@ REPO_ROOT = Path(__file__).resolve().parents[3] -README_PATH = REPO_ROOT / "plugins.v2/subscribeassistantenhanced/README.md" -FRONTEND_PACKAGE_PATH = REPO_ROOT / "plugins.v2/subscribeassistantenhanced/frontend/package.json" -DEFAULTS_PATH = REPO_ROOT / "plugins.v2/subscribeassistantenhanced/frontend/src/config/defaults.ts" -FIELDS_PATH = REPO_ROOT / "plugins.v2/subscribeassistantenhanced/frontend/src/config/fields.ts" +README_PATH = REPO_ROOT / "plugins.v3/subscribeassistantenhanced/README.md" +FRONTEND_PACKAGE_PATH = REPO_ROOT / "plugins.v3/subscribeassistantenhanced/frontend/package.json" +DEFAULTS_PATH = REPO_ROOT / "plugins.v3/subscribeassistantenhanced/frontend/src/config/defaults.ts" +FIELDS_PATH = REPO_ROOT / "plugins.v3/subscribeassistantenhanced/frontend/src/config/fields.ts" TAB_GROUPS = { "订阅清理": "cleanup",