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:
co-authored by
Claude Sonnet 4.5
Cursor
parent
5f0b531269
commit
79797644e1
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()` 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 完成后再写)。*
|
||||
@@ -13,7 +13,6 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"hypothesis>=6.82.0",
|
||||
"textual>=0.47.0",
|
||||
]
|
||||
tui = [
|
||||
"textual>=0.47.0",
|
||||
|
||||
+73
-940
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+135
-34
@@ -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,31 +346,25 @@ 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(
|
||||
{
|
||||
@@ -335,6 +380,38 @@ def review_plan(
|
||||
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
|
||||
|
||||
try:
|
||||
if review_csv.stat().st_mtime < plan_path.stat().st_mtime:
|
||||
if not plan.metadata.get(REVIEW_APPLIED_AT_KEY):
|
||||
errors.append(
|
||||
f"Review CSV is older than plan ({review_csv}). "
|
||||
f"Re-run review-plan and apply-review before execute --confirm."
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
+34
-22
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": "重复项",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-7
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user