From 79797644e1c8cad48b41542fbf4a61634707b8da Mon Sep 17 00:00:00 2001 From: windyboy Date: Thu, 21 May 2026 10:36:03 +0800 Subject: [PATCH] chore: trim dead code, modularize CLI, and archive stale docs Extract review-plan, report, quarantine, state, and config handlers into commands/ with shared cli_helpers; remove unused exceptions and duplicate plan summary wrappers. Archive superseded review markdown, sync docs to 517-test baseline, and fix empty series titles when only a quality tag remains. Co-Authored-By: Claude Sonnet 4.5 Co-authored-by: Cursor --- AGENTS.md | 2 +- CHANGELOG.md | 11 + CLAUDE.md | 2 +- README.md | 5 +- ...-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md | 0 ...26-04-02-review-plan-output-refactor-v1.md | 0 ...26-04-07-review-report-refactor-plan-v1.md | 0 .../2026-pre-baseline/ARCHITECTURE_REVIEW.md | 0 .../2026-pre-baseline/AUDIT_FIX_PLAN.md | 0 .../CODE_ANALYSIS_2026-04-01.md | 0 .../2026-pre-baseline/CODE_IMPROVEMENTS.md | 0 .../archive/2026-pre-baseline/FIX_PLAN.md | 0 .../archive/2026-pre-baseline/GEMINI.md | 0 .../IMPLEMENTATION_PLAN_2026-02-13.md | 0 .../IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md | 0 docs/archive/2026-pre-baseline/README.md | 13 + .../2026-pre-baseline/REVIEW_REPORT.md | 0 .../2026-pre-baseline}/TECHNICAL_REVIEW.md | 0 .../2026-pre-baseline/TMDB_REFACTOR_PLAN.md | 0 .../VLM_PROJECT_AUDIT_REPORT.md | 0 .../archive/2026-pre-baseline/codex_review.md | 0 ...-functional-code-simplification-plan-v1.md | 266 +++++ pyproject.toml | 1 - src/vlm/cli.py | 1041 ++--------------- src/vlm/cli_helpers.py | 125 ++ src/vlm/commands/config_cmd.py | 62 + src/vlm/commands/quarantine_cmd.py | 149 +++ src/vlm/commands/report.py | 313 +++++ src/vlm/commands/review_plan.py | 239 ++++ src/vlm/commands/state_cmd.py | 139 +++ src/vlm/exceptions.py | 120 -- src/vlm/parser.py | 10 +- src/vlm/plan_render.py | 3 + src/vlm/plan_review.py | 191 ++- src/vlm/planner.py | 58 +- src/vlm/review_display.py | 2 + src/vlm/review_tui.py | 20 +- tests/test_cli_review_plan.py | 6 +- tests/test_plan_review.py | 99 +- uv.lock | 2 - 40 files changed, 1714 insertions(+), 1165 deletions(-) rename {plans => docs/archive/2026-pre-baseline}/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md (100%) rename {plans => docs/archive/2026-pre-baseline}/2026-04-02-review-plan-output-refactor-v1.md (100%) rename {plans => docs/archive/2026-pre-baseline}/2026-04-07-review-report-refactor-plan-v1.md (100%) rename ARCHITECTURE_REVIEW.md => docs/archive/2026-pre-baseline/ARCHITECTURE_REVIEW.md (100%) rename AUDIT_FIX_PLAN.md => docs/archive/2026-pre-baseline/AUDIT_FIX_PLAN.md (100%) rename CODE_ANALYSIS_2026-04-01.md => docs/archive/2026-pre-baseline/CODE_ANALYSIS_2026-04-01.md (100%) rename CODE_IMPROVEMENTS.md => docs/archive/2026-pre-baseline/CODE_IMPROVEMENTS.md (100%) rename FIX_PLAN.md => docs/archive/2026-pre-baseline/FIX_PLAN.md (100%) rename GEMINI.md => docs/archive/2026-pre-baseline/GEMINI.md (100%) rename IMPLEMENTATION_PLAN_2026-02-13.md => docs/archive/2026-pre-baseline/IMPLEMENTATION_PLAN_2026-02-13.md (100%) rename IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md => docs/archive/2026-pre-baseline/IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md (100%) create mode 100644 docs/archive/2026-pre-baseline/README.md rename REVIEW_REPORT.md => docs/archive/2026-pre-baseline/REVIEW_REPORT.md (100%) rename docs/{ => archive/2026-pre-baseline}/TECHNICAL_REVIEW.md (100%) rename TMDB_REFACTOR_PLAN.md => docs/archive/2026-pre-baseline/TMDB_REFACTOR_PLAN.md (100%) rename VLM_PROJECT_AUDIT_REPORT.md => docs/archive/2026-pre-baseline/VLM_PROJECT_AUDIT_REPORT.md (100%) rename codex_review.md => docs/archive/2026-pre-baseline/codex_review.md (100%) create mode 100644 plans/2026-05-21-functional-code-simplification-plan-v1.md create mode 100644 src/vlm/cli_helpers.py create mode 100644 src/vlm/commands/config_cmd.py create mode 100644 src/vlm/commands/quarantine_cmd.py create mode 100644 src/vlm/commands/report.py create mode 100644 src/vlm/commands/review_plan.py create mode 100644 src/vlm/commands/state_cmd.py delete mode 100644 src/vlm/exceptions.py diff --git a/AGENTS.md b/AGENTS.md index bbb2828..124167a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization - Core package lives in `src/vlm/`. -- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Commands use `pass_context` and `CLIContext` from `context.py`; command logic is modularized in `commands/` (e.g. `scan`, `parse`, `enrich`, `analyze`, `plan`, `execute`). +- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Shared CLI helpers live in `cli_helpers.py`. Command logic is in `commands/` (scan, parse, enrich, analyze, plan, execute, review_plan, report, quarantine_cmd, state_cmd, config_cmd). - Functional modules by concern: scanning (`scanner.py`), parsing (`parser.py`), enrichment (`enrichment.py`, `cache.py`, `providers/`), analysis/planning/execution (`analysis.py`, `planner.py`, `executor.py`), I/O helpers (`io.py`), utilities (`utils.py`), state/reporting/logging (`state.py`, `reports.py`, `logging_config.py`), config (`config.py`), models (`models.py`). - Tests live in `tests/` and mirror feature areas (e.g. `tests/test_scanner.py`, `tests/test_cli_state.py`, `tests/test_enrichment.py`). - Project metadata and tool config are in `pyproject.toml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f0240aa..93c6006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-05-21 + +### Functional code simplification + +- Removed unused `src/vlm/exceptions.py` and consolidated plan summary helpers (`preferred_plan_summary` everywhere). +- Extracted CLI implementations to `cli_helpers.py` and `commands/` (`review_plan`, `report`, `quarantine_cmd`, `state_cmd`, `config_cmd`); `cli.py` is now Click registration and thin delegation. +- Moved `textual` out of `[dev]` optional deps (install via `[tui]` or `[dev,tui]`). +- Archived superseded review/audit markdown under `docs/archive/2026-pre-baseline/`. +- Fixed series parser empty title when the only title token is a quality tag (e.g. `UHD S01E01.mp4`). +- Verification: `pytest -q` → **517 passed**. + ## 2026-04-07 ### Review-plan Safety & Validation Hardening diff --git a/CLAUDE.md b/CLAUDE.md index e95e908..aaeb3fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Key settings: - `log_level` - logging verbosity - `categories` - mapping of category names to directory name lists - `enrichment` (or `enrich`) - TMDB/api_keys, cache_db, translation, reputation; see README for full schema -- `plan.duplicate_keep` - when using `vlm plan --analysis`: `by_quality` (resolution > source > codec > size), `by_reputation` (default), `first_seen`, or `manual` +- `plan.duplicate_keep` - when using `vlm plan --analysis`: `by_quality`, `by_reputation` (default), `by_reputation_quality_time`, `first_seen`, or `manual` ### Category Mappings diff --git a/README.md b/README.md index 8185267..50b2de7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Documentation Status - Last synchronized: **2026-04-07** -- Validation baseline: **`pytest -q` → 507 passed** +- Validation baseline: **`pytest -q` → 517 passed** - Human-in-the-loop workflow includes `vlm apply-review` for syncing manual plan edits from CSV back into `plan.json`. - Scanner now detects `ffprobe` availability, degrades gracefully, and treats partial `find` output as a warning-backed partial result. - JSON artifacts (`identities.json`, `analysis.json`, `plan.json`) are schema-validated on load/save, and plan loading now crosses a typed `ExecutionPlan` boundary. @@ -39,8 +39,9 @@ uv pip install -e . # Install with development dependencies uv pip install -e ".[dev]" -# Optional: Textual TUI for `vlm review-plan --tui` +# Optional: Textual TUI for `vlm review-plan --tui` (not included in [dev]) uv pip install -e ".[tui]" +# Or both: uv pip install -e ".[dev,tui]" ``` The standard CLI remains fully usable without Textual; the dependency is imported only when `vlm review-plan --tui` is requested. diff --git a/plans/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md b/docs/archive/2026-pre-baseline/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md similarity index 100% rename from plans/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md rename to docs/archive/2026-pre-baseline/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md diff --git a/plans/2026-04-02-review-plan-output-refactor-v1.md b/docs/archive/2026-pre-baseline/2026-04-02-review-plan-output-refactor-v1.md similarity index 100% rename from plans/2026-04-02-review-plan-output-refactor-v1.md rename to docs/archive/2026-pre-baseline/2026-04-02-review-plan-output-refactor-v1.md diff --git a/plans/2026-04-07-review-report-refactor-plan-v1.md b/docs/archive/2026-pre-baseline/2026-04-07-review-report-refactor-plan-v1.md similarity index 100% rename from plans/2026-04-07-review-report-refactor-plan-v1.md rename to docs/archive/2026-pre-baseline/2026-04-07-review-report-refactor-plan-v1.md diff --git a/ARCHITECTURE_REVIEW.md b/docs/archive/2026-pre-baseline/ARCHITECTURE_REVIEW.md similarity index 100% rename from ARCHITECTURE_REVIEW.md rename to docs/archive/2026-pre-baseline/ARCHITECTURE_REVIEW.md diff --git a/AUDIT_FIX_PLAN.md b/docs/archive/2026-pre-baseline/AUDIT_FIX_PLAN.md similarity index 100% rename from AUDIT_FIX_PLAN.md rename to docs/archive/2026-pre-baseline/AUDIT_FIX_PLAN.md diff --git a/CODE_ANALYSIS_2026-04-01.md b/docs/archive/2026-pre-baseline/CODE_ANALYSIS_2026-04-01.md similarity index 100% rename from CODE_ANALYSIS_2026-04-01.md rename to docs/archive/2026-pre-baseline/CODE_ANALYSIS_2026-04-01.md diff --git a/CODE_IMPROVEMENTS.md b/docs/archive/2026-pre-baseline/CODE_IMPROVEMENTS.md similarity index 100% rename from CODE_IMPROVEMENTS.md rename to docs/archive/2026-pre-baseline/CODE_IMPROVEMENTS.md diff --git a/FIX_PLAN.md b/docs/archive/2026-pre-baseline/FIX_PLAN.md similarity index 100% rename from FIX_PLAN.md rename to docs/archive/2026-pre-baseline/FIX_PLAN.md diff --git a/GEMINI.md b/docs/archive/2026-pre-baseline/GEMINI.md similarity index 100% rename from GEMINI.md rename to docs/archive/2026-pre-baseline/GEMINI.md diff --git a/IMPLEMENTATION_PLAN_2026-02-13.md b/docs/archive/2026-pre-baseline/IMPLEMENTATION_PLAN_2026-02-13.md similarity index 100% rename from IMPLEMENTATION_PLAN_2026-02-13.md rename to docs/archive/2026-pre-baseline/IMPLEMENTATION_PLAN_2026-02-13.md diff --git a/IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md b/docs/archive/2026-pre-baseline/IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md similarity index 100% rename from IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md rename to docs/archive/2026-pre-baseline/IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md diff --git a/docs/archive/2026-pre-baseline/README.md b/docs/archive/2026-pre-baseline/README.md new file mode 100644 index 0000000..1a83c8f --- /dev/null +++ b/docs/archive/2026-pre-baseline/README.md @@ -0,0 +1,13 @@ +# Archived documentation (pre-2026-05-21 baseline) + +These files are historical review, audit, and implementation plans from earlier refactors. +They are **not** maintained as current project documentation. + +**Canonical docs (use these instead):** + +- `/README.md` — user guide and workflow +- `/CHANGELOG.md` — release and refactor history +- `/CLAUDE.md` / `/AGENTS.md` — agent/developer guidance +- `/plans/2026-05-21-functional-code-simplification-plan-v1.md` — code simplification plan + +Archived on 2026-05-21 as part of the functional code trim (Phase 2). diff --git a/REVIEW_REPORT.md b/docs/archive/2026-pre-baseline/REVIEW_REPORT.md similarity index 100% rename from REVIEW_REPORT.md rename to docs/archive/2026-pre-baseline/REVIEW_REPORT.md diff --git a/docs/TECHNICAL_REVIEW.md b/docs/archive/2026-pre-baseline/TECHNICAL_REVIEW.md similarity index 100% rename from docs/TECHNICAL_REVIEW.md rename to docs/archive/2026-pre-baseline/TECHNICAL_REVIEW.md diff --git a/TMDB_REFACTOR_PLAN.md b/docs/archive/2026-pre-baseline/TMDB_REFACTOR_PLAN.md similarity index 100% rename from TMDB_REFACTOR_PLAN.md rename to docs/archive/2026-pre-baseline/TMDB_REFACTOR_PLAN.md diff --git a/VLM_PROJECT_AUDIT_REPORT.md b/docs/archive/2026-pre-baseline/VLM_PROJECT_AUDIT_REPORT.md similarity index 100% rename from VLM_PROJECT_AUDIT_REPORT.md rename to docs/archive/2026-pre-baseline/VLM_PROJECT_AUDIT_REPORT.md diff --git a/codex_review.md b/docs/archive/2026-pre-baseline/codex_review.md similarity index 100% rename from codex_review.md rename to docs/archive/2026-pre-baseline/codex_review.md diff --git a/plans/2026-05-21-functional-code-simplification-plan-v1.md b/plans/2026-05-21-functional-code-simplification-plan-v1.md new file mode 100644 index 0000000..a833899 --- /dev/null +++ b/plans/2026-05-21-functional-code-simplification-plan-v1.md @@ -0,0 +1,266 @@ +# 修改计划:只保留对功能有贡献的代码 + +**日期:** 2026-05-21 +**状态:** 已完成(2026-05-21) +**基线:** `uv run pytest -q` → **517 passed**(2026-05-21 实测) +**原则:** 删除或合并**无运行时贡献**的代码与文档;**不**削减 CLI 命令、配置项、产物格式、安全策略或用户工作流。 + +--- + +## 1. 目标 + +| 目标 | 说明 | +|------|------| +| 减噪 | 去掉从未被 import / 调用的模块与一层包装函数 | +| 减重复 | 同一语义只保留一处实现(如 plan 摘要) | +| 减结构债 | 把 `cli.py` 中已独立的命令体迁出,**行为不变** | +| 减文档漂移 | 归档已完成的历史审查/实施计划,避免与 `README`/`CHANGELOG` 冲突 | +| 不丢功能 | 全量 pytest + 现有 `tests/test_cli_*` 作为回归门禁 | + +**非目标(本计划不做):** + +- 删除 duplicate 策略、`legacy` 产物回退、anime 扫描分类、TUI、TMDB enrich、transaction/rollback +- 合并 `needs_review` 与 `review_status`(会改变 enrich/plan/CSV 语义) +- 缩小 `quarantine.py` / `executor.py` 的**对外 API**(仅允许内部拆分 + re-export) + +--- + +## 2. 功能贡献判定标准 + +代码/文件在下列情况之一时视为**有贡献**,保留: + +1. 被 `vlm` CLI 路径或 `pyproject.toml` entry point 直接或间接调用 +2. 被 `tests/` 覆盖且对应用户可见行为(含可选 `[tui]`) +3. 被其他保留模块 import 且删除会导致 import 失败或行为变化 +4. 属于安全/数据契约:`io` 校验、`executor` 边界、`duplicate_resolve` 显式失败 + +下列情况视为**无贡献**,可删或合并: + +1. 全仓库零 import(静态可证) +2. 仅转发到另一函数的薄包装(调用方可直接调目标) +3. 已完成且被 `CHANGELOG` 取代的历史计划/审查 markdown +4. 与保留文档逐字重复、无额外运维价值的 agent 副本(如 `GEMINI.md`) + +--- + +## 3. 审计清单 + +### 3.1 可删除(运行时零贡献) + +| 项 | 路径 | 证据 | 操作 | +|----|------|------|------| +| 未使用异常层次 | `src/vlm/exceptions.py` | 全仓库无 `from vlm.exceptions` | **删除文件** | +| CLI 薄包装 | `cli.py` `_fallback_plan_summary()` L1183–1185 | 仅调用 `plan_render.fallback_plan_summary` | **删除**;调用改 `preferred_plan_summary` | +| 重复 import | `cli.py` 对 `fallback_plan_summary` 的 import | 包装删除后不再需要 | **删除 import** | + +### 3.2 可合并(保留行为,减重复) + +| 项 | 位置 | 现状 | 操作 | +|----|------|------|------| +| Plan 摘要 | `cli.py` L1332、L1451 | `human_summary or _fallback_plan_summary(...)` | 改为 `preferred_plan_summary(execution_plan)`(与 `execute`/`review-plan` 一致;空白 `human_summary` 处理更一致) | +| 可选依赖声明 | `pyproject.toml` `[dev]` | `textual` 与 `[tui]` 重复 | **从 `[dev]` 移除 textual**;开发需 TUI 时用 `uv pip install -e ".[dev,tui]"` 或文档说明 | + +### 3.3 保留(有贡献,勿删) + +| 模块 | 贡献 | +|------|------| +| `scanner` / `parser` / `analysis` / `planner` / `executor` | 主管道 | +| `duplicate_resolve` | 重复策略与显式失败 | +| `io` | 产物校验与 typed plan | +| `plan_review` / `plan_render` / `review_display` / `review_tui` | 人工复核与可选 TUI | +| `plan_structure_preview` | `review-plan` 结构预览(`cli` + `test_plan_review`) | +| `transaction` | `executor` 执行期事务日志 | +| `quarantine` / `state` / `reports` / `enrichment` / `cache` / `providers` | 对应子命令 | +| `commands/*`(已有) | scan/parse/enrich/analyze/plan/execute/rollback | +| `logging_config` | CLI + executor + quarantine + 测试 | +| `context` / `config` / `models` / `utils` | 全局基础设施 | + +### 3.4 文档归档(不删功能,减仓库噪音) + +移至 `docs/archive/2026-pre-baseline/`(或删除若确认无历史查阅需求): + +| 文件 | 理由 | +|------|------| +| `REVIEW_REPORT.md` | 2026-04-07 计划已 Completed | +| `ARCHITECTURE_REVIEW.md` | 历史架构审查,多处已修复 | +| `VLM_PROJECT_AUDIT_REPORT.md` | 审计快照 | +| `AUDIT_FIX_PLAN.md` | 任务已勾选完成 | +| `FIX_PLAN.md` | 同上 | +| `CODE_IMPROVEMENTS.md` | 建议清单,非现行规范 | +| `CODE_ANALYSIS_2026-04-01.md` | 一次性分析 | +| `IMPLEMENTATION_PLAN_2026-02-13.md` | 已过期 | +| `IMPROVEMENT_RECOMMENDATIONS_2026-02-13.md` | 已过期 | +| `TMDB_REFACTOR_PLAN.md` | 若 TMDB 已落地则归档 | +| `codex_review.md` | 外部审查副本 | +| `plans/2026-04-07-review-report-refactor-plan-v1.md` | Status: Completed | +| `plans/2026-04-02-review-plan-output-refactor-v1.md` | 已完成 | +| `plans/2026-04-01-CODE_REFACTOR_REFINEMENT_PLAN-v1.md` | 已完成 | +| `GEMINI.md` | 与 `CLAUDE.md`/`AGENTS.md` 重复 | + +**保留为现行文档:** + +- `README.md`、`CHANGELOG.md`、`CLAUDE.md`、`AGENTS.md` +- `docs/TECHNICAL_REVIEW.md`(可选:精简后保留为「设计备忘」或一并归档) +- `skills/vlm-library-workflow/**`(Agent 操作指引) +- `plans/2026-05-21-functional-code-simplification-plan-v1.md`(本计划) + +**可选归档:** `.kiro/specs/video-library-manager/` — 若与当前实现严重偏离且团队不用 Kiro,整目录归档。 + +### 3.5 结构重组(不删命令,减 `cli.py` 体积) + +将下列 Click 命令体迁到 `commands/`,`cli.py` 只保留装饰器 + 一行委托: + +| 新文件 | 迁出命令 | +|--------|----------| +| `commands/review_plan.py` | `review_plan_cmd`, `apply_review_cmd` | +| `commands/report.py` | `report` 组及四个子命令 | +| `commands/quarantine_cmd.py` | `quarantine` 组(避免与 `quarantine.py` 模块名冲突) | +| `commands/state_cmd.py` | `state` 组 | +| `commands/config_cmd.py` | `config` 组 | + +共享辅助函数抽到 `cli_helpers.py`(或 `context.py` 旁): + +- `default_config_path`, `default_artifact_path`, `resolve_legacy_default_input_path` +- `_load_or_create_config`, `_command_error`, `_review_plan_tui_streams_ok` + +**预期:** `cli.py` 从 ~1941 行降至 ~300 行;**`vlm --help` 与子命令选项不变**。 + +### 3.6 延后(本计划不拆文件内容) + +以下能减行数但工作量/风险更高,单列 **Phase 2**(可选后续计划): + +- 拆分 `quarantine.py`(1027 行)、`executor.py`(833 行)、`planner.py`(815 行) +- 统一 rollback/quarantine 的 `is_within_root`(**安全增强**,非删功能) +- `io.py` 增加 `operation_type` 白名单(**更严校验**) + +--- + +## 4. 分阶段实施 + +### Phase 0 — 准备(0.5h) + +- [ ] 确认工作区干净或建立分支 `chore/functional-code-trim` +- [ ] 记录基线:`uv run pytest -q` → 517 passed +- [ ] 记录 `uv run vlm --help` 与子命令列表截图或文本(回归对比) + +### Phase 1 — 删除零贡献代码(0.5–1h) + +| 步骤 | 改动 | +|------|------| +| 1.1 | 删除 `src/vlm/exceptions.py` | +| 1.2 | 删除 `cli.py` `_fallback_plan_summary`;L1332/L1451 改用 `preferred_plan_summary`;移除 `fallback_plan_summary` import | +| 1.3 | `pyproject.toml`:`[dev]` 去掉 `textual`;README/CLAUDE 一行说明 TUI 安装方式 | + +**验收:** + +- [ ] `uv run pytest -q` 全绿 +- [ ] `rg "exceptions|_fallback_plan_summary" src tests` 无匹配 + +### Phase 2 — 文档归档(0.5h) + +| 步骤 | 改动 | +|------|------| +| 2.1 | 创建 `docs/archive/2026-pre-baseline/README.md`(索引归档原因与日期) | +| 2.2 | `git mv` 第三节所列 markdown 到归档目录 | +| 2.3 | 更新 `README.md`:测试基线 **517**;`CLAUDE.md` 补充 `by_reputation_quality_time` | +| 2.4 | `CHANGELOG.md` 增加条目:「文档归档 + 删除未使用 exceptions + CLI 摘要合并」 | + +**验收:** + +- [ ] 根目录仅保留现行文档(见 3.4) +- [ ] 无断链:README 不引用已归档文件名(或改为 archive 链接) + +### Phase 3 — CLI 模块化(1–2d) + +按 3.5 迁移;每迁一组命令跑一次 targeted tests: + +```bash +uv run pytest tests/test_cli_review_plan.py tests/test_cli_reports.py \ + tests/test_cli_quarantine.py tests/test_cli_state.py tests/test_config.py -q +``` + +**验收:** + +- [ ] 全量 `pytest -q` 517+ passed +- [ ] `uv run vlm --help` 与 Phase 0 命令列表一致 +- [ ] `cli.py` 行数 < 400(软目标) + +### Phase 4 — 收尾与门禁(0.5h) + +- [ ] 更新 `AGENTS.md` / `skills/vlm-library-workflow/SKILL.md` 中的模块路径说明(若 CLI 拆分) +- [ ] PR 描述附:删除/归档清单、pytest 输出、`wc -l src/vlm/cli.py` 前后对比 +- [ ] 不提交 `artifacts/`、`.nvimlog`、`.venv/` + +--- + +## 5. 验证矩阵 + +| 检查项 | 命令/方法 | +|--------|-----------| +| 单元+集成测试 | `uv run pytest -q` | +| CLI 冒烟 | `uv run vlm --help`;`config validate`;`review-plan --help` | +| 可选 TUI 边界 | `uv run pytest tests/test_cli_review_plan.py -q` | +| 无死 import | `rg "vlm\.exceptions"` → 空 | +| 包可安装 | `uv pip install -e .` | + +--- + +## 6. 风险与回滚 + +| 风险 | 等级 | 缓解 | +|------|------|------| +| 删除 `exceptions.py` 后未来 PR 又引入 import | 低 | PR 门禁 `rg exceptions` | +| `preferred_plan_summary` 与 `or _fallback` 空白语义差异 | 低 | 以 tests 为准;`test_plan_render` / report CLI 测试覆盖 | +| CLI 迁移遗漏 `pass_context` / 选项默认值 | 中 | 分命令迁移 + cli 集成测试 | +| 文档归档后外部链接失效 | 低 | archive README 写清迁移;根 README 不链旧文件 | + +**回滚:** 按 Phase 逆序 revert;Phase 1 可单独 revert 且不影响 Phase 3。 + +--- + +## 7. 成功标准(Definition of Done) + +1. **功能:** 所有现有 `vlm` 子命令、配置键、产物文件名与 schema 行为不变 +2. **测试:** `pytest -q` 全绿,数量不低于 517(允许因补测略增) +3. **代码:** 无全仓库零引用 Python 模块;`cli.py` 仅负责注册与委托 +4. **文档:** 单一事实来源 = `README` + `CHANGELOG` + `CLAUDE`/`AGENTS`;历史计划进 `docs/archive/` +5. **可维护性:** 新贡献者不再面对 6+ 份互相矛盾的 REVIEW/AUDIT 文档 + +--- + +## 8. 工作量估算 + +| Phase | 估时 | 可独立合并 | +|-------|------|------------| +| 0 准备 | 0.5h | — | +| 1 删死代码 | 0.5–1h | ✅ 建议首 PR | +| 2 文档归档 | 0.5h | ✅ 可与 Phase 1 同 PR | +| 3 CLI 拆分 | 1–2d | ✅ 单独 PR | +| 4 收尾 | 0.5h | 随 PR | + +**合计:** 约 2–3 个工作日(含 review),若只做 Phase 1+2 约 **半天**。 + +--- + +## 9. 建议 PR 拆分 + +| PR | 内容 | 标题示例 | +|----|------|----------| +| PR-1 | Phase 1 + 2 | `chore: remove unused code and archive stale docs` | +| PR-2 | Phase 3 | `refactor: extract remaining CLI commands to commands/` | +| PR-3(可选) | Phase 2 计划 3.6 | `refactor: split quarantine and align path guards` | + +--- + +## 10. 执行后预期指标 + +| 指标 | 当前 | Phase 1+2 后 | Phase 3 后 | +|------|------|--------------|------------| +| `src/vlm/*.py` 模块数 | 36 | 35(-exceptions) | 35 + 4~5 command 模块 | +| `cli.py` 行数 | ~1941 | ~1935 | ~300–400 | +| 根目录 *.md(审查类) | ~12 | 0(已归档) | 0 | +| pytest | 517 | 517 | 517 | + +--- + +*本计划只覆盖「删无贡献 + 合重复 + 搬 CLI」;更深的安全加固与 domain 文件拆分见后续 `plans/2026-*-phase2-internal-split-v1.md`(待 Phase 1–3 完成后再写)。* diff --git a/pyproject.toml b/pyproject.toml index 187d390..017c81c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ dev = [ "pytest>=7.4.0", "hypothesis>=6.82.0", - "textual>=0.47.0", ] tui = [ "textual>=0.47.0", diff --git a/src/vlm/cli.py b/src/vlm/cli.py index d5c6599..a7a497f 100644 --- a/src/vlm/cli.py +++ b/src/vlm/cli.py @@ -11,129 +11,16 @@ from pathlib import Path from typing import Optional import click -import yaml -from click.core import ParameterSource -from vlm.config import Config, load_config, create_default_config, validate_config -from vlm.context import CLIContext, pass_context -from vlm.logging_config import setup_logging, get_logger -from vlm.plan_render import ( - fallback_plan_summary, - preferred_plan_summary, - render_review_footer, - render_review_preview, - render_review_verdict, - duplicate_groups_from_plan, +from vlm.cli_helpers import ( + command_error, + default_artifact_path, + default_config_path, + initialize_cli_context, + resolve_legacy_default_input_path, + review_plan_tui_streams_ok as _review_plan_tui_streams_ok, ) -from vlm.utils import format_size - - -def default_config_path() -> Path: - """Return the default config path resolved at runtime.""" - return Path.home() / ".vlm" / "config.yaml" - - -def _workspace_dir_from_context() -> Path: - """Resolve workspace directory from CLI context at runtime.""" - click_ctx = click.get_current_context(silent=True) - if click_ctx and isinstance(click_ctx.obj, CLIContext): - return click_ctx.obj.config.workspace_dir - return Path("artifacts") - - -def default_artifact_path(filename: str) -> Path: - """Build default artifact path for a filename at runtime.""" - return _workspace_dir_from_context() / filename - - -def _is_default_parameter(parameter_name: str) -> bool: - """Check whether a parameter value came from Click default.""" - click_ctx = click.get_current_context(silent=True) - if click_ctx is None: - return False - return click_ctx.get_parameter_source(parameter_name) == ParameterSource.DEFAULT - - -def resolve_legacy_default_input_path( - current_path: Path, - parameter_name: str, - legacy_filename: str, - option_name: str, -) -> Path: - """Fallback to legacy root path when default workspace file is missing. - - This keeps stage-A compatibility for users who still have legacy artifacts - in repository root while printing a migration warning. - """ - if not _is_default_parameter(parameter_name): - return current_path - - workspace_default = default_artifact_path(legacy_filename) - legacy_default = Path(legacy_filename) - if current_path != workspace_default: - return current_path - if current_path.exists() or not legacy_default.exists(): - return current_path - - click.echo( - "Warning: detected legacy default input at " - f"{legacy_default.resolve()}. Please migrate to " - f"{workspace_default.resolve()} (example: {option_name} {workspace_default.resolve()}).", - err=True, - ) - return legacy_default - - -def _command_error(ctx: CLIContext, user_message: str, logger_message: str, *, exc_info: bool = False) -> None: - """Print a command error message, log it, and exit consistently.""" - click.echo(user_message, err=True) - ctx.logger.error(logger_message, exc_info=exc_info) - raise SystemExit(1) - - -def _review_plan_tui_streams_ok() -> bool: - """Return True if stdin/stdout appear to be an interactive terminal.""" - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _load_or_create_config(config: Path) -> Config: - """Load configuration from disk or create a default config file.""" - if config.exists(): - try: - cfg = load_config(config) - except yaml.YAMLError as e: - click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True) - click.echo("Using default configuration values.", err=True) - cfg = create_default_config(config) - except ValueError as e: - click.echo(f"Error: {e}", err=True) - click.echo("Using default configuration values.", err=True) - cfg = create_default_config(config) - else: - click.echo(f"Configuration file not found at {config}", err=True) - click.echo("Creating default configuration...", err=True) - cfg = create_default_config(config) - click.echo(f"Default configuration created at {config}", err=True) - - validation_errors = validate_config(cfg) - if validation_errors: - click.echo("Configuration validation errors:", err=True) - for error in validation_errors: - click.echo(f" - {error}", err=True) - click.echo("Please fix the configuration file and try again.", err=True) - raise ValueError("invalid configuration") - - return cfg - - -def _initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext: - """Load config, apply CLI overrides, and build the CLI context.""" - cfg = _load_or_create_config(config) - if log_level: - cfg.log_level = log_level.upper() - - logger = setup_logging(log_level=cfg.log_level) - return CLIContext(config=cfg, logger=logger) +from vlm.context import CLIContext, pass_context @click.group() @@ -173,7 +60,7 @@ def main(ctx, config: Path, log_level: Optional[str]): ctx.ensure_object(dict) try: - ctx.obj = _initialize_cli_context(config, log_level) + ctx.obj = initialize_cli_context(config, log_level) except Exception as e: traceback.print_exc(file=sys.stderr) click.echo(f"Error initializing VLM: {e}", err=True) @@ -275,15 +162,15 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]) from vlm.commands.parse import parse_cmd parse_cmd(ctx, input, output, inventory) except FileNotFoundError: - _command_error( + command_error( ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}", ) except ValueError as e: - _command_error(ctx, f"Error: {e}", f"Parse failed: {e}") + command_error(ctx, f"Error: {e}", f"Parse failed: {e}") except OSError as e: - _command_error( + command_error( ctx, f"Error reading/writing files: {e}", f"Parse file I/O failed: {e}", @@ -358,22 +245,22 @@ def enrich( retries, ) except FileNotFoundError: - _command_error( + command_error( ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}", ) except json.JSONDecodeError as e: - _command_error( + command_error( ctx, f"Error: Failed to parse JSON file: {e}", f"JSON parsing failed during enrich: {e}", exc_info=True, ) except ValueError as e: - _command_error(ctx, f"Error: {e}", f"Enrich validation failed: {e}") + command_error(ctx, f"Error: {e}", f"Enrich validation failed: {e}") except OSError as e: - _command_error( + command_error( ctx, f"Error reading/writing files: {e}", f"Enrich file I/O failed: {e}", @@ -419,20 +306,20 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path from vlm.commands.analyze import analyze_cmd analyze_cmd(ctx, input, output, inventory) except FileNotFoundError: - _command_error( + command_error( ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}", ) except json.JSONDecodeError as e: - _command_error( + command_error( ctx, f"Error: Failed to parse JSON file: {e}", f"JSON parsing failed: {e}", exc_info=True, ) except Exception as e: - _command_error( + command_error( ctx, f"Error during analysis: {e}", f"Analysis failed: {e}", @@ -478,20 +365,20 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None): from vlm.commands.plan import plan_cmd plan_cmd(ctx, input, output, analysis) except FileNotFoundError: - _command_error( + command_error( ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}", ) except json.JSONDecodeError as e: - _command_error( + command_error( ctx, f"Error: Failed to parse JSON file: {e}", f"JSON parsing failed: {e}", exc_info=True, ) except Exception as e: - _command_error( + command_error( ctx, f"Error during plan generation: {e}", f"Plan generation failed: {e}", @@ -594,171 +481,23 @@ def review_plan_cmd( structure_preview: Optional[Path], ): """Review a plan and export high-risk operations for manual confirmation.""" - from vlm.io import load_analysis_json, load_identities_json - from vlm.planner import load_plan - from vlm.plan_review import GROUP_BY_CHOICES, prepare_review_rows, save_review_csv - from vlm.plan_structure_preview import write_structure_preview + from vlm.commands.review_plan import review_plan_cmd as run_review_plan - logger = ctx.logger - - try: - input = resolve_legacy_default_input_path(input, "input", "plan.json", "--input") - if season_threshold < 1 or episode_threshold < 1: - click.echo("Error: thresholds must be >= 1", err=True) - sys.exit(1) - if preview_limit < 1: - click.echo("Error: --preview-limit must be >= 1", err=True) - sys.exit(1) - if sample_safe < 0: - click.echo("Error: --sample-safe must be >= 0", err=True) - sys.exit(1) - group_by = group_by.lower() - if group_by not in GROUP_BY_CHOICES: - click.echo(f"Error: invalid --group-by {group_by}", err=True) - sys.exit(1) - - if tui: - if not _review_plan_tui_streams_ok(): - click.echo("Error: --tui requires an interactive terminal (TTY)", err=True) - sys.exit(1) - - click.echo(f"Loading plan: {input}") - execution_plan = load_plan(input) - - identities_data = None - identities_path = identities - if identities_path is None: - default_id = default_artifact_path("identities.json") - if default_id.is_file(): - identities_path = default_id - if identities_path is not None and identities_path.is_file(): - identities_data = load_identities_json(identities_path) - click.echo(f"Loaded identities: {identities_path}") - - analysis_data = None - if analysis is not None and analysis.is_file(): - analysis_data = load_analysis_json(analysis) - click.echo(f"Loaded analysis: {analysis}") - - rows, counters = prepare_review_rows( - execution_plan, - season_threshold=season_threshold, - episode_threshold=episode_threshold, - library_root=ctx.config.library_root, - identities_data=identities_data, - analysis_data=analysis_data, - sample_safe=sample_safe, - group_by=group_by, - ) - - if structure_preview is not None: - write_structure_preview( - execution_plan, - ctx.config.library_root, - structure_preview, - ) - click.echo(f"Wrote structure preview: {structure_preview}") - - if tui: - from vlm.plan_review import build_duplicate_path_maps - from vlm.review_tui import ReviewTUIContext, run_plan_review_tui - - _, path_to_quality = build_duplicate_path_maps(analysis_data) - tui_ctx = ReviewTUIContext( - rows=rows, - counters=counters, - library_root=ctx.config.library_root, - output_csv=output, - plan_input=input, - summary_text=preferred_plan_summary(execution_plan), - path_to_quality=path_to_quality, - ) - rc = run_plan_review_tui(tui_ctx) - if rc != 0: - click.echo("Plan review aborted (no CSV written).", err=True) - sys.exit(rc) - click.echo(f"Saved manual review CSV to: {output}") - logger.info( - "Plan review TUI completed: total=%s high_risk=%s output=%s", - counters["total_operations"], - counters["high_risk_operations"], - output, - ) - return - - save_review_csv(rows, output) - - click.echo() - click.echo("Plan overview:") - click.echo(preferred_plan_summary(execution_plan)) - - click.echo() - click.echo("Plan review summary:") - click.echo(f" Total operations: {counters['total_operations']}") - click.echo(f" High-risk operations: {counters['high_risk_operations']}") - click.echo(f" manual_review: {counters['manual_review']}") - click.echo(f" sample_source: {counters['sample_source']}") - click.echo(f" high_season: {counters['high_season']}") - click.echo(f" high_episode: {counters['high_episode']}") - click.echo(f" conflicts: {counters['conflicts']}") - - click.echo() - click.echo(render_review_verdict(counters)) - - click.echo() - click.echo("High-risk operations preview:") - if rows: - preview_lines, hidden_count = render_review_preview( - rows, - preview_limit=preview_limit, - show_all=show_all, - library_root=ctx.config.library_root, - ) - for line in preview_lines: - click.echo(line) - if hidden_count > 0: - click.echo( - f" ... and {hidden_count} more high-risk operations " - "(use --show-all to display all)" - ) - else: - click.echo(" (none)") - - click.echo() - click.echo(f"Saved manual review CSV to: {output}") - - dup_groups = duplicate_groups_from_plan(execution_plan) - click.echo() - for line in render_review_footer( - counters=counters, - output_csv=output, - plan_input=input, - duplicate_groups=dup_groups, - ): - click.echo(line) - - logger.info( - "Plan review completed: total=%s high_risk=%s output=%s", - counters["total_operations"], - counters["high_risk_operations"], - output, - ) - except FileNotFoundError: - _command_error(ctx, f"Error: Plan file not found: {input}", f"Plan file not found: {input}") - except json.JSONDecodeError as e: - _command_error( - ctx, - f"Error: Failed to parse plan JSON: {e}", - f"Plan review JSON parsing failed: {e}", - exc_info=True, - ) - except Exception as e: - _command_error( - ctx, - f"Error during plan review: {e}", - f"Plan review failed: {e}", - exc_info=True, - ) + run_review_plan( + ctx, + input, + output, + season_threshold, + episode_threshold, + preview_limit, + show_all, + tui, + identities, + analysis, + group_by, + sample_safe, + structure_preview, + ) @main.command(name="apply-review") @@ -788,42 +527,14 @@ def apply_review_cmd( output: Optional[Path], ): """Apply modifications from a manual review CSV back to the plan JSON. - + This command reads the 'operation_type' column from the CSV and updates the corresponding operations in the plan. This is the primary way to manually approve or reject high-risk operations. """ - from vlm.planner import load_plan, save_plan, apply_review_to_plan - - logger = ctx.logger - output_path = output or plan - - try: - click.echo(f"Loading plan: {plan}") - execution_plan = load_plan(plan) - - click.echo(f"Applying review from: {csv}") - updated_plan = apply_review_to_plan(execution_plan, csv) - - save_plan(updated_plan, output_path) - click.echo(f"Successfully updated plan saved to: {output_path}") - - # Calculate changes for user feedback - modified = 0 - for i, op in enumerate(updated_plan.operations): - if op.operation_type != execution_plan.operations[i].operation_type: - modified += 1 - - click.echo(f"Total operations modified: {modified}") - logger.info("Applied review from %s to %s, modified %s ops", csv, output_path, modified) - - except Exception as e: - _command_error( - ctx, - f"Error applying review: {e}", - f"Apply review failed: {e}", - exc_info=True, - ) + from vlm.commands.review_plan import apply_review_cmd as run_apply_review + + run_apply_review(ctx, plan, csv, output) @main.command() @@ -915,15 +626,15 @@ def execute( review_csv=review_csv, ) except FileNotFoundError: - _command_error( + command_error( ctx, f"Error: File not found: {plan}", f"Execution file not found: {plan}", ) except ValueError as e: - _command_error(ctx, f"Error: {e}", f"Execution validation failed: {e}") + command_error(ctx, f"Error: {e}", f"Execution validation failed: {e}") except OSError as e: - _command_error( + command_error( ctx, f"Error during execution: {e}", f"Execution I/O failed: {e}", @@ -962,58 +673,9 @@ def quarantine_list(ctx: CLIContext, category: Optional[str]): vlm quarantine list # List all quarantined files vlm quarantine list --category movie # List only movie files """ - from vlm.quarantine import QuarantineManager - - config = ctx.config - logger = ctx.logger - - try: - # Create quarantine manager - manager = QuarantineManager(config, logger) - - # List quarantined files - entries = manager.list_quarantined(category=category) - - if not entries: - if category: - click.echo(f"No quarantined files found in category '{category}'.") - else: - click.echo("No quarantined files found.") - return - - # Display quarantined files - click.echo() - if category: - click.echo(f"Quarantined files in category '{category}':") - else: - click.echo("Quarantined files:") - click.echo("=" * 80) - - for i, entry in enumerate(entries, 1): - click.echo(f"\n[{i}] {entry.quarantine_path.name}") - click.echo(f" Category: {entry.category}") - click.echo(f" Original: {entry.original_path}") - click.echo(f" Quarantine: {entry.quarantine_path}") - click.echo(f" Size: {format_size(entry.size_bytes)}") - click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}") - if entry.reason: - click.echo(f" Reason: {entry.reason}") - - click.echo() - click.echo("=" * 80) - click.echo(f"Total: {len(entries)} quarantined file(s)") - click.echo() - - logger.info(f"Listed {len(entries)} quarantined files" + - (f" from category '{category}'" if category else "")) - - except Exception as e: - _command_error( - ctx, - f"Error listing quarantined files: {e}", - f"Failed to list quarantined files: {e}", - exc_info=True, - ) + from vlm.commands.quarantine_cmd import quarantine_list_cmd + + quarantine_list_cmd(ctx, category) @quarantine.command('add') @@ -1037,51 +699,9 @@ def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]): vlm quarantine add /path/to/movie.mkv vlm quarantine add /path/to/series.mkv --reason "duplicate" """ - from vlm.quarantine import QuarantineManager - - config = ctx.config - logger = ctx.logger - - try: - # Create quarantine manager - manager = QuarantineManager(config, logger) - - # Display confirmation - click.echo(f"Quarantining file: {file}") - if reason: - click.echo(f"Reason: {reason}") - click.echo() - - # Quarantine the file - result = manager.quarantine_file(file, reason=reason) - - if result.success: - click.echo(f"✓ File successfully quarantined!") - click.echo(f" Original: {result.operation.source_path}") - click.echo(f" Quarantine: {result.operation.destination_path}") - click.echo() - click.echo("To restore this file, run:") - click.echo(f" vlm quarantine restore {result.operation.destination_path}") - else: - _command_error( - ctx, - f"✗ Failed to quarantine file: {result.error_message}", - f"Quarantine failed for {file}: {result.error_message}", - ) + from vlm.commands.quarantine_cmd import quarantine_add_cmd - logger.info(f"Quarantined file: {file}") - - except ValueError as e: - # Category restriction error - _command_error(ctx, f"Error: {e}", f"Quarantine rejected: {e}") - - except Exception as e: - _command_error( - ctx, - f"Error quarantining file: {e}", - f"Failed to quarantine file: {e}", - exc_info=True, - ) + quarantine_add_cmd(ctx, file, reason) @quarantine.command('restore') @@ -1097,47 +717,9 @@ def quarantine_restore(ctx: CLIContext, file: Path): vlm quarantine restore /path/to/.quarantine/movie.mkv """ - from vlm.quarantine import QuarantineManager - - config = ctx.config - logger = ctx.logger - - try: - # Create quarantine manager - manager = QuarantineManager(config, logger) - - # Display confirmation - click.echo(f"Restoring file from quarantine: {file}") - click.echo() - - # Restore the file - result = manager.restore_from_quarantine(file) - - if result.success: - click.echo(f"✓ File successfully restored!") - click.echo(f" Quarantine: {result.operation.source_path}") - click.echo(f" Restored to: {result.operation.destination_path}") - else: - if result.operation.has_conflict: - click.echo(f"✗ Cannot restore: {result.operation.conflict_reason}", err=True) - click.echo(f" Original location: {result.operation.destination_path}", err=True) - else: - click.echo(f"✗ Failed to restore file: {result.error_message}", err=True) - _command_error( - ctx, - f"Error restoring file: {result.error_message or result.operation.conflict_reason or 'unknown error'}", - f"Failed to restore file: {file}", - ) - - logger.info(f"Restored file from quarantine: {file}") - - except Exception as e: - _command_error( - ctx, - f"Error restoring file: {e}", - f"Failed to restore file: {e}", - exc_info=True, - ) + from vlm.commands.quarantine_cmd import quarantine_restore_cmd + + quarantine_restore_cmd(ctx, file) @main.command() @@ -1168,11 +750,11 @@ def rollback(ctx: CLIContext, log: Optional[Path]): from vlm.commands.execute import rollback_cmd rollback_cmd(ctx, log) except FileNotFoundError as e: - _command_error(ctx, f"Error: {e}", f"Rollback log not found: {e}") + command_error(ctx, f"Error: {e}", f"Rollback log not found: {e}") except ValueError as e: - _command_error(ctx, f"Error: {e}", f"Rollback failed: {e}") + command_error(ctx, f"Error: {e}", f"Rollback failed: {e}") except OSError as e: - _command_error( + command_error( ctx, f"Error during rollback: {e}", f"Rollback failed: {e}", @@ -1180,11 +762,6 @@ def rollback(ctx: CLIContext, log: Optional[Path]): ) -def _fallback_plan_summary(execution_plan) -> str: - """Build a short plan summary from summary and summary_by_reason when human_summary is empty.""" - return fallback_plan_summary(execution_plan) - - @main.group() @pass_context def report(ctx: CLIContext): @@ -1227,53 +804,9 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional vlm report inventory --format csv # CSV format to console vlm report inventory --format json --output inventory_report.json """ - from vlm.reports import generate_inventory_report - from vlm.io import load_inventory_csv - - config = ctx.config - logger = ctx.logger - - try: - input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") - # Load inventory from CSV - click.echo(f"Loading inventory from: {input}") - - video_files = load_inventory_csv(input) - - click.echo(f"Loaded {len(video_files)} files") - click.echo() - - # Generate report - click.echo(f"Generating inventory report in {format} format...") - - # For text format, use CSV format as the text representation - report_format = 'csv' if format == 'text' else format - report_content = generate_inventory_report(video_files, report_format, config.library_root) - - # Output report - if output: - # Save to file - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, 'w', encoding='utf-8') as f: - f.write(report_content) - click.echo(f"Report saved to: {output}") - else: - # Print to console - click.echo() - click.echo(report_content) - - logger.info(f"Generated inventory report in {format} format with {len(video_files)} files") - - except FileNotFoundError: - _command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") - - except Exception as e: - _command_error( - ctx, - f"Error generating inventory report: {e}", - f"Inventory report generation failed: {e}", - exc_info=True, - ) + from vlm.commands.report import report_inventory_cmd + + report_inventory_cmd(ctx, format, input, output) @report.command('completeness') @@ -1314,83 +847,9 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio vlm report completeness --plan plan.json # Include plan content summary vlm report completeness --format text --output completeness.txt """ - from vlm.reports import generate_completeness_report - from vlm.models import SeasonCompleteness - from vlm.planner import load_plan - from vlm.io import load_analysis_json + from vlm.commands.report import report_completeness_cmd - config = ctx.config - logger = ctx.logger - - input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") - plan_summary = None - if plan: - if not plan.exists(): - click.echo(f"Error: Plan file not found: {plan}", err=True) - sys.exit(1) - execution_plan = load_plan(plan) - plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan) - - try: - # Load analysis from JSON - click.echo(f"Loading analysis from: {input}") - - analysis_data = load_analysis_json(input) - - # Extract completeness data - completeness_list = analysis_data.get('completeness', []) - - # Convert to SeasonCompleteness objects - season_completeness = [] - for c in completeness_list: - season_completeness.append(SeasonCompleteness( - series_title=c['series_title'], - season=c['season'], - episodes_found=c['episodes_found'], - episodes_missing=c['episodes_missing'] - )) - - click.echo(f"Loaded {len(season_completeness)} series with gaps") - click.echo() - - # Generate report - click.echo(f"Generating completeness report in {format} format...") - report_content = generate_completeness_report( - season_completeness, format, config.library_root, plan_summary=plan_summary - ) - - # Output report - if output: - # Save to file - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, 'w', encoding='utf-8') as f: - f.write(report_content) - click.echo(f"Report saved to: {output}") - else: - # Print to console - click.echo() - click.echo(report_content) - - logger.info(f"Generated completeness report in {format} format with {len(season_completeness)} series") - - except FileNotFoundError: - _command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") - - except json.JSONDecodeError as e: - _command_error( - ctx, - f"Error: Failed to parse JSON file: {e}", - f"JSON parsing failed: {e}", - exc_info=True, - ) - - except Exception as e: - _command_error( - ctx, - f"Error generating completeness report: {e}", - f"Completeness report generation failed: {e}", - exc_info=True, - ) + report_completeness_cmd(ctx, format, input, output, plan) @report.command('duplicates') @@ -1432,124 +891,9 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona vlm report duplicates --plan plan.json # Include plan content summary vlm report duplicates --format text --output duplicates.txt """ - from vlm.reports import generate_duplicate_report - from vlm.models import DuplicateGroup, MovieIdentity, SeriesIdentity, VideoFile - from vlm.planner import load_plan - from datetime import datetime, timezone - from vlm.io import load_analysis_json - - config = ctx.config - logger = ctx.logger + from vlm.commands.report import report_duplicates_cmd - input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") - plan_summary = None - if plan: - if not plan.exists(): - click.echo(f"Error: Plan file not found: {plan}", err=True) - sys.exit(1) - execution_plan = load_plan(plan) - plan_summary = execution_plan.human_summary or _fallback_plan_summary(execution_plan) - - try: - # Load analysis from JSON - click.echo(f"Loading analysis from: {input}") - - analysis_data = load_analysis_json(input) - - # Extract duplicates data - duplicates_list = analysis_data.get('duplicates', []) - - # Convert to DuplicateGroup objects - duplicate_groups = [] - for d in duplicates_list: - identity_data = d['identity'] - - # Reconstruct identity - if identity_data['type'] == 'movie': - identity = MovieIdentity( - title=identity_data['title'], - year=identity_data.get('year'), - confidence=1.0, - needs_review=False, - original_filename="" - ) - else: # series - identity = SeriesIdentity( - title=identity_data['title'], - season=identity_data.get('season'), - episodes=identity_data.get('episodes', []), - confidence=1.0, - needs_review=False, - original_filename="" - ) - - quality_by_path = { - str(item.get("path", "")): item for item in d.get("quality_comparison", []) - } - - # Reconstruct VideoFile objects from file paths and preserve size metadata - files = [] - for file_path in d['files']: - quality = quality_by_path.get(str(file_path), {}) - files.append(VideoFile( - path=Path(file_path), - filename=Path(file_path).name, - size_bytes=int(quality.get("size_bytes", 0) or 0), - modified_timestamp=datetime.now(timezone.utc), - category="", - resolution=quality.get("resolution"), - codec=quality.get("codec"), - duration_seconds=quality.get("duration_seconds"), - bitrate_kbps=quality.get("bitrate_kbps"), - )) - - duplicate_groups.append(DuplicateGroup( - identity=identity, - files=files, - quality_comparison=d['quality_comparison'] - )) - - click.echo(f"Loaded {len(duplicate_groups)} duplicate groups") - click.echo() - - # Generate report - click.echo(f"Generating duplicate report in {format} format...") - report_content = generate_duplicate_report( - duplicate_groups, format, config.library_root, plan_summary=plan_summary - ) - - # Output report - if output: - # Save to file - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, 'w', encoding='utf-8') as f: - f.write(report_content) - click.echo(f"Report saved to: {output}") - else: - # Print to console - click.echo() - click.echo(report_content) - - logger.info(f"Generated duplicate report in {format} format with {len(duplicate_groups)} groups") - - except FileNotFoundError: - _command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") - - except json.JSONDecodeError as e: - _command_error( - ctx, - f"Error: Failed to parse JSON file: {e}", - f"JSON parsing failed: {e}", - exc_info=True, - ) - - except Exception as e: - _command_error( - ctx, - f"Error generating duplicate report: {e}", - f"Duplicate report generation failed: {e}", - exc_info=True, - ) + report_duplicates_cmd(ctx, format, input, output, plan) @report.command('summary') @@ -1577,50 +921,9 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]): vlm report summary # Print to console vlm report summary --output summary.txt # Save to file """ - from vlm.reports import generate_summary_report - from vlm.io import load_inventory_csv - - config = ctx.config - logger = ctx.logger - - try: - input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") - # Load inventory from CSV - click.echo(f"Loading inventory from: {input}") - - video_files = load_inventory_csv(input) - - click.echo(f"Loaded {len(video_files)} files") - click.echo() - - # Generate report - click.echo("Generating summary report...") - report_content = generate_summary_report(video_files, config.library_root) - - # Output report - if output: - # Save to file - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, 'w', encoding='utf-8') as f: - f.write(report_content) - click.echo(f"Report saved to: {output}") - else: - # Print to console - click.echo() - click.echo(report_content) - - logger.info(f"Generated summary report with {len(video_files)} files") - - except FileNotFoundError: - _command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") - - except Exception as e: - _command_error( - ctx, - f"Error generating summary report: {e}", - f"Summary report generation failed: {e}", - exc_info=True, - ) + from vlm.commands.report import report_summary_cmd + + report_summary_cmd(ctx, input, output) @main.group() @@ -1645,41 +948,9 @@ def state_show(ctx: CLIContext, file: Path): vlm state show /path/to/movie.mkv """ - from vlm.state import StateManager - - config = ctx.config - logger = ctx.logger - - try: - # Get state store path - state_path = Path.home() / ".vlm" / "state.json" - - # Create state manager - manager = StateManager(state_path) - - # Get file state - file_state = manager.get_file_state(file) - - if file_state is None: - click.echo(f"No state found for file: {file}") - click.echo("This file has not been tracked yet.") - else: - click.echo(f"State for file: {file}") - click.echo() - click.echo(f" Status: {file_state.status}") - if file_state.reason: - click.echo(f" Reason: {file_state.reason}") - click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") - - logger.info(f"Showed state for file: {file}") - - except Exception as e: - _command_error( - ctx, - f"Error showing file state: {e}", - f"Failed to show file state: {e}", - exc_info=True, - ) + from vlm.commands.state_cmd import state_show_cmd + + state_show_cmd(ctx, file) @state.command('set') @@ -1711,42 +982,9 @@ def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]): vlm state set /path/to/movie.mkv --status reviewed vlm state set /path/to/movie.mkv --status ignored --reason "duplicate" """ - from vlm.state import StateManager - - config = ctx.config - logger = ctx.logger - - try: - # Get state store path - state_path = Path.home() / ".vlm" / "state.json" - - # Create state manager - manager = StateManager(state_path) - - # Set file state - manager.set_file_state(file, status, reason) - - # Save state - manager.save() - - # Display confirmation - click.echo(f"✓ State updated for file: {file}") - click.echo(f" Status: {status}") - if reason: - click.echo(f" Reason: {reason}") - - logger.info(f"Set state for file {file}: status={status}, reason={reason}") - - except ValueError as e: - _command_error(ctx, f"Error: {e}", f"Invalid status: {e}") - - except Exception as e: - _command_error( - ctx, - f"Error setting file state: {e}", - f"Failed to set file state: {e}", - exc_info=True, - ) + from vlm.commands.state_cmd import state_set_cmd + + state_set_cmd(ctx, file, status, reason) @state.command('query') @@ -1767,49 +1005,9 @@ def state_query(ctx: CLIContext, status: str): vlm state query --status ignored vlm state query --status reviewed """ - from vlm.state import StateManager - - config = ctx.config - logger = ctx.logger - - try: - # Get state store path - state_path = Path.home() / ".vlm" / "state.json" - - # Create state manager - manager = StateManager(state_path) - - # Query files by status - file_states = manager.query_by_status(status) - - if not file_states: - click.echo(f"No files found with status '{status}'.") - return - - # Display results - click.echo(f"Files with status '{status}':") - click.echo("=" * 80) - click.echo() - - for i, file_state in enumerate(file_states, 1): - click.echo(f"[{i}] {file_state.file_path}") - if file_state.reason: - click.echo(f" Reason: {file_state.reason}") - click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") - click.echo() - - click.echo("=" * 80) - click.echo(f"Total: {len(file_states)} file(s)") - - logger.info(f"Queried files with status '{status}': {len(file_states)} found") - - except Exception as e: - _command_error( - ctx, - f"Error querying file states: {e}", - f"Failed to query file states: {e}", - exc_info=True, - ) + from vlm.commands.state_cmd import state_query_cmd + + state_query_cmd(ctx, status) @state.command('clear') @@ -1824,44 +1022,9 @@ def state_clear(ctx: CLIContext, file: Path): vlm state clear /path/to/movie.mkv """ - from vlm.state import StateManager - - config = ctx.config - logger = ctx.logger - - try: - # Get state store path - state_path = Path.home() / ".vlm" / "state.json" - - # Create state manager - manager = StateManager(state_path) - - # Check if state exists - file_state = manager.get_file_state(file) - - if file_state is None: - click.echo(f"No state found for file: {file}") - click.echo("Nothing to clear.") - return - - # Clear file state - manager.clear_state(file) - - # Save state - manager.save() - - # Display confirmation - click.echo(f"✓ State cleared for file: {file}") - - logger.info(f"Cleared state for file: {file}") - - except Exception as e: - _command_error( - ctx, - f"Error clearing file state: {e}", - f"Failed to clear file state: {e}", - exc_info=True, - ) + from vlm.commands.state_cmd import state_clear_cmd + + state_clear_cmd(ctx, file) @main.group(name='config') @@ -1884,57 +1047,27 @@ def config_cmd(ctx: CLIContext): @pass_context def config_init(ctx: CLIContext, path: Path): """Initialize configuration file with defaults.""" - try: - if path.exists(): - click.echo(f"Configuration file already exists at {path}", err=True) - if not click.confirm("Overwrite existing configuration?"): - click.echo("Configuration initialization cancelled.") - return - - create_default_config(path) - click.echo(f"Configuration file created at {path}") - click.echo("Edit this file to customize your settings.") - - except Exception as e: - _command_error( - ctx, - f"Error creating configuration: {e}", - f"Configuration creation failed: {e}", - exc_info=True, - ) + from vlm.commands.config_cmd import config_init_cmd + + config_init_cmd(ctx, path) @config_cmd.command('show') @pass_context def config_show(ctx: CLIContext): """Show current configuration.""" - cfg = ctx.config - click.echo("Current configuration:") - click.echo(f" Library root: {cfg.library_root}") - click.echo(f" Video extensions: {', '.join(cfg.video_extensions)}") - click.echo(f" Movie template: {cfg.movie_template}") - click.echo(f" Series template: {cfg.series_template}") - click.echo(f" Movie filename template: {cfg.movie_filename_template}") - click.echo(f" Series filename template: {cfg.series_filename_template}") - click.echo(f" Quarantine directory: {cfg.quarantine_dir}") - click.echo(f" Workspace directory: {cfg.workspace_dir}") - click.echo(f" Log level: {cfg.log_level}") + from vlm.commands.config_cmd import config_show_cmd + + config_show_cmd(ctx) @config_cmd.command('validate') @pass_context def config_validate(ctx: CLIContext): """Validate configuration.""" - cfg = ctx.config - errors = validate_config(cfg) - - if not errors: - click.echo("Configuration is valid.") - else: - click.echo("Configuration validation errors:", err=True) - for error in errors: - click.echo(f" - {error}", err=True) - _command_error(ctx, "Configuration validation failed.", "Configuration validation failed") + from vlm.commands.config_cmd import config_validate_cmd + + config_validate_cmd(ctx) if __name__ == '__main__': diff --git a/src/vlm/cli_helpers.py b/src/vlm/cli_helpers.py new file mode 100644 index 0000000..cf6391d --- /dev/null +++ b/src/vlm/cli_helpers.py @@ -0,0 +1,125 @@ +"""Shared helpers for VLM CLI commands.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + +import click +import yaml +from click.core import ParameterSource + +from vlm.config import Config, create_default_config, load_config, validate_config +from vlm.context import CLIContext +from vlm.logging_config import setup_logging + + +def default_config_path() -> Path: + """Return the default config path resolved at runtime.""" + return Path.home() / ".vlm" / "config.yaml" + + +def workspace_dir_from_context() -> Path: + """Resolve workspace directory from CLI context at runtime.""" + click_ctx = click.get_current_context(silent=True) + if click_ctx and isinstance(click_ctx.obj, CLIContext): + return click_ctx.obj.config.workspace_dir + return Path("artifacts") + + +def default_artifact_path(filename: str) -> Path: + """Build default artifact path for a filename at runtime.""" + return workspace_dir_from_context() / filename + + +def is_default_parameter(parameter_name: str) -> bool: + """Check whether a parameter value came from Click default.""" + click_ctx = click.get_current_context(silent=True) + if click_ctx is None: + return False + return click_ctx.get_parameter_source(parameter_name) == ParameterSource.DEFAULT + + +def resolve_legacy_default_input_path( + current_path: Path, + parameter_name: str, + legacy_filename: str, + option_name: str, +) -> Path: + """Fallback to legacy root path when default workspace file is missing.""" + if not is_default_parameter(parameter_name): + return current_path + + workspace_default = default_artifact_path(legacy_filename) + legacy_default = Path(legacy_filename) + if current_path != workspace_default: + return current_path + if current_path.exists() or not legacy_default.exists(): + return current_path + + click.echo( + "Warning: detected legacy default input at " + f"{legacy_default.resolve()}. Please migrate to " + f"{workspace_default.resolve()} (example: {option_name} {workspace_default.resolve()}).", + err=True, + ) + return legacy_default + + +def command_error( + ctx: CLIContext, + user_message: str, + logger_message: str, + *, + exc_info: bool = False, +) -> None: + """Print a command error message, log it, and exit consistently.""" + click.echo(user_message, err=True) + ctx.logger.error(logger_message, exc_info=exc_info) + raise SystemExit(1) + + +def review_plan_tui_streams_ok() -> bool: + """Return True if stdin/stdout appear to be an interactive terminal.""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def load_or_create_config(config: Path) -> Config: + """Load configuration from disk or create a default config file.""" + if config.exists(): + try: + cfg = load_config(config) + except yaml.YAMLError as e: + click.echo(f"Error: Invalid YAML syntax in configuration file: {e}", err=True) + click.echo("Using default configuration values.", err=True) + cfg = create_default_config(config) + except ValueError as e: + click.echo(f"Error: {e}", err=True) + click.echo("Using default configuration values.", err=True) + cfg = create_default_config(config) + else: + click.echo(f"Configuration file not found at {config}", err=True) + click.echo("Creating default configuration...", err=True) + cfg = create_default_config(config) + click.echo(f"Default configuration created at {config}", err=True) + + validation_errors = validate_config(cfg) + if validation_errors: + click.echo("Configuration validation errors:", err=True) + for error in validation_errors: + click.echo(f" - {error}", err=True) + click.echo("Please fix the configuration file and try again.", err=True) + raise ValueError("invalid configuration") + + return cfg + + +def initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext: + """Load config, apply CLI overrides, and build the CLI context.""" + cfg = load_or_create_config(config) + if log_level: + cfg.log_level = log_level.upper() + + logger = setup_logging(log_level=cfg.log_level) + return CLIContext(config=cfg, logger=logger) diff --git a/src/vlm/commands/config_cmd.py b/src/vlm/commands/config_cmd.py new file mode 100644 index 0000000..82ee940 --- /dev/null +++ b/src/vlm/commands/config_cmd.py @@ -0,0 +1,62 @@ +"""Config CLI command implementations.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from vlm.cli_helpers import command_error, default_config_path +from vlm.config import create_default_config, validate_config +from vlm.context import CLIContext + + +def config_init_cmd(ctx: CLIContext, path: Path) -> None: + """Initialize configuration file with defaults.""" + try: + if path.exists(): + click.echo(f"Configuration file already exists at {path}", err=True) + if not click.confirm("Overwrite existing configuration?"): + click.echo("Configuration initialization cancelled.") + return + + create_default_config(path) + click.echo(f"Configuration file created at {path}") + click.echo("Edit this file to customize your settings.") + + except Exception as e: + command_error( + ctx, + f"Error creating configuration: {e}", + f"Configuration creation failed: {e}", + exc_info=True, + ) + + +def config_show_cmd(ctx: CLIContext) -> None: + """Show current configuration.""" + cfg = ctx.config + click.echo("Current configuration:") + click.echo(f" Library root: {cfg.library_root}") + click.echo(f" Video extensions: {', '.join(cfg.video_extensions)}") + click.echo(f" Movie template: {cfg.movie_template}") + click.echo(f" Series template: {cfg.series_template}") + click.echo(f" Movie filename template: {cfg.movie_filename_template}") + click.echo(f" Series filename template: {cfg.series_filename_template}") + click.echo(f" Quarantine directory: {cfg.quarantine_dir}") + click.echo(f" Workspace directory: {cfg.workspace_dir}") + click.echo(f" Log level: {cfg.log_level}") + + +def config_validate_cmd(ctx: CLIContext) -> None: + """Validate configuration.""" + cfg = ctx.config + errors = validate_config(cfg) + + if not errors: + click.echo("Configuration is valid.") + else: + click.echo("Configuration validation errors:", err=True) + for error in errors: + click.echo(f" - {error}", err=True) + command_error(ctx, "Configuration validation failed.", "Configuration validation failed") diff --git a/src/vlm/commands/quarantine_cmd.py b/src/vlm/commands/quarantine_cmd.py new file mode 100644 index 0000000..a96ba48 --- /dev/null +++ b/src/vlm/commands/quarantine_cmd.py @@ -0,0 +1,149 @@ +"""Quarantine CLI command implementations.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click + +from vlm.cli_helpers import command_error +from vlm.context import CLIContext +from vlm.quarantine import QuarantineManager +from vlm.utils import format_size + + +def quarantine_list_cmd(ctx: CLIContext, category: Optional[str]) -> None: + """List quarantined files.""" + config = ctx.config + logger = ctx.logger + + try: + manager = QuarantineManager(config, logger) + entries = manager.list_quarantined(category=category) + + if not entries: + if category: + click.echo(f"No quarantined files found in category '{category}'.") + else: + click.echo("No quarantined files found.") + return + + click.echo() + if category: + click.echo(f"Quarantined files in category '{category}':") + else: + click.echo("Quarantined files:") + click.echo("=" * 80) + + for i, entry in enumerate(entries, 1): + click.echo(f"\n[{i}] {entry.quarantine_path.name}") + click.echo(f" Category: {entry.category}") + click.echo(f" Original: {entry.original_path}") + click.echo(f" Quarantine: {entry.quarantine_path}") + click.echo(f" Size: {format_size(entry.size_bytes)}") + click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}") + if entry.reason: + click.echo(f" Reason: {entry.reason}") + + click.echo() + click.echo("=" * 80) + click.echo(f"Total: {len(entries)} quarantined file(s)") + click.echo() + + logger.info( + "Listed %s quarantined files%s", + len(entries), + f" from category '{category}'" if category else "", + ) + + except Exception as e: + command_error( + ctx, + f"Error listing quarantined files: {e}", + f"Failed to list quarantined files: {e}", + exc_info=True, + ) + + +def quarantine_add_cmd(ctx: CLIContext, file: Path, reason: Optional[str]) -> None: + """Add file to quarantine.""" + config = ctx.config + logger = ctx.logger + + try: + manager = QuarantineManager(config, logger) + + click.echo(f"Quarantining file: {file}") + if reason: + click.echo(f"Reason: {reason}") + click.echo() + + result = manager.quarantine_file(file, reason=reason) + + if result.success: + click.echo("✓ File successfully quarantined!") + click.echo(f" Original: {result.operation.source_path}") + click.echo(f" Quarantine: {result.operation.destination_path}") + click.echo() + click.echo("To restore this file, run:") + click.echo(f" vlm quarantine restore {result.operation.destination_path}") + else: + command_error( + ctx, + f"✗ Failed to quarantine file: {result.error_message}", + f"Quarantine failed for {file}: {result.error_message}", + ) + + logger.info("Quarantined file: %s", file) + + except ValueError as e: + command_error(ctx, f"Error: {e}", f"Quarantine rejected: {e}") + + except Exception as e: + command_error( + ctx, + f"Error quarantining file: {e}", + f"Failed to quarantine file: {e}", + exc_info=True, + ) + + +def quarantine_restore_cmd(ctx: CLIContext, file: Path) -> None: + """Restore file from quarantine.""" + config = ctx.config + logger = ctx.logger + + try: + manager = QuarantineManager(config, logger) + + click.echo(f"Restoring file from quarantine: {file}") + click.echo() + + result = manager.restore_from_quarantine(file) + + if result.success: + click.echo("✓ File successfully restored!") + click.echo(f" Quarantine: {result.operation.source_path}") + click.echo(f" Restored to: {result.operation.destination_path}") + else: + if result.operation.has_conflict: + click.echo(f"✗ Cannot restore: {result.operation.conflict_reason}", err=True) + click.echo(f" Original location: {result.operation.destination_path}", err=True) + else: + click.echo(f"✗ Failed to restore file: {result.error_message}", err=True) + command_error( + ctx, + f"Error restoring file: {result.error_message or result.operation.conflict_reason or 'unknown error'}", + f"Failed to restore file: {file}", + ) + + logger.info("Restored file from quarantine: %s", file) + + except Exception as e: + command_error( + ctx, + f"Error restoring file: {e}", + f"Failed to restore file: {e}", + exc_info=True, + ) diff --git a/src/vlm/commands/report.py b/src/vlm/commands/report.py new file mode 100644 index 0000000..7b41730 --- /dev/null +++ b/src/vlm/commands/report.py @@ -0,0 +1,313 @@ +"""Report CLI command implementations.""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import click + +from vlm.cli_helpers import command_error, resolve_legacy_default_input_path +from vlm.context import CLIContext +from vlm.io import load_analysis_json, load_inventory_csv +from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile +from vlm.plan_render import preferred_plan_summary +from vlm.planner import load_plan +from vlm.reports import ( + generate_completeness_report, + generate_duplicate_report, + generate_inventory_report, + generate_summary_report, +) + + +def report_inventory_cmd( + ctx: CLIContext, + format: str, + input: Path, + output: Optional[Path], +) -> None: + """Generate inventory report.""" + config = ctx.config + logger = ctx.logger + + try: + input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") + click.echo(f"Loading inventory from: {input}") + + video_files = load_inventory_csv(input) + + click.echo(f"Loaded {len(video_files)} files") + click.echo() + + click.echo(f"Generating inventory report in {format} format...") + + report_format = "csv" if format == "text" else format + report_content = generate_inventory_report(video_files, report_format, config.library_root) + + if output: + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w", encoding="utf-8") as f: + f.write(report_content) + click.echo(f"Report saved to: {output}") + else: + click.echo() + click.echo(report_content) + + logger.info("Generated inventory report in %s format with %s files", format, len(video_files)) + + except FileNotFoundError: + command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") + + except Exception as e: + command_error( + ctx, + f"Error generating inventory report: {e}", + f"Inventory report generation failed: {e}", + exc_info=True, + ) + + +def report_completeness_cmd( + ctx: CLIContext, + format: str, + input: Path, + output: Optional[Path], + plan: Optional[Path], +) -> None: + """Generate completeness report.""" + config = ctx.config + logger = ctx.logger + + input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") + plan_summary = None + if plan: + if not plan.exists(): + click.echo(f"Error: Plan file not found: {plan}", err=True) + sys.exit(1) + execution_plan = load_plan(plan) + plan_summary = preferred_plan_summary(execution_plan) + + try: + click.echo(f"Loading analysis from: {input}") + + analysis_data = load_analysis_json(input) + + completeness_list = analysis_data.get("completeness", []) + + season_completeness = [] + for c in completeness_list: + season_completeness.append( + SeasonCompleteness( + series_title=c["series_title"], + season=c["season"], + episodes_found=c["episodes_found"], + episodes_missing=c["episodes_missing"], + ) + ) + + click.echo(f"Loaded {len(season_completeness)} series with gaps") + click.echo() + + click.echo(f"Generating completeness report in {format} format...") + report_content = generate_completeness_report( + season_completeness, format, config.library_root, plan_summary=plan_summary + ) + + if output: + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w", encoding="utf-8") as f: + f.write(report_content) + click.echo(f"Report saved to: {output}") + else: + click.echo() + click.echo(report_content) + + logger.info( + "Generated completeness report in %s format with %s series", + format, + len(season_completeness), + ) + + except FileNotFoundError: + command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") + + except json.JSONDecodeError as e: + command_error( + ctx, + f"Error: Failed to parse JSON file: {e}", + f"JSON parsing failed: {e}", + exc_info=True, + ) + + except Exception as e: + command_error( + ctx, + f"Error generating completeness report: {e}", + f"Completeness report generation failed: {e}", + exc_info=True, + ) + + +def report_duplicates_cmd( + ctx: CLIContext, + format: str, + input: Path, + output: Optional[Path], + plan: Optional[Path], +) -> None: + """Generate duplicate report.""" + config = ctx.config + logger = ctx.logger + + input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input") + plan_summary = None + if plan: + if not plan.exists(): + click.echo(f"Error: Plan file not found: {plan}", err=True) + sys.exit(1) + execution_plan = load_plan(plan) + plan_summary = preferred_plan_summary(execution_plan) + + try: + click.echo(f"Loading analysis from: {input}") + + analysis_data = load_analysis_json(input) + + duplicates_list = analysis_data.get("duplicates", []) + + duplicate_groups = [] + for d in duplicates_list: + identity_data = d["identity"] + + if identity_data["type"] == "movie": + identity = MovieIdentity( + title=identity_data["title"], + year=identity_data.get("year"), + confidence=1.0, + needs_review=False, + original_filename="", + ) + else: + identity = SeriesIdentity( + title=identity_data["title"], + season=identity_data.get("season"), + episodes=identity_data.get("episodes", []), + confidence=1.0, + needs_review=False, + original_filename="", + ) + + quality_by_path = { + str(item.get("path", "")): item for item in d.get("quality_comparison", []) + } + + files = [] + for file_path in d["files"]: + quality = quality_by_path.get(str(file_path), {}) + files.append( + VideoFile( + path=Path(file_path), + filename=Path(file_path).name, + size_bytes=int(quality.get("size_bytes", 0) or 0), + modified_timestamp=datetime.now(timezone.utc), + category="", + resolution=quality.get("resolution"), + codec=quality.get("codec"), + duration_seconds=quality.get("duration_seconds"), + bitrate_kbps=quality.get("bitrate_kbps"), + ) + ) + + duplicate_groups.append( + DuplicateGroup( + identity=identity, + files=files, + quality_comparison=d["quality_comparison"], + ) + ) + + click.echo(f"Loaded {len(duplicate_groups)} duplicate groups") + click.echo() + + click.echo(f"Generating duplicate report in {format} format...") + report_content = generate_duplicate_report( + duplicate_groups, format, config.library_root, plan_summary=plan_summary + ) + + if output: + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w", encoding="utf-8") as f: + f.write(report_content) + click.echo(f"Report saved to: {output}") + else: + click.echo() + click.echo(report_content) + + logger.info( + "Generated duplicate report in %s format with %s groups", + format, + len(duplicate_groups), + ) + + except FileNotFoundError: + command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") + + except json.JSONDecodeError as e: + command_error( + ctx, + f"Error: Failed to parse JSON file: {e}", + f"JSON parsing failed: {e}", + exc_info=True, + ) + + except Exception as e: + command_error( + ctx, + f"Error generating duplicate report: {e}", + f"Duplicate report generation failed: {e}", + exc_info=True, + ) + + +def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None: + """Generate summary report.""" + config = ctx.config + logger = ctx.logger + + try: + input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input") + click.echo(f"Loading inventory from: {input}") + + video_files = load_inventory_csv(input) + + click.echo(f"Loaded {len(video_files)} files") + click.echo() + + click.echo("Generating summary report...") + report_content = generate_summary_report(video_files, config.library_root) + + if output: + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w", encoding="utf-8") as f: + f.write(report_content) + click.echo(f"Report saved to: {output}") + else: + click.echo() + click.echo(report_content) + + logger.info("Generated summary report with %s files", len(video_files)) + + except FileNotFoundError: + command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}") + + except Exception as e: + command_error( + ctx, + f"Error generating summary report: {e}", + f"Summary report generation failed: {e}", + exc_info=True, + ) diff --git a/src/vlm/commands/review_plan.py b/src/vlm/commands/review_plan.py new file mode 100644 index 0000000..30847ee --- /dev/null +++ b/src/vlm/commands/review_plan.py @@ -0,0 +1,239 @@ +"""Review-plan and apply-review command implementations.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +import click + +from vlm.cli_helpers import command_error, default_artifact_path, resolve_legacy_default_input_path, review_plan_tui_streams_ok +from vlm.context import CLIContext +from vlm.io import load_analysis_json, load_identities_json +from vlm.plan_render import ( + duplicate_groups_from_plan, + preferred_plan_summary, + render_review_footer, + render_review_preview, + render_review_verdict, +) +from vlm.plan_review import GROUP_BY_CHOICES, prepare_review_rows, save_review_csv +from vlm.plan_structure_preview import write_structure_preview +from vlm.planner import apply_review_to_plan, load_plan, save_plan + + +def review_plan_cmd( + ctx: CLIContext, + input: Path, + output: Path, + season_threshold: int, + episode_threshold: int, + preview_limit: int, + show_all: bool, + tui: bool, + identities: Optional[Path], + analysis: Optional[Path], + group_by: str, + sample_safe: int, + structure_preview: Optional[Path], +) -> None: + """Review a plan and export high-risk operations for manual confirmation.""" + logger = ctx.logger + + try: + input = resolve_legacy_default_input_path(input, "input", "plan.json", "--input") + if season_threshold < 1 or episode_threshold < 1: + click.echo("Error: thresholds must be >= 1", err=True) + sys.exit(1) + if preview_limit < 1: + click.echo("Error: --preview-limit must be >= 1", err=True) + sys.exit(1) + if sample_safe < 0: + click.echo("Error: --sample-safe must be >= 0", err=True) + sys.exit(1) + group_by = group_by.lower() + if group_by not in GROUP_BY_CHOICES: + click.echo(f"Error: invalid --group-by {group_by}", err=True) + sys.exit(1) + + if tui: + if not review_plan_tui_streams_ok(): + click.echo("Error: --tui requires an interactive terminal (TTY)", err=True) + sys.exit(1) + + click.echo(f"Loading plan: {input}") + execution_plan = load_plan(input) + + identities_data = None + identities_path = identities + if identities_path is None: + default_id = default_artifact_path("identities.json") + if default_id.is_file(): + identities_path = default_id + if identities_path is not None and identities_path.is_file(): + identities_data = load_identities_json(identities_path) + click.echo(f"Loaded identities: {identities_path}") + + analysis_data = None + if analysis is not None and analysis.is_file(): + analysis_data = load_analysis_json(analysis) + click.echo(f"Loaded analysis: {analysis}") + + rows, counters = prepare_review_rows( + execution_plan, + season_threshold=season_threshold, + episode_threshold=episode_threshold, + library_root=ctx.config.library_root, + identities_data=identities_data, + analysis_data=analysis_data, + sample_safe=sample_safe, + group_by=group_by, + ) + + if structure_preview is not None: + write_structure_preview( + execution_plan, + ctx.config.library_root, + structure_preview, + ) + click.echo(f"Wrote structure preview: {structure_preview}") + + if tui: + from vlm.plan_review import build_duplicate_path_maps + from vlm.review_tui import ReviewTUIContext, run_plan_review_tui + + _, path_to_quality = build_duplicate_path_maps(analysis_data) + tui_ctx = ReviewTUIContext( + rows=rows, + counters=counters, + library_root=ctx.config.library_root, + output_csv=output, + plan_input=input, + summary_text=preferred_plan_summary(execution_plan), + path_to_quality=path_to_quality, + ) + rc = run_plan_review_tui(tui_ctx) + if rc != 0: + click.echo("Plan review aborted (no CSV written).", err=True) + sys.exit(rc) + click.echo(f"Saved manual review CSV to: {output}") + logger.info( + "Plan review TUI completed: total=%s high_risk=%s output=%s", + counters["total_operations"], + counters["high_risk_operations"], + output, + ) + return + + save_review_csv(rows, output) + + click.echo() + click.echo("Plan overview:") + click.echo(preferred_plan_summary(execution_plan)) + + click.echo() + click.echo("Plan review summary:") + click.echo(f" Total operations: {counters['total_operations']}") + click.echo(f" High-risk operations: {counters['high_risk_operations']}") + click.echo(f" manual_review: {counters['manual_review']}") + click.echo(f" sample_source: {counters['sample_source']}") + click.echo(f" high_season: {counters['high_season']}") + click.echo(f" high_episode: {counters['high_episode']}") + click.echo(f" conflicts: {counters['conflicts']}") + + click.echo() + click.echo(render_review_verdict(counters)) + + click.echo() + click.echo("High-risk operations preview:") + if rows: + preview_lines, hidden_count = render_review_preview( + rows, + preview_limit=preview_limit, + show_all=show_all, + library_root=ctx.config.library_root, + ) + for line in preview_lines: + click.echo(line) + if hidden_count > 0: + click.echo( + f" ... and {hidden_count} more high-risk operations " + "(use --show-all to display all)" + ) + else: + click.echo(" (none)") + + click.echo() + click.echo(f"Saved manual review CSV to: {output}") + + dup_groups = duplicate_groups_from_plan(execution_plan) + click.echo() + for line in render_review_footer( + counters=counters, + output_csv=output, + plan_input=input, + duplicate_groups=dup_groups, + ): + click.echo(line) + + logger.info( + "Plan review completed: total=%s high_risk=%s output=%s", + counters["total_operations"], + counters["high_risk_operations"], + output, + ) + except FileNotFoundError: + command_error(ctx, f"Error: Plan file not found: {input}", f"Plan file not found: {input}") + except json.JSONDecodeError as e: + command_error( + ctx, + f"Error: Failed to parse plan JSON: {e}", + f"Plan review JSON parsing failed: {e}", + exc_info=True, + ) + except Exception as e: + command_error( + ctx, + f"Error during plan review: {e}", + f"Plan review failed: {e}", + exc_info=True, + ) + + +def apply_review_cmd( + ctx: CLIContext, + plan: Path, + csv: Path, + output: Optional[Path], +) -> None: + """Apply modifications from a manual review CSV back to the plan JSON.""" + logger = ctx.logger + output_path = output or plan + + try: + click.echo(f"Loading plan: {plan}") + execution_plan = load_plan(plan) + + click.echo(f"Applying review from: {csv}") + updated_plan = apply_review_to_plan(execution_plan, csv) + + save_plan(updated_plan, output_path) + click.echo(f"Successfully updated plan saved to: {output_path}") + + modified = 0 + for i, op in enumerate(updated_plan.operations): + if op.operation_type != execution_plan.operations[i].operation_type: + modified += 1 + + click.echo(f"Total operations modified: {modified}") + logger.info("Applied review from %s to %s, modified %s ops", csv, output_path, modified) + + except Exception as e: + command_error( + ctx, + f"Error applying review: {e}", + f"Apply review failed: {e}", + exc_info=True, + ) diff --git a/src/vlm/commands/state_cmd.py b/src/vlm/commands/state_cmd.py new file mode 100644 index 0000000..fcaf607 --- /dev/null +++ b/src/vlm/commands/state_cmd.py @@ -0,0 +1,139 @@ +"""State tracking CLI command implementations.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click + +from vlm.cli_helpers import command_error +from vlm.context import CLIContext +from vlm.state import StateManager + + +def _state_path() -> Path: + return Path.home() / ".vlm" / "state.json" + + +def state_show_cmd(ctx: CLIContext, file: Path) -> None: + """Show state for a file.""" + logger = ctx.logger + + try: + manager = StateManager(_state_path()) + file_state = manager.get_file_state(file) + + if file_state is None: + click.echo(f"No state found for file: {file}") + click.echo("This file has not been tracked yet.") + else: + click.echo(f"State for file: {file}") + click.echo() + click.echo(f" Status: {file_state.status}") + if file_state.reason: + click.echo(f" Reason: {file_state.reason}") + click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + + logger.info("Showed state for file: %s", file) + + except Exception as e: + command_error( + ctx, + f"Error showing file state: {e}", + f"Failed to show file state: {e}", + exc_info=True, + ) + + +def state_set_cmd(ctx: CLIContext, file: Path, status: str, reason: Optional[str]) -> None: + """Set state for a file.""" + logger = ctx.logger + + try: + manager = StateManager(_state_path()) + manager.set_file_state(file, status, reason) + manager.save() + + click.echo(f"✓ State updated for file: {file}") + click.echo(f" Status: {status}") + if reason: + click.echo(f" Reason: {reason}") + + logger.info("Set state for file %s: status=%s, reason=%s", file, status, reason) + + except ValueError as e: + command_error(ctx, f"Error: {e}", f"Invalid status: {e}") + + except Exception as e: + command_error( + ctx, + f"Error setting file state: {e}", + f"Failed to set file state: {e}", + exc_info=True, + ) + + +def state_query_cmd(ctx: CLIContext, status: str) -> None: + """Query files by status.""" + logger = ctx.logger + + try: + manager = StateManager(_state_path()) + file_states = manager.query_by_status(status) + + if not file_states: + click.echo(f"No files found with status '{status}'.") + return + + click.echo(f"Files with status '{status}':") + click.echo("=" * 80) + click.echo() + + for i, file_state in enumerate(file_states, 1): + click.echo(f"[{i}] {file_state.file_path}") + if file_state.reason: + click.echo(f" Reason: {file_state.reason}") + click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}") + click.echo() + + click.echo("=" * 80) + click.echo(f"Total: {len(file_states)} file(s)") + + logger.info("Queried files with status '%s': %s found", status, len(file_states)) + + except Exception as e: + command_error( + ctx, + f"Error querying file states: {e}", + f"Failed to query file states: {e}", + exc_info=True, + ) + + +def state_clear_cmd(ctx: CLIContext, file: Path) -> None: + """Clear state for a file.""" + logger = ctx.logger + + try: + manager = StateManager(_state_path()) + file_state = manager.get_file_state(file) + + if file_state is None: + click.echo(f"No state found for file: {file}") + click.echo("Nothing to clear.") + return + + manager.clear_state(file) + manager.save() + + click.echo(f"✓ State cleared for file: {file}") + logger.info("Cleared state for file: %s", file) + + except Exception as e: + command_error( + ctx, + f"Error clearing file state: {e}", + f"Failed to clear file state: {e}", + exc_info=True, + ) diff --git a/src/vlm/exceptions.py b/src/vlm/exceptions.py deleted file mode 100644 index c603e1e..0000000 --- a/src/vlm/exceptions.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Exception hierarchy for Video Library Manager. - -Provides structured exceptions with exit codes and retry support for better -error handling and user experience. -""" - - -class VLMError(Exception): - """Base exception for all VLM errors. - - All VLM-specific exceptions inherit from this class. - - Attributes: - exit_code: Suggested exit code for CLI applications (1 by default) - is_retryable: Whether the error might succeed if retried - """ - - exit_code: int = 1 - is_retryable: bool = False - - def __init__(self, message: str, *args, **kwargs): - """Initialize VLM error. - - Args: - message: Error message - *args: Additional positional arguments for Exception - **kwargs: Additional keyword arguments - """ - super().__init__(message, *args) - self.message = message - - -class VLMConfigError(VLMError): - """Configuration-related errors. - - Raised when configuration is invalid, missing required fields, or cannot be loaded. - - Examples: - - Missing library_root - - Invalid YAML syntax - - Invalid category mappings - """ - - exit_code = 2 - is_retryable = False - - -class VLMFileSystemError(VLMError): - """File system operation errors. - - Raised when file operations fail (read, write, move, delete). - - Examples: - - Permission denied - - File not found - - Disk full - """ - - exit_code = 3 - is_retryable = False - - -class VLMIOError(VLMError): - """I/O errors for reading/writing data files. - - Raised when loading or saving inventory, identities, analysis, plans, etc. - - Examples: - - Invalid JSON format - - Schema version mismatch - - Missing required fields - """ - - exit_code = 4 - is_retryable = False - - -class VLMTransientError(VLMError): - """Transient errors that may succeed if retried. - - Raised for temporary failures that might resolve on retry. - - Examples: - - Network timeouts (TMDB API) - - Temporary file locks - - Rate limiting - """ - - exit_code = 1 # No special exit code (will retry) - is_retryable = True - - -class VLMValidationError(VLMError): - """Data validation errors. - - Raised when data fails validation checks. - - Examples: - - Invalid filename patterns - - Invalid season/episode numbers - - Invalid quality metrics - """ - - exit_code = 5 - is_retryable = False - - -class VLMQuarantineError(VLMError): - """Quarantine operation errors. - - Raised when quarantine or restore operations fail. - - Examples: - - Cannot quarantine file from unsupported category - - Quarantine manifest corruption - - File already in quarantine - """ - - exit_code = 6 - is_retryable = False diff --git a/src/vlm/parser.py b/src/vlm/parser.py index 5ce6075..cbe97ab 100644 --- a/src/vlm/parser.py +++ b/src/vlm/parser.py @@ -250,16 +250,20 @@ def parse_series( break - # Clean the title + # Clean the title (fall back when quality tags consumed the only title token) if title_part: title_part = remove_quality_tags(title_part) title_part = remove_release_groups(title_part) - title_part = humanize_parsed_title(title_part or name_without_ext) + if not title_part.strip(): + title_part = name_without_ext + title_part = humanize_parsed_title(title_part) else: # If no title part found, use the whole filename cleaned title_part = remove_quality_tags(name_without_ext) title_part = remove_release_groups(title_part) - title_part = humanize_parsed_title(title_part or name_without_ext) + if not title_part.strip(): + title_part = name_without_ext + title_part = humanize_parsed_title(title_part) # Determine if review is needed needs_review = season is None or len(episodes) == 0 diff --git a/src/vlm/plan_render.py b/src/vlm/plan_render.py index a45cf8b..079698b 100644 --- a/src/vlm/plan_render.py +++ b/src/vlm/plan_render.py @@ -81,6 +81,9 @@ def render_review_footer( ) lines.append(f" vlm apply-review --plan {plan_input} --csv {output_csv}") lines.append(f" 2. Dry-run after apply-review: vlm execute --plan {plan_input}") + lines.append( + f" 3. Execute with review gate: vlm execute --plan {plan_input} --confirm --require-review" + ) else: lines.append("Next steps:") lines.append(f" 1. Dry-run: vlm execute --plan {plan_input}") diff --git a/src/vlm/plan_review.py b/src/vlm/plan_review.py index 30411e2..efe429b 100644 --- a/src/vlm/plan_review.py +++ b/src/vlm/plan_review.py @@ -10,7 +10,10 @@ from typing import Any from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, SeriesIdentity from vlm.review_display import display_path -from vlm.utils import format_size, is_sample_path +from vlm.utils import canonical_path_str, format_size, is_sample_path, utc_now + +REVIEW_APPLIED_AT_KEY = "review_applied_at" +REVIEW_CSV_PATH_KEY = "review_csv_path" REVIEW_CSV_BASE_FIELDS = [ "index", @@ -39,6 +42,14 @@ REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS GROUP_BY_CHOICES = ("none", "reason", "title", "duplicate") +def normalized_path_key(path_value: str | Path) -> str: + """Normalize path-like values for duplicate-group and lookup matching.""" + text = str(path_value).strip() + if not text: + return "" + return canonical_path_str(Path(text.replace("\\", "/"))) + + def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int | None]: season: int | None = None episode: int | None = None @@ -156,10 +167,12 @@ def build_duplicate_path_maps( if not isinstance(quality_list, list): quality_list = [] for file_path in dup.get("files", []) or []: - path_key = str(file_path) + path_key = normalized_path_key(file_path) + if not path_key: + continue path_to_group[path_key] = group_id for qc in quality_list: - if isinstance(qc, dict) and str(qc.get("path")) == path_key: + if isinstance(qc, dict) and normalized_path_key(qc.get("path", "")) == path_key: path_to_quality[path_key] = qc break @@ -244,7 +257,7 @@ def enrich_review_row( enriched[key] = id_ctx[key] if path_to_duplicate_group and source_path: - gid = path_to_duplicate_group.get(source_path) + gid = path_to_duplicate_group.get(normalized_path_key(source_path)) if gid and not enriched.get("duplicate_group_id"): enriched["duplicate_group_id"] = gid @@ -277,6 +290,43 @@ def enrich_review_rows( return result +def _risk_flags_for_operation( + op: FileOperation, + *, + season_threshold: int = 20, + episode_threshold: int = 40, +) -> list[str]: + """Return risk flag keys for an operation, or empty if not flagged.""" + if op.operation_type == "no-op" and op.reason.casefold().startswith("modified via manual review"): + return [] + + flags: list[str] = [] + reason_l = op.reason.casefold() + if "manual review" in reason_l: + flags.append("manual_review") + if is_sample_path(op.source_path): + flags.append("sample_source") + season, episode = _extract_season_episode(op) + if season is not None and season >= season_threshold: + flags.append("high_season") + if episode is not None and episode >= episode_threshold: + flags.append("high_episode") + if op.has_conflict: + flags.append("conflict") + rc = op.review_context if hasattr(op, "review_context") else {} + if isinstance(rc, dict) and rc.get("duplicate_group_id") and op.operation_type == "quarantine": + if "duplicate" not in flags: + flags.append("duplicate") + elif "duplicate" in reason_l and "duplicate" not in flags: + flags.append("duplicate") + return flags + + +def _is_actionable_high_risk(op: FileOperation, flags: list[str]) -> bool: + """True when review is required before confirmed execute (would modify files).""" + return bool(flags) and op.operation_type != "no-op" + + def review_plan( plan: ExecutionPlan, season_threshold: int = 20, @@ -287,6 +337,7 @@ def review_plan( counters = { "total_operations": len(plan.operations), "high_risk_operations": 0, + "review_export_rows": 0, "manual_review": 0, "sample_source": 0, "high_season": 0, @@ -295,46 +346,72 @@ def review_plan( } for idx, op in enumerate(plan.operations, start=1): - flags: list[str] = [] - reason_l = op.reason.casefold() - if "manual review" in reason_l: - flags.append("manual_review") + flags = _risk_flags_for_operation( + op, + season_threshold=season_threshold, + episode_threshold=episode_threshold, + ) + if not flags: + continue + if "manual_review" in flags: counters["manual_review"] += 1 - if is_sample_path(op.source_path): - flags.append("sample_source") + if "sample_source" in flags: counters["sample_source"] += 1 - season, episode = _extract_season_episode(op) - if season is not None and season >= season_threshold: - flags.append("high_season") + if "high_season" in flags: counters["high_season"] += 1 - if episode is not None and episode >= episode_threshold: - flags.append("high_episode") + if "high_episode" in flags: counters["high_episode"] += 1 - if op.has_conflict: - flags.append("conflict") + if "conflict" in flags: counters["conflicts"] += 1 - rc = op.review_context if hasattr(op, "review_context") else {} - if isinstance(rc, dict) and rc.get("duplicate_group_id") and op.operation_type == "quarantine": - if "duplicate" not in flags: - flags.append("duplicate") - elif "duplicate" in reason_l and "duplicate" not in flags: - flags.append("duplicate") - if flags: + counters["review_export_rows"] += 1 + if _is_actionable_high_risk(op, flags): counters["high_risk_operations"] += 1 - rows.append( - { - "index": str(idx), - "operation_type": op.operation_type, - "risk_flags": "|".join(flags), - "source_path": str(op.source_path), - "destination_path": str(op.destination_path) if op.destination_path else "", - "reason": op.reason, - } - ) + rows.append( + { + "index": str(idx), + "operation_type": op.operation_type, + "risk_flags": "|".join(flags), + "source_path": str(op.source_path), + "destination_path": str(op.destination_path) if op.destination_path else "", + "reason": op.reason, + } + ) return rows, counters +def _load_review_csv_by_index(csv_path: Path) -> dict[int, dict[str, str]]: + """Load manual review CSV rows keyed by 1-based operation index.""" + indexed: dict[int, dict[str, str]] = {} + with open(csv_path, "r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + for row in reader: + try: + indexed[int(row["index"])] = row + except (KeyError, ValueError, TypeError): + continue + return indexed + + +def _actionable_high_risk_indices( + plan: ExecutionPlan, + *, + season_threshold: int = 20, + episode_threshold: int = 40, +) -> list[int]: + """Return 1-based indices of operations that block execute until reviewed.""" + indices: list[int] = [] + for idx, op in enumerate(plan.operations, start=1): + flags = _risk_flags_for_operation( + op, + season_threshold=season_threshold, + episode_threshold=episode_threshold, + ) + if _is_actionable_high_risk(op, flags): + indices.append(idx) + return indices + + def append_sample_safe_rows( plan: ExecutionPlan, rows: list[dict[str, str]], @@ -406,32 +483,56 @@ def check_review_requirements( season_threshold: int = 20, episode_threshold: int = 40, ) -> list[str]: - """Return human-readable errors when review is required but missing or stale.""" - _, counters = review_plan( + """Return human-readable errors when review is required but missing or not applied.""" + actionable = _actionable_high_risk_indices( plan, season_threshold=season_threshold, episode_threshold=episode_threshold, ) - high_risk = counters.get("high_risk_operations", 0) - if high_risk == 0: + if not actionable: return [] errors: list[str] = [] + count = len(actionable) if not review_csv.is_file(): errors.append( - f"Review required: {high_risk} high-risk operation(s) but review CSV not found: {review_csv}. " + f"Review required: {count} actionable high-risk operation(s) but review CSV not found: {review_csv}. " f"Run: vlm review-plan --input {plan_path}" ) return errors + if not plan.metadata.get(REVIEW_APPLIED_AT_KEY): + errors.append( + f"Review required: {count} actionable high-risk operation(s) but plan was not updated via apply-review. " + f"Run: vlm apply-review --plan {plan_path} --csv {review_csv}" + ) + return errors + try: - if review_csv.stat().st_mtime < plan_path.stat().st_mtime: - errors.append( - f"Review CSV is older than plan ({review_csv}). " - f"Re-run review-plan and apply-review before execute --confirm." - ) + csv_rows = _load_review_csv_by_index(review_csv) except OSError as exc: - errors.append(f"Could not compare review CSV timestamps: {exc}") + errors.append(f"Could not read review CSV: {exc}") + return errors + + missing = [idx for idx in actionable if idx not in csv_rows] + if missing: + preview = ", ".join(str(i) for i in missing[:8]) + suffix = f" (and {len(missing) - 8} more)" if len(missing) > 8 else "" + errors.append( + f"Review CSV missing rows for actionable operation index(es): {preview}{suffix}. " + f"Re-run: vlm review-plan --input {plan_path}" + ) + + recorded_csv = plan.metadata.get(REVIEW_CSV_PATH_KEY) + if recorded_csv: + try: + if Path(recorded_csv).resolve() != review_csv.resolve(): + errors.append( + f"Review CSV path does not match last apply-review ({recorded_csv}). " + f"Re-run apply-review with --csv {review_csv}" + ) + except OSError: + pass return errors diff --git a/src/vlm/planner.py b/src/vlm/planner.py index f43405e..54d7ebc 100644 --- a/src/vlm/planner.py +++ b/src/vlm/planner.py @@ -14,9 +14,14 @@ from dataclasses import replace from vlm.config import Config from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index from vlm.io import load_execution_plan, save_execution_plan -from vlm.plan_review import build_duplicate_path_maps, build_review_context +from vlm.plan_review import ( + REVIEW_APPLIED_AT_KEY, + REVIEW_CSV_PATH_KEY, + build_duplicate_path_maps, + build_review_context, + normalized_path_key, +) from vlm.utils import ( - canonical_path_str, is_sample_path, is_within_root, sanitize_path_component, @@ -39,21 +44,13 @@ NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds c NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)" -def _normalized_path_key(path_value: str | Path) -> str: - """Normalize path-like values for duplicate-group matching.""" - text = str(path_value).strip() - if not text: - return "" - return canonical_path_str(Path(text.replace("\\", "/"))) - - def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]: """Index duplicate quality entries by canonicalized path.""" lookup: dict[str, dict] = {} for quality in quality_comparison: quality_path = quality.get("path") if isinstance(quality_path, str) and quality_path.strip(): - lookup[_normalized_path_key(quality_path)] = quality + lookup[normalized_path_key(quality_path)] = quality return lookup @@ -151,12 +148,12 @@ def generate_plan( if config.duplicate_keep != "manual": path_to_index = { - _normalized_path_key(vf.path): i for i, (vf, _) in enumerate(identities) + normalized_path_key(vf.path): i for i, (vf, _) in enumerate(identities) } for dup in analysis_data.get("duplicates", []): paths = dup.get("files", []) normalized_paths = [ - _normalized_path_key(path) + normalized_path_key(path) for path in paths if isinstance(path, str) and path.strip() ] @@ -183,7 +180,7 @@ def generate_plan( if not items: continue quality_list = [ - path_to_qc.get(_normalized_path_key(path), {}) for path, _ in items + path_to_qc.get(normalized_path_key(path), {}) for path, _ in items ] try: if config.duplicate_keep == "by_quality" and any(not qc for qc in quality_list): @@ -205,7 +202,7 @@ def generate_plan( if keep_idx is None: continue keep_identity_index = valid_indices[keep_idx] - duplicate_keep_paths.add(str(identities[keep_identity_index][0].path)) + duplicate_keep_paths.add(normalized_path_key(identities[keep_identity_index][0].path)) quarantine_indices = set(valid_indices) - {keep_identity_index} reason = _select_duplicate_quarantine_reason( config.duplicate_keep, @@ -578,10 +575,10 @@ def _stamp_review_context_on_operations( if i < len(identities): vf, identity = identities[i] if isinstance(identity, (MovieIdentity, SeriesIdentity)): - gid = path_to_dup_group.get(str(vf.path), "") + gid = path_to_dup_group.get(normalized_path_key(vf.path), "") keep_candidate = None if gid: - keep_candidate = str(vf.path) in duplicate_keep_paths + keep_candidate = normalized_path_key(vf.path) in duplicate_keep_paths ctx = build_review_context( vf.path, identity, @@ -784,12 +781,19 @@ def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan: except (ValueError, KeyError): continue + metadata = dict(plan.metadata) + metadata[REVIEW_APPLIED_AT_KEY] = utc_now().isoformat() + try: + metadata[REVIEW_CSV_PATH_KEY] = str(csv_path.resolve()) + except OSError: + metadata[REVIEW_CSV_PATH_KEY] = str(csv_path) + if modified_count > 0: - # Re-generate summary and human summary for the updated plan summary = _generate_summary(updated_ops) summary_by_reason = _generate_summary_by_reason(updated_ops) - human_summary = _generate_human_summary(updated_ops, summary, summary_by_reason, plan.metadata) - + human_summary = _generate_human_summary( + updated_ops, summary, summary_by_reason, metadata + ) return ExecutionPlan( plan_id=plan.plan_id, created_at=plan.created_at, @@ -797,7 +801,15 @@ def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan: summary=summary, summary_by_reason=summary_by_reason, human_summary=human_summary, - metadata=plan.metadata, + metadata=metadata, ) - - return plan + + return ExecutionPlan( + plan_id=plan.plan_id, + created_at=plan.created_at, + operations=updated_ops, + summary=plan.summary, + summary_by_reason=plan.summary_by_reason, + human_summary=plan.human_summary, + metadata=metadata, + ) diff --git a/src/vlm/review_display.py b/src/vlm/review_display.py index c6f453c..710ca08 100644 --- a/src/vlm/review_display.py +++ b/src/vlm/review_display.py @@ -8,9 +8,11 @@ from pathlib import Path RISK_FLAG_LABELS: dict[str, str] = { "manual_review": "需人工判断", "sample_source": "样片路径", + "spot_check": "安全抽检", "high_season": "季号偏高", "high_episode": "集号偏高", "conflict": "目标冲突", + "duplicate": "重复项", } diff --git a/src/vlm/review_tui.py b/src/vlm/review_tui.py index 4ca68d6..ce9de74 100644 --- a/src/vlm/review_tui.py +++ b/src/vlm/review_tui.py @@ -15,7 +15,7 @@ from vlm.review_display import ( ) from vlm.utils import format_size -FILTER_CHOICES = ("all", "manual_review", "sample_source", "conflict", "duplicate") +FILTER_CHOICES = ("all", "manual_review", "sample_source", "spot_check", "conflict", "duplicate") def _row_matches_filter(row: dict[str, str], filter_key: str) -> bool: @@ -101,8 +101,8 @@ if TEXTUAL_IMPORT_ERROR is None: "", f"将要写入: {self._ctx.output_csv}", "", - "默认仅审核标记为高危的操作(与 CSV 行一致)。", - "筛选: 1全部 2需人工 3样片 4冲突 5重复 · g 驳回整组重复", + "高危操作写入 CSV;可选 --sample-safe 追加安全抽检行。", + "筛选: 1全部 2需人工 3样片路径 4安全抽检 5冲突 6重复 · g 驳回整组重复", "", "Enter 进入审核 · q 退出", ] @@ -168,8 +168,9 @@ if TEXTUAL_IMPORT_ERROR is None: Binding("1", "filter_all", show=False), Binding("2", "filter_manual", show=False), Binding("3", "filter_sample", show=False), - Binding("4", "filter_conflict", show=False), - Binding("5", "filter_duplicate", show=False), + Binding("4", "filter_spot_check", show=False), + Binding("5", "filter_conflict", show=False), + Binding("6", "filter_duplicate", show=False), ] def __init__(self, ctx: ReviewTUIContext) -> None: @@ -199,7 +200,7 @@ if TEXTUAL_IMPORT_ERROR is None: with ScrollableContainer(id="detail_scroll"): yield Static("", id="detail_text") yield Static( - "1-5 筛选 · ↑↓ j/k · a 保留 · r 驳回 · g 驳回重复组 · u 撤销 · s 保存 · q 退出", + "1-6 筛选 · ↑↓ j/k · a 保留 · r 驳回 · g 驳回重复组 · u 撤销 · s 保存 · q 退出", id="footer_line", ) yield Footer() @@ -262,7 +263,8 @@ if TEXTUAL_IMPORT_ERROR is None: labels = { "all": "全部", "manual_review": "需人工", - "sample_source": "样片", + "sample_source": "样片路径", + "spot_check": "安全抽检", "conflict": "冲突", "duplicate": "重复", } @@ -469,6 +471,10 @@ if TEXTUAL_IMPORT_ERROR is None: self._filter = "sample_source" self._rebuild_table() + def action_filter_spot_check(self) -> None: + self._filter = "spot_check" + self._rebuild_table() + def action_filter_conflict(self) -> None: self._filter = "conflict" self._rebuild_table() diff --git a/tests/test_cli_review_plan.py b/tests/test_cli_review_plan.py index aa85759..7bed230 100644 --- a/tests/test_cli_review_plan.py +++ b/tests/test_cli_review_plan.py @@ -99,7 +99,7 @@ def test_review_plan_generates_csv_summary_and_preview(tmp_path): assert "Plan overview:" in result.output assert "这是测试计划摘要" in result.output assert "Plan review summary:" in result.output - assert "High-risk operations: 2" in result.output + assert "High-risk operations: 1" in result.output assert "High-risk operations preview:" in result.output assert "source:" in result.output assert "destination:" in result.output @@ -294,7 +294,7 @@ def test_review_plan_tui_stubbed_success(tmp_path, monkeypatch): summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0}, ) - monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True) + monkeypatch.setattr("vlm.commands.review_plan.review_plan_tui_streams_ok", lambda: True) monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 0) output_csv = tmp_path / "review.csv" @@ -332,7 +332,7 @@ def test_review_plan_tui_stubbed_abort(tmp_path, monkeypatch): summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0}, ) - monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True) + monkeypatch.setattr("vlm.commands.review_plan.review_plan_tui_streams_ok", lambda: True) monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 1) output_csv = tmp_path / "review.csv" diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index c040716..d33ad51 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -1,19 +1,23 @@ """Tests for plan review helpers.""" -import json +import csv from pathlib import Path from vlm.models import ExecutionPlan, FileOperation from vlm.plan_review import ( + REVIEW_APPLIED_AT_KEY, + build_duplicate_path_maps, build_identity_lookup, check_review_requirements, enrich_review_rows, + normalized_path_key, prepare_review_rows, review_plan, + save_review_csv, ) from vlm.plan_render import render_review_verdict from vlm.plan_structure_preview import build_structure_preview_lines -from vlm.planner import load_plan, save_plan +from vlm.planner import apply_review_to_plan, load_plan, save_plan from vlm.utils import utc_now @@ -78,7 +82,7 @@ def test_build_identity_lookup_and_enrich(tmp_path): def test_check_review_requirements_missing_csv(tmp_path): plan_path = tmp_path / "plan.json" op = FileOperation( - operation_type="no-op", + operation_type="quarantine", source_path=tmp_path / "Series.S20E01.mkv", destination_path=None, reason="Series needs manual review (season exceeds configured threshold)", @@ -92,6 +96,95 @@ def test_check_review_requirements_missing_csv(tmp_path): assert "not found" in errors[0] +def test_check_review_requirements_requires_apply_review(tmp_path): + plan_path = tmp_path / "plan.json" + csv_path = tmp_path / "plan_manual_review.csv" + op = FileOperation( + operation_type="move", + source_path=tmp_path / "a.mkv", + destination_path=tmp_path / "lib/a.mkv", + reason="Series needs manual review (season exceeds configured threshold)", + has_conflict=True, + ) + plan = _minimal_plan([op]) + save_plan(plan, plan_path) + rows, _ = review_plan(plan) + save_review_csv(rows, csv_path) + + errors = check_review_requirements(load_plan(plan_path), plan_path, csv_path) + assert len(errors) == 1 + assert "apply-review" in errors[0] + + +def test_check_review_requirements_passes_after_apply_review(tmp_path): + plan_path = tmp_path / "plan.json" + csv_path = tmp_path / "plan_manual_review.csv" + op = FileOperation( + operation_type="move", + source_path=tmp_path / "a.mkv", + destination_path=tmp_path / "lib/a.mkv", + reason="Series needs manual review (season exceeds configured threshold)", + has_conflict=True, + ) + plan = _minimal_plan([op]) + save_plan(plan, plan_path) + rows, _ = review_plan(plan) + for row in rows: + row["operation_type"] = "no-op" + save_review_csv(rows, csv_path) + + updated = apply_review_to_plan(load_plan(plan_path), csv_path) + save_plan(updated, plan_path) + + errors = check_review_requirements(load_plan(plan_path), plan_path, csv_path) + assert errors == [] + + +def test_planner_noop_manual_review_does_not_block_execute_gate(tmp_path): + plan_path = tmp_path / "plan.json" + op = FileOperation( + operation_type="no-op", + source_path=tmp_path / "Series.S20E01.mkv", + destination_path=None, + reason="Series needs manual review (season exceeds configured threshold)", + has_conflict=False, + ) + plan = _minimal_plan([op]) + save_plan(plan, plan_path) + errors = check_review_requirements(load_plan(plan_path), plan_path, tmp_path / "missing.csv") + assert errors == [] + + +def test_review_plan_skips_applied_noop_reason(tmp_path): + op = FileOperation( + operation_type="no-op", + source_path=tmp_path / "a.mkv", + destination_path=None, + reason="Modified via manual review: Series needs manual review (season exceeds configured threshold)", + has_conflict=False, + ) + rows, counters = review_plan(_minimal_plan([op])) + assert rows == [] + assert counters["high_risk_operations"] == 0 + + +def test_build_duplicate_path_maps_uses_canonical_keys(tmp_path): + raw = str(tmp_path / "movie.mkv") + analysis = { + "duplicates": [ + { + "identity": {"type": "movie", "title": "X", "year": 2020}, + "files": [raw], + "quality_comparison": [{"path": raw, "resolution": "1080p"}], + } + ] + } + path_to_group, path_to_quality = build_duplicate_path_maps(analysis) + key = normalized_path_key(raw) + assert path_to_group[key] == "movie:X:2020" + assert path_to_quality[key]["resolution"] == "1080p" + + def test_structure_preview_lines(tmp_path): lib = tmp_path / "library" op = FileOperation( diff --git a/uv.lock b/uv.lock index a3f0299..dfd4e3d 100644 --- a/uv.lock +++ b/uv.lock @@ -349,7 +349,6 @@ dependencies = [ dev = [ { name = "hypothesis" }, { name = "pytest" }, - { name = "textual" }, ] tui = [ { name = "textual" }, @@ -361,7 +360,6 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "textual", marker = "extra == 'dev'", specifier = ">=0.47.0" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" }, ] provides-extras = ["dev", "tui"]