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 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-05-21 10:36:03 +08:00
co-authored by Claude Sonnet 4.5 Cursor
parent 5f0b531269
commit 79797644e1
40 changed files with 1714 additions and 1165 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
- Core package lives in `src/vlm/`. - 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`). - 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`). - 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`. - Project metadata and tool config are in `pyproject.toml`.
+11
View File
@@ -1,5 +1,16 @@
# Changelog # 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 ## 2026-04-07
### Review-plan Safety & Validation Hardening ### Review-plan Safety & Validation Hardening
+1 -1
View File
@@ -158,7 +158,7 @@ Key settings:
- `log_level` - logging verbosity - `log_level` - logging verbosity
- `categories` - mapping of category names to directory name lists - `categories` - mapping of category names to directory name lists
- `enrichment` (or `enrich`) - TMDB/api_keys, cache_db, translation, reputation; see README for full schema - `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 ### Category Mappings
+3 -2
View File
@@ -3,7 +3,7 @@
## Documentation Status ## Documentation Status
- Last synchronized: **2026-04-07** - 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`. - 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. - 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. - 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 # Install with development dependencies
uv pip install -e ".[dev]" 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]" 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. The standard CLI remains fully usable without Textual; the dependency is imported only when `vlm review-plan --tui` is requested.
+13
View File
@@ -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).
@@ -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()` L11831185 | 仅调用 `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 模块化(12d
按 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` 行数 &lt; 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 逆序 revertPhase 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.51h | ✅ 建议首 PR |
| 2 文档归档 | 0.5h | ✅ 可与 Phase 1 同 PR |
| 3 CLI 拆分 | 12d | ✅ 单独 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 | ~300400 |
| 根目录 *.md(审查类) | ~12 | 0(已归档) | 0 |
| pytest | 517 | 517 | 517 |
---
*本计划只覆盖「删无贡献 + 合重复 + 搬 CLI」;更深的安全加固与 domain 文件拆分见后续 `plans/2026-*-phase2-internal-split-v1.md`(待 Phase 13 完成后再写)。*
-1
View File
@@ -13,7 +13,6 @@ dependencies = [
dev = [ dev = [
"pytest>=7.4.0", "pytest>=7.4.0",
"hypothesis>=6.82.0", "hypothesis>=6.82.0",
"textual>=0.47.0",
] ]
tui = [ tui = [
"textual>=0.47.0", "textual>=0.47.0",
+87 -954
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -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)
+62
View File
@@ -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")
+149
View File
@@ -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,
)
+313
View File
@@ -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,
)
+239
View File
@@ -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,
)
+139
View File
@@ -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,
)
-120
View File
@@ -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
+7 -3
View File
@@ -250,16 +250,20 @@ def parse_series(
break break
# Clean the title # Clean the title (fall back when quality tags consumed the only title token)
if title_part: if title_part:
title_part = remove_quality_tags(title_part) title_part = remove_quality_tags(title_part)
title_part = remove_release_groups(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: else:
# If no title part found, use the whole filename cleaned # If no title part found, use the whole filename cleaned
title_part = remove_quality_tags(name_without_ext) title_part = remove_quality_tags(name_without_ext)
title_part = remove_release_groups(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)
# Determine if review is needed # Determine if review is needed
needs_review = season is None or len(episodes) == 0 needs_review = season is None or len(episodes) == 0
+3
View File
@@ -81,6 +81,9 @@ def render_review_footer(
) )
lines.append(f" vlm apply-review --plan {plan_input} --csv {output_csv}") 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" 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: else:
lines.append("Next steps:") lines.append("Next steps:")
lines.append(f" 1. Dry-run: vlm execute --plan {plan_input}") lines.append(f" 1. Dry-run: vlm execute --plan {plan_input}")
+146 -45
View File
@@ -10,7 +10,10 @@ from typing import Any
from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, SeriesIdentity from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, SeriesIdentity
from vlm.review_display import display_path 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 = [ REVIEW_CSV_BASE_FIELDS = [
"index", "index",
@@ -39,6 +42,14 @@ REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS
GROUP_BY_CHOICES = ("none", "reason", "title", "duplicate") 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]: def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int | None]:
season: int | None = None season: int | None = None
episode: int | None = None episode: int | None = None
@@ -156,10 +167,12 @@ def build_duplicate_path_maps(
if not isinstance(quality_list, list): if not isinstance(quality_list, list):
quality_list = [] quality_list = []
for file_path in dup.get("files", []) or []: 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 path_to_group[path_key] = group_id
for qc in quality_list: 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 path_to_quality[path_key] = qc
break break
@@ -244,7 +257,7 @@ def enrich_review_row(
enriched[key] = id_ctx[key] enriched[key] = id_ctx[key]
if path_to_duplicate_group and source_path: 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"): if gid and not enriched.get("duplicate_group_id"):
enriched["duplicate_group_id"] = gid enriched["duplicate_group_id"] = gid
@@ -277,6 +290,43 @@ def enrich_review_rows(
return result 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( def review_plan(
plan: ExecutionPlan, plan: ExecutionPlan,
season_threshold: int = 20, season_threshold: int = 20,
@@ -287,6 +337,7 @@ def review_plan(
counters = { counters = {
"total_operations": len(plan.operations), "total_operations": len(plan.operations),
"high_risk_operations": 0, "high_risk_operations": 0,
"review_export_rows": 0,
"manual_review": 0, "manual_review": 0,
"sample_source": 0, "sample_source": 0,
"high_season": 0, "high_season": 0,
@@ -295,46 +346,72 @@ def review_plan(
} }
for idx, op in enumerate(plan.operations, start=1): for idx, op in enumerate(plan.operations, start=1):
flags: list[str] = [] flags = _risk_flags_for_operation(
reason_l = op.reason.casefold() op,
if "manual review" in reason_l: season_threshold=season_threshold,
flags.append("manual_review") episode_threshold=episode_threshold,
)
if not flags:
continue
if "manual_review" in flags:
counters["manual_review"] += 1 counters["manual_review"] += 1
if is_sample_path(op.source_path): if "sample_source" in flags:
flags.append("sample_source")
counters["sample_source"] += 1 counters["sample_source"] += 1
season, episode = _extract_season_episode(op) if "high_season" in flags:
if season is not None and season >= season_threshold:
flags.append("high_season")
counters["high_season"] += 1 counters["high_season"] += 1
if episode is not None and episode >= episode_threshold: if "high_episode" in flags:
flags.append("high_episode")
counters["high_episode"] += 1 counters["high_episode"] += 1
if op.has_conflict: if "conflict" in flags:
flags.append("conflict")
counters["conflicts"] += 1 counters["conflicts"] += 1
rc = op.review_context if hasattr(op, "review_context") else {} counters["review_export_rows"] += 1
if isinstance(rc, dict) and rc.get("duplicate_group_id") and op.operation_type == "quarantine": if _is_actionable_high_risk(op, flags):
if "duplicate" not in flags:
flags.append("duplicate")
elif "duplicate" in reason_l and "duplicate" not in flags:
flags.append("duplicate")
if flags:
counters["high_risk_operations"] += 1 counters["high_risk_operations"] += 1
rows.append( rows.append(
{ {
"index": str(idx), "index": str(idx),
"operation_type": op.operation_type, "operation_type": op.operation_type,
"risk_flags": "|".join(flags), "risk_flags": "|".join(flags),
"source_path": str(op.source_path), "source_path": str(op.source_path),
"destination_path": str(op.destination_path) if op.destination_path else "", "destination_path": str(op.destination_path) if op.destination_path else "",
"reason": op.reason, "reason": op.reason,
} }
) )
return rows, counters 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( def append_sample_safe_rows(
plan: ExecutionPlan, plan: ExecutionPlan,
rows: list[dict[str, str]], rows: list[dict[str, str]],
@@ -406,32 +483,56 @@ def check_review_requirements(
season_threshold: int = 20, season_threshold: int = 20,
episode_threshold: int = 40, episode_threshold: int = 40,
) -> list[str]: ) -> list[str]:
"""Return human-readable errors when review is required but missing or stale.""" """Return human-readable errors when review is required but missing or not applied."""
_, counters = review_plan( actionable = _actionable_high_risk_indices(
plan, plan,
season_threshold=season_threshold, season_threshold=season_threshold,
episode_threshold=episode_threshold, episode_threshold=episode_threshold,
) )
high_risk = counters.get("high_risk_operations", 0) if not actionable:
if high_risk == 0:
return [] return []
errors: list[str] = [] errors: list[str] = []
count = len(actionable)
if not review_csv.is_file(): if not review_csv.is_file():
errors.append( 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}" f"Run: vlm review-plan --input {plan_path}"
) )
return errors 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: try:
if review_csv.stat().st_mtime < plan_path.stat().st_mtime: csv_rows = _load_review_csv_by_index(review_csv)
errors.append(
f"Review CSV is older than plan ({review_csv}). "
f"Re-run review-plan and apply-review before execute --confirm."
)
except OSError as exc: 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 return errors
+35 -23
View File
@@ -14,9 +14,14 @@ from dataclasses import replace
from vlm.config import Config from vlm.config import Config
from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index
from vlm.io import load_execution_plan, save_execution_plan 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 ( from vlm.utils import (
canonical_path_str,
is_sample_path, is_sample_path,
is_within_root, is_within_root,
sanitize_path_component, 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)" 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]: def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
"""Index duplicate quality entries by canonicalized path.""" """Index duplicate quality entries by canonicalized path."""
lookup: dict[str, dict] = {} lookup: dict[str, dict] = {}
for quality in quality_comparison: for quality in quality_comparison:
quality_path = quality.get("path") quality_path = quality.get("path")
if isinstance(quality_path, str) and quality_path.strip(): 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 return lookup
@@ -151,12 +148,12 @@ def generate_plan(
if config.duplicate_keep != "manual": if config.duplicate_keep != "manual":
path_to_index = { 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", []): for dup in analysis_data.get("duplicates", []):
paths = dup.get("files", []) paths = dup.get("files", [])
normalized_paths = [ normalized_paths = [
_normalized_path_key(path) normalized_path_key(path)
for path in paths for path in paths
if isinstance(path, str) and path.strip() if isinstance(path, str) and path.strip()
] ]
@@ -183,7 +180,7 @@ def generate_plan(
if not items: if not items:
continue continue
quality_list = [ 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: try:
if config.duplicate_keep == "by_quality" and any(not qc for qc in quality_list): 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: if keep_idx is None:
continue continue
keep_identity_index = valid_indices[keep_idx] 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} quarantine_indices = set(valid_indices) - {keep_identity_index}
reason = _select_duplicate_quarantine_reason( reason = _select_duplicate_quarantine_reason(
config.duplicate_keep, config.duplicate_keep,
@@ -578,10 +575,10 @@ def _stamp_review_context_on_operations(
if i < len(identities): if i < len(identities):
vf, identity = identities[i] vf, identity = identities[i]
if isinstance(identity, (MovieIdentity, SeriesIdentity)): 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 keep_candidate = None
if gid: 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( ctx = build_review_context(
vf.path, vf.path,
identity, identity,
@@ -784,12 +781,19 @@ def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan:
except (ValueError, KeyError): except (ValueError, KeyError):
continue 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: if modified_count > 0:
# Re-generate summary and human summary for the updated plan
summary = _generate_summary(updated_ops) summary = _generate_summary(updated_ops)
summary_by_reason = _generate_summary_by_reason(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( return ExecutionPlan(
plan_id=plan.plan_id, plan_id=plan.plan_id,
created_at=plan.created_at, created_at=plan.created_at,
@@ -797,7 +801,15 @@ def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan:
summary=summary, summary=summary,
summary_by_reason=summary_by_reason, summary_by_reason=summary_by_reason,
human_summary=human_summary, 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,
)
+2
View File
@@ -8,9 +8,11 @@ from pathlib import Path
RISK_FLAG_LABELS: dict[str, str] = { RISK_FLAG_LABELS: dict[str, str] = {
"manual_review": "需人工判断", "manual_review": "需人工判断",
"sample_source": "样片路径", "sample_source": "样片路径",
"spot_check": "安全抽检",
"high_season": "季号偏高", "high_season": "季号偏高",
"high_episode": "集号偏高", "high_episode": "集号偏高",
"conflict": "目标冲突", "conflict": "目标冲突",
"duplicate": "重复项",
} }
+13 -7
View File
@@ -15,7 +15,7 @@ from vlm.review_display import (
) )
from vlm.utils import format_size 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: 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}", f"将要写入: {self._ctx.output_csv}",
"", "",
"默认仅审核标记为高危操作(与 CSV 行一致)", "高危操作写入 CSV;可选 --sample-safe 追加安全抽检行",
"筛选: 1全部 2需人工 3样片 4冲突 5重复 · g 驳回整组重复", "筛选: 1全部 2需人工 3样片路径 4安全抽检 5冲突 6重复 · g 驳回整组重复",
"", "",
"Enter 进入审核 · q 退出", "Enter 进入审核 · q 退出",
] ]
@@ -168,8 +168,9 @@ if TEXTUAL_IMPORT_ERROR is None:
Binding("1", "filter_all", show=False), Binding("1", "filter_all", show=False),
Binding("2", "filter_manual", show=False), Binding("2", "filter_manual", show=False),
Binding("3", "filter_sample", show=False), Binding("3", "filter_sample", show=False),
Binding("4", "filter_conflict", show=False), Binding("4", "filter_spot_check", show=False),
Binding("5", "filter_duplicate", show=False), Binding("5", "filter_conflict", show=False),
Binding("6", "filter_duplicate", show=False),
] ]
def __init__(self, ctx: ReviewTUIContext) -> None: def __init__(self, ctx: ReviewTUIContext) -> None:
@@ -199,7 +200,7 @@ if TEXTUAL_IMPORT_ERROR is None:
with ScrollableContainer(id="detail_scroll"): with ScrollableContainer(id="detail_scroll"):
yield Static("", id="detail_text") yield Static("", id="detail_text")
yield Static( 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", id="footer_line",
) )
yield Footer() yield Footer()
@@ -262,7 +263,8 @@ if TEXTUAL_IMPORT_ERROR is None:
labels = { labels = {
"all": "全部", "all": "全部",
"manual_review": "需人工", "manual_review": "需人工",
"sample_source": "样片", "sample_source": "样片路径",
"spot_check": "安全抽检",
"conflict": "冲突", "conflict": "冲突",
"duplicate": "重复", "duplicate": "重复",
} }
@@ -469,6 +471,10 @@ if TEXTUAL_IMPORT_ERROR is None:
self._filter = "sample_source" self._filter = "sample_source"
self._rebuild_table() self._rebuild_table()
def action_filter_spot_check(self) -> None:
self._filter = "spot_check"
self._rebuild_table()
def action_filter_conflict(self) -> None: def action_filter_conflict(self) -> None:
self._filter = "conflict" self._filter = "conflict"
self._rebuild_table() self._rebuild_table()
+3 -3
View File
@@ -99,7 +99,7 @@ def test_review_plan_generates_csv_summary_and_preview(tmp_path):
assert "Plan overview:" in result.output assert "Plan overview:" in result.output
assert "这是测试计划摘要" in result.output assert "这是测试计划摘要" in result.output
assert "Plan review summary:" 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 "High-risk operations preview:" in result.output
assert "source:" in result.output assert "source:" in result.output
assert "destination:" 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}, 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) monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 0)
output_csv = tmp_path / "review.csv" 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}, 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) monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 1)
output_csv = tmp_path / "review.csv" output_csv = tmp_path / "review.csv"
+96 -3
View File
@@ -1,19 +1,23 @@
"""Tests for plan review helpers.""" """Tests for plan review helpers."""
import json import csv
from pathlib import Path from pathlib import Path
from vlm.models import ExecutionPlan, FileOperation from vlm.models import ExecutionPlan, FileOperation
from vlm.plan_review import ( from vlm.plan_review import (
REVIEW_APPLIED_AT_KEY,
build_duplicate_path_maps,
build_identity_lookup, build_identity_lookup,
check_review_requirements, check_review_requirements,
enrich_review_rows, enrich_review_rows,
normalized_path_key,
prepare_review_rows, prepare_review_rows,
review_plan, review_plan,
save_review_csv,
) )
from vlm.plan_render import render_review_verdict from vlm.plan_render import render_review_verdict
from vlm.plan_structure_preview import build_structure_preview_lines 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 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): def test_check_review_requirements_missing_csv(tmp_path):
plan_path = tmp_path / "plan.json" plan_path = tmp_path / "plan.json"
op = FileOperation( op = FileOperation(
operation_type="no-op", operation_type="quarantine",
source_path=tmp_path / "Series.S20E01.mkv", source_path=tmp_path / "Series.S20E01.mkv",
destination_path=None, destination_path=None,
reason="Series needs manual review (season exceeds configured threshold)", 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] 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): def test_structure_preview_lines(tmp_path):
lib = tmp_path / "library" lib = tmp_path / "library"
op = FileOperation( op = FileOperation(
Generated
-2
View File
@@ -349,7 +349,6 @@ dependencies = [
dev = [ dev = [
{ name = "hypothesis" }, { name = "hypothesis" },
{ name = "pytest" }, { name = "pytest" },
{ name = "textual" },
] ]
tui = [ tui = [
{ name = "textual" }, { name = "textual" },
@@ -361,7 +360,6 @@ requires-dist = [
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "textual", marker = "extra == 'dev'", specifier = ">=0.47.0" },
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" },
] ]
provides-extras = ["dev", "tui"] provides-extras = ["dev", "tui"]