diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d94d18..37c4af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ ## 2026-05-21 +### CLI line-count reduction (phase 2) + +- Shrunk `cli.py` to a thin registrar (~70 lines); Click definitions live beside command implementations in `commands/`. +- Deduplicated report commands via `reports.duplicate_groups_from_analysis` / `completeness_from_analysis` and shared `cli_helpers.run_command` / `emit_report`. +- Archived the completed simplification plan under `docs/archive/2026-pre-baseline/`. + ### Functional code simplification - Removed unused `src/vlm/exceptions.py` and consolidated plan summary helpers (`preferred_plan_summary` everywhere). @@ -22,6 +28,11 @@ - Fixed series parser empty title when the only title token is a quality tag (e.g. `UHD S01E01.mp4`). - Verification: `pytest -q` → **517 passed**. +### Documentation sync + +- Resynchronized `README.md`, `CLAUDE.md`, `AGENTS.md`, and `skills/vlm-library-workflow/` docs to the CLI-modularization baseline; refreshed validation baseline (`pytest -q` → **529 passed**). +- Fixed the archive index pointer to the completed simplification plan (now inside `docs/archive/2026-pre-baseline/`). + ## 2026-04-07 ### Review-plan Safety & Validation Hardening diff --git a/CLAUDE.md b/CLAUDE.md index a8d8280..60829ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,9 +102,16 @@ VLM follows a read-first, multi-stage pipeline: 8. **Rollback** → reverses executed operations (best-effort) ### Module Organization -- `cli.py` - Click-based CLI interface, global options, command registration +- `cli.py` - Thin Click registrar with global options; command definitions live in `commands/` +- `cli_helpers.py` - Shared CLI helpers (context init, command runner, report emission) - `context.py` - CLIContext (config, paths) and pass_context for commands -- `commands/` - Command implementations (scan, parse, enrich, analyze, plan, execute/rollback) +- `commands/` - Command modules (scan, parse, enrich, analyze, plan, execute/rollback, review_plan, report, quarantine_cmd, state_cmd, config_cmd) +- `plan_render.py` - Shared rendering of plan summaries in CLI output +- `plan_review.py` - High-risk operation flagging and manual-review export +- `plan_structure_preview.py` - Target library tree preview for review-plan +- `review_display.py` - Human-readable plan review rendering shared by CLI preview and TUI +- `review_tui.py` - Optional Textual review UI (lazy import) +- `transaction.py` - Transaction/rollback logging for executed operations - `scanner.py` - File discovery using system `find` command, metadata extraction via ffprobe - `parser.py` - Filename parsing using regex patterns (movies: title + year, series: SxxExx) - `enrichment.py` - Enrichment pipeline; `cache.py` - SQLite cache; `providers/` - TMDB etc. diff --git a/README.md b/README.md index 177dc32..322aabb 100644 --- a/README.md +++ b/README.md @@ -814,23 +814,6 @@ Always keep backups of important files! - **Local files only**: Designed for local or mounted network storage - **Best-effort rollback**: Rollback may not succeed if files have been modified -## Agent skills - -This project includes workflow guidance under `skills/vlm-library-workflow/` (scan → parse → enrich → analyze → plan → review-plan → apply-review → execute). - -### Activation (Gemini CLI) - -```bash -activate_skill vlm-library-workflow -``` - -### Features - -- **Context-Aware Guidance**: The Agent understands the VLM safety-first workflow and configuration. -- **Risk Warnings**: Automatic alerts if a plan contains high-risk operations (>20% quarantine). -- **Command Recipes**: Instant access to complex command sequences and troubleshooting steps. -- **Developer Verification**: Built-in test execution recipes for verifying core logic changes. - ## Development ### Running Tests @@ -860,7 +843,8 @@ uv run pytest --cov=vlm tests/ ``` src/vlm/ -├── cli.py # Click-based CLI interface, global options +├── cli.py # Thin Click registrar; global options and command registration +├── cli_helpers.py # Shared CLI helpers (context init, command runner, report emission) ├── context.py # CLIContext and pass_context for commands ├── cli_helpers.py # Shared CLI helpers ├── commands/ # Command implementations @@ -894,6 +878,7 @@ src/vlm/ ├── review_tui.py # Optional Textual review UI ├── transaction.py # Execution transaction log ├── executor.py # File operations and rollback +├── transaction.py # Transaction/rollback logging for executed operations ├── quarantine.py # Quarantine management ├── state.py # File state tracking ├── reports.py # Report generation diff --git a/docs/archive/2026-pre-baseline/README.md b/docs/archive/2026-pre-baseline/README.md index 1a83c8f..db8c68d 100644 --- a/docs/archive/2026-pre-baseline/README.md +++ b/docs/archive/2026-pre-baseline/README.md @@ -8,6 +8,6 @@ They are **not** maintained as current project documentation. - `/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 +- `2026-05-21-functional-code-simplification-plan-v1.md` (in this archive) — completed code simplification plan Archived on 2026-05-21 as part of the functional code trim (Phase 2). diff --git a/skills/vlm-library-workflow/SKILL.md b/skills/vlm-library-workflow/SKILL.md index 60afc04..2b2ac94 100644 --- a/skills/vlm-library-workflow/SKILL.md +++ b/skills/vlm-library-workflow/SKILL.md @@ -1,96 +1,69 @@ -# Documentation Status -- Synced with artifacts/ baseline on 2026-06-01. - --- name: vlm-library-workflow -description: Operate and extend the Video Library Manager (`vlm`) with a safety-first, human-in-the-loop workflow across scan, parse, enrich, analyze, plan, review-plan, apply-review, execute, rollback, and developer verification. Use when requests involve organizing a video library, producing or reviewing `artifacts/inventory.csv`, `artifacts/identities.json`, `artifacts/analysis.json`, `artifacts/plan.json`, tuning VLM config templates, resolving duplicates or episode gaps, running dry-run/confirm execution, recovering changes via rollback, explaining VLM CLI usage, or developing/modifying VLM features (parser, providers, planner, executor, commands, tests). +description: Operate and extend the Video Library Manager (vlm) CLI. Use for organizing video libraries (scan/parse/enrich/analyze/plan/execute), reviewing plans, resolving duplicates, or developing VLM features. --- # VLM Library Workflow -## Overview +## Safety Contract -Run the repository's built-in media organization pipeline with consistent safety checks and explicit output verification. Prefer incremental, reviewable steps and never skip dry-run and plan inspection before destructive operations. +1. Before `execute --confirm`, always run `review-plan` and report `High-risk operations` count. +2. If high-risk > 0: **stop**, wait for explicit user decision. +3. `--confirm` requires `--require-review` to enforce review gate; default does not force. +4. Never run `execute --confirm` without user confirmation. -**Do not commit** generated CSV/JSON under `artifacts/` or at the repository root. +## Execution Threshold Semantics -## Workflow Order +- `high_risk_operations`: count of non-no-op operations with risk flags (printed by `review-plan`). +- `review_export_rows`: internal count (not printed); may be larger. Do not use `wc -l` on CSV. +- If export row count is needed, parse CSV data rows or add the count to CLI output. -Run commands in this default sequence unless the user asks for a specific stage: +## High-Risk Closed Loop -1. `vlm config init` then set `library_root` in `~/.vlm/config.yaml` -2. `vlm scan` → `artifacts/inventory.csv` -3. `vlm parse --inventory artifacts/inventory.csv` → `artifacts/identities.json` (v2 schema) -4. `vlm enrich` when bilingual titles and reputation signals are needed -5. `vlm analyze --inventory artifacts/inventory.csv` → `artifacts/analysis.json` -6. `vlm plan --analysis artifacts/analysis.json` → `artifacts/plan.json` -7. `vlm review-plan` → `artifacts/plan_manual_review.csv` (use `--tui` only when Textual is installed) -8. Edit CSV if needed, then `vlm apply-review` to sync decisions into `plan.json` -9. `vlm execute` (dry-run) and inspect summary -10. `vlm execute --confirm` only after explicit user confirmation -11. `vlm rollback` if the user requests revert +When high-risk > 0, use the same plan and CSV path throughout: -If `vlm` is not on PATH, prefix commands with `uv run`. +1. `vlm review-plan --input --output ` +2. Human reviews CSV (modify decision or keep) +3. `vlm apply-review --plan --csv ` — **required even if no changes** (writes `review_applied_at`) +4. `vlm execute --plan ` (dry-run with updated plan) +5. Get explicit user confirmation +6. `vlm execute --plan --confirm --require-review --review-csv ` -## Preflight Checks +Three rejection paths: missing CSV, missing `review_applied_at`, CSV path mismatch. -Run these checks before executing workflow commands: +## Workflow Phases -1. Confirm current working directory is repository root. -2. Probe command availability: `vlm --help` or `uv run vlm --help`. -3. Run the target subcommand `--help` when options are uncertain. -4. Confirm config validity with `vlm config validate` after config edits. -5. Verify required input artifacts exist under `artifacts/` (or paths passed via flags). -6. Treat `execute --confirm` as destructive and require explicit user confirmation. +| Phase | Purpose | Artifact | +|-------|---------|----------| +| config | Set `library_root` | `~/.vlm/config.yaml` | +| scan | Discover files | `inventory.csv` | +| parse | Extract identities | `identities.json` | +| enrich | Add TMDB metadata | `identities.json` (updated) | +| analyze | Detect gaps/duplicates | `analysis.json` | +| plan | Generate operations | `plan.json` | +| review-plan | Export high-risk + preview | `plan_manual_review.csv` | +| apply-review | Sync manual decisions | `plan.json` (updated) | +| execute | Dry-run, then confirm | rollback log | +| rollback | Revert if needed | restored files | -## Execution Rules +## Decision Rules -Follow these rules while executing tasks: +**duplicate_keep strategies** (5): `by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`. -1. Prefer read-only stages first: scan, parse, enrich, analyze, plan. -2. Treat `artifacts/plan.json` as the reviewable contract; summarize counts and conflicts before execution. -3. Run `review-plan` and `apply-review` before `execute --confirm` when manual decisions are required. -4. Run dry-run (`vlm execute`) before `vlm execute --confirm`. -5. If execution is interrupted or results are incorrect, locate rollback logs and run `vlm rollback`. -6. Keep outputs explicit in responses: file path, record counts, and next command. -7. If the user asks for a partial workflow, run only required stages and state skipped dependencies. -8. Before `execute --confirm`, run `vlm review-plan` and report high-risk counts; pause if the user has not reviewed high-risk operations. +**Parser boundary risks**: filenames with resolution-like `1920x1080`/`1440x1080` and `Sample` clips are high-risk; require review-plan output before confirmation. -## Decision Points +**Metadata quality**: prefer `vlm parse --inventory` when duplicate quality ranking matters. -Use these decision policies: - -1. Duplicate handling: choose `plan.duplicate_keep` (`by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`) per user preference. -2. Enrichment: skip `vlm enrich` only when translations/reputation are not needed or API keys are unavailable. -3. Metadata quality: prefer `vlm parse --inventory artifacts/inventory.csv` when duplicate quality ranking matters. -4. Analysis-assisted planning: prefer `vlm plan --analysis artifacts/analysis.json` for automatic duplicate quarantine decisions. -5. Parser boundary risk: treat resolution-like tokens (`1920x1080`) and `Sample` clips as high-risk; require review-plan output before confirmation. +**Analysis-assisted planning**: prefer `vlm plan --analysis` for automatic duplicate quarantine. ## Output Contract -Return concise, operational summaries: - -1. Commands executed. -2. Artifacts generated or updated (under `artifacts/` by default). -3. Key counts (files scanned, identities parsed, duplicate groups, plan operations). -4. Risk counts from `review-plan` (manual_review / sample_source / high_season / high_episode / conflicts). -5. Blocking errors and exact remediation command. -6. Safe next step. - -## Developer Verification - -When modifying VLM core logic: - -1. **Full suite**: `uv run pytest -q` -2. **Core components**: `uv run pytest tests/test_scanner.py tests/test_planner.py tests/test_executor.py` -3. **Property tests**: `uv run pytest tests/test_analysis_properties.py` -4. **Lint** (with dev extras): `uv run ruff check src tests` +1. Commands executed and artifacts generated. +2. Key counts (files, identities, duplicates, operations). +3. Risk counts from review-plan; blocking errors with exact remediation command. ## References -Load these references on demand: - -1. `references/command-recipes.md` — command syntax, artifact expectations, failure triage. -2. `references/workflow.md` — end-to-end operator workflow. -3. `references/cli-reference.md` — command and config quick reference. -4. `references/dev-guide.md` — architecture and developer modification patterns. +- `references/triage.md`: failure triage mapping + preflight checks. +- `references/dev-map.md`: module → test → verification mapping. +- For CLI options: run `vlm --help`. Do not trust memory. diff --git a/skills/vlm-library-workflow/references/cli-reference.md b/skills/vlm-library-workflow/references/cli-reference.md deleted file mode 100644 index 50fe762..0000000 --- a/skills/vlm-library-workflow/references/cli-reference.md +++ /dev/null @@ -1,31 +0,0 @@ -# Documentation Status -- Updated to current CLI options on 2026-06-01. Default artifact paths are under `artifacts/`. - -# VLM CLI Reference - -## Configuration -- `vlm config init`: Create default config. -- `vlm config show`: Display current settings. -- `vlm config validate`: Check config for errors. - -## Core Commands -- `vlm scan [--output PATH]`: Discover video files. -- `vlm parse [--input CSV] [--output JSON] [--inventory CSV]`: Parse filenames. -- `vlm enrich [--input JSON] [--output JSON] [--refresh-all]`: Fetch TMDB metadata. -- `vlm analyze [--input JSON] [--output JSON]`: Find gaps and duplicates. -- `vlm plan [--input JSON] [--analysis JSON] [--output JSON]`: Generate operations. -- `vlm review-plan [--input JSON] [--output CSV] [--tui]`: Export manual review CSV (optional Textual UI). -- `vlm apply-review [--plan JSON] [--csv PATH]`: Sync edited review CSV into plan. -- `vlm execute [--plan JSON] [--confirm] [--yes] [--verbose-ops] [--safe-mode] [--preserve-directories]`: Move/Rename/Quarantine operations with safety guards. -- `vlm rollback [--log PATH]`: Undo operations. - -## Management & Reporting -- `vlm quarantine list|add|restore`: Manage the `.quarantine` directory. -- `vlm report inventory|completeness|duplicates|summary`: Generate human-readable reports. -- `vlm state show|set|query|clear`: Track manual review status for files. - -## Important Config Options (`~/.vlm/config.yaml`) -- `library_root`: Path to the video collection. -- `templates`: Naming patterns for movies and series. -- `plan.duplicate_keep`: Strategy for duplicates (`by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`). -- `enrichment.api_keys`: TMDB and OpenAI keys. diff --git a/skills/vlm-library-workflow/references/command-recipes.md b/skills/vlm-library-workflow/references/command-recipes.md deleted file mode 100644 index 274a26c..0000000 --- a/skills/vlm-library-workflow/references/command-recipes.md +++ /dev/null @@ -1,67 +0,0 @@ -# Documentation Status -- Synced with artifacts/ baseline on 2026-06-01. - -# VLM Command Recipes - -## Baseline - -Run from repository root unless the user specifies otherwise. - -```bash -vlm --help -vlm config show -vlm config validate -``` - -If `vlm` is not on PATH: - -```bash -uv run vlm --help -uv run vlm config show -uv run vlm config validate -``` - -## End-to-End Pipeline - -Default artifact paths match CLI defaults under `artifacts/`: - -```bash -vlm scan -vlm parse --inventory artifacts/inventory.csv -vlm enrich -vlm analyze --inventory artifacts/inventory.csv -vlm plan --analysis artifacts/analysis.json -vlm review-plan --input artifacts/plan.json --output artifacts/plan_manual_review.csv -vlm apply-review -vlm execute -vlm execute --confirm -``` - -## Artifact Expectations - -1. `artifacts/inventory.csv` — discovered files with filesystem and optional ffprobe metadata. -2. `artifacts/identities.json` — parsed identities (v2 when parse used `--inventory`). -3. `artifacts/analysis.json` — completeness gaps and duplicate groups. -4. `artifacts/plan.json` — planned operations (`move`, `rename`, `quarantine`, `no-op`). -5. `artifacts/plan_manual_review.csv` — high-risk rows for human review before `--confirm`. - -Do not commit these files to Git. - -## Focused Workflows - -```bash -vlm parse --inventory artifacts/inventory.csv -vlm analyze --input artifacts/identities.json --output artifacts/analysis.json --inventory artifacts/inventory.csv -vlm plan --input artifacts/identities.json --analysis artifacts/analysis.json --output artifacts/plan.json -vlm rollback -``` - -## Frequent Failure Triage - -1. Missing config: `vlm config init`, set `library_root`. -2. Invalid config: `vlm config validate`. -3. Missing input artifact: run prerequisite stage (scan → parse → analyze → plan). -4. Unexpected duplicate decisions: check `plan.duplicate_keep`, rerun `vlm plan --analysis artifacts/analysis.json`. -5. Unsafe execution: `vlm rollback`, re-run `review-plan` / `apply-review`. -6. `vlm` not found: use `uv run vlm ...`. -7. TMDB rate limits: lower `enrichment.max_concurrency` in config and retry later. diff --git a/skills/vlm-library-workflow/references/dev-guide.md b/skills/vlm-library-workflow/references/dev-guide.md deleted file mode 100644 index c7b8802..0000000 --- a/skills/vlm-library-workflow/references/dev-guide.md +++ /dev/null @@ -1,33 +0,0 @@ -# Documentation Status -- Updated for the modular command architecture on 2026-02-16. - -# VLM Developer Guide - -## Project Structure -- `src/vlm/cli.py`: Entry point and thin command wrappers. -- `src/vlm/parser.py`: Regex-based filename parsing logic. -- `src/vlm/enrichment.py`: Pipeline for external metadata fetching. -- `src/vlm/providers/`: API implementations (e.g., TMDB). -- `src/vlm/planner.py`: Logic for mapping identities to filesystem operations. -- `src/vlm/executor.py`: Safe file manipulation and rollback logging. - -## Adding a New Command -1. Create a new module in `src/vlm/commands/`. -2. Define the command using `@click.command()`. -3. Register it in `src/vlm/cli.py` with a Click-decorated function that delegates to the module implementation. - -## Modifying the Parser -- The parser uses a sequence of regex patterns in `src/vlm/parser.py`. -- Add new patterns to the `PATTERNS` list or improve existing ones. -- Always run `pytest tests/test_parser.py` after changes. - -## Data Models -See `src/vlm/models.py` for core data structures: -- `VideoFile`: Basic file metadata. -- `MovieIdentity` / `SeriesIdentity`: Parsed/enriched identity records. -- `FileOperation`: Definition of a move/rename/quarantine/no-op/preserve-directory operation. - -## Testing -- **Unit Tests**: `pytest` -- **Property-based Tests**: `pytest tests/test_analysis_properties.py` (uses Hypothesis). -- **Integration Tests**: `pytest tests/test_reports_integration.py`. diff --git a/skills/vlm-library-workflow/references/dev-map.md b/skills/vlm-library-workflow/references/dev-map.md new file mode 100644 index 0000000..75b481d --- /dev/null +++ b/skills/vlm-library-workflow/references/dev-map.md @@ -0,0 +1,25 @@ +# Developer Map + +## Module → Test → Verification + +| Module | Tests | When to run | +|--------|-------|-------------| +| `scanner.py` | `tests/test_scanner.py` | File discovery, metadata extraction | +| `parser.py` | `tests/test_parser.py` | Filename parsing changes | +| `enrichment.py`, `providers/` | `tests/test_enrichment.py` | TMDB/API changes | +| `planner.py` | `tests/test_planner.py` | Operation generation, duplicate resolution | +| `executor.py` | `tests/test_executor.py` | File operations, rollback | +| `plan_review.py` | `tests/test_plan_review.py` | Review gate, CSV handling | +| `commands/*.py` | `tests/test_cli_*.py` | CLI flag/behavior changes | +| `analysis.py` | `tests/test_analysis_properties.py` | Property-based tests (Hypothesis) | +| Full integration | `pytest` | Before any commit | + +## Adding a New Command + +1. Create module in `src/vlm/commands/`. +2. Define with `@click.command()`. +3. Import and register in `src/vlm/cli.py` via `main.add_command(...)`. + +## Key Models + +See `src/vlm/models.py`: `VideoFile`, `MovieIdentity`, `SeriesIdentity`, `FileOperation`, `ExecutionPlan`. diff --git a/skills/vlm-library-workflow/references/triage.md b/skills/vlm-library-workflow/references/triage.md new file mode 100644 index 0000000..168ecbb --- /dev/null +++ b/skills/vlm-library-workflow/references/triage.md @@ -0,0 +1,23 @@ +# Triage & Preflight + +## Preflight Checks + +Before executing workflow commands: + +1. Confirm working directory is repository root. +2. Run `vlm --help`; if unavailable, switch to `uv run vlm`. +3. Run ` --help` when options are uncertain. +4. Run `vlm config validate` after config edits. +5. Verify required input files exist before downstream stages. +6. Treat `execute --confirm` as destructive; require explicit user confirmation. + +## Failure Triage + +| Problem | Command | +|---------|---------| +| Missing config | `vlm config init`, then set `library_root` | +| Invalid config values | `vlm config validate` and fix reported keys | +| Missing input artifact | Run prerequisite stage (scan → parse → analyze → plan) | +| Unexpected duplicate decisions | Check `plan.duplicate_keep` in config, rerun `vlm plan --analysis` | +| Unsafe execution results | `vlm rollback`, inspect plan, re-run `execute --confirm` | +| `vlm` not found | Use `uv run vlm ...` fallback | diff --git a/skills/vlm-library-workflow/references/workflow.md b/skills/vlm-library-workflow/references/workflow.md deleted file mode 100644 index 5dfdec0..0000000 --- a/skills/vlm-library-workflow/references/workflow.md +++ /dev/null @@ -1,74 +0,0 @@ -# Documentation Status -- Synced with artifacts/ baseline on 2026-06-01. - -# VLM Workflow Guide - -This guide details the standard end-to-end process for organizing a video library using VLM. - -## 1. Setup and Discovery - -### Initialize Configuration -```bash -vlm config init -``` -Edit `~/.vlm/config.yaml` to set `library_root` and optionally `workspace_dir: artifacts`. - -### Library Scan -```bash -vlm scan -``` -- **Goal**: Create `artifacts/inventory.csv`. -- **Note**: Ensure `ffprobe` is installed for resolution and codec metadata. - -## 2. Identification - -### Filename Parsing -```bash -vlm parse --inventory artifacts/inventory.csv -``` -- **Goal**: Create `artifacts/identities.json`. -- **Why --inventory?**: Embeds video metadata (v2 schema) required for quality-based duplicate resolution. - -### Metadata Enrichment -```bash -vlm enrich -``` -- **Goal**: Update `artifacts/identities.json` with TMDB data. -- **Troubleshooting**: Check `enrichment.api_keys` in config; never commit `~/.vlm/config.yaml`. - -## 3. Analysis and Planning - -### Detect Issues -```bash -vlm analyze -``` -- **Goal**: Create `artifacts/analysis.json`. -- **Outputs**: Episode gaps and duplicate groups. - -### Create Execution Plan -```bash -vlm plan --analysis artifacts/analysis.json -``` -- **Goal**: Create `artifacts/plan.json`. -- **Strategy**: Uses `plan.duplicate_keep` (default: `by_reputation`) for duplicate decisions. - -## 4. Review and Execution - -### Human review -```bash -vlm review-plan -# Optional: vlm review-plan --tui (requires textual extra) -vlm apply-review # after editing artifacts/plan_manual_review.csv -``` - -### Execute Changes -```bash -vlm execute # Dry-run -vlm execute --confirm # Actual operations (explicit confirmation) -``` - -### Reverting Changes -```bash -vlm rollback -``` -Restores files using the latest log in `~/.vlm/rollback/`. diff --git a/src/vlm/cli_helpers.py b/src/vlm/cli_helpers.py index cf6391d..96ededc 100644 --- a/src/vlm/cli_helpers.py +++ b/src/vlm/cli_helpers.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import sys +from collections.abc import Callable from pathlib import Path from typing import Optional @@ -123,3 +125,63 @@ def initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext logger = setup_logging(log_level=cfg.log_level) return CLIContext(config=cfg, logger=logger) + + +def emit_report(content: str, output: Optional[Path]) -> None: + """Write report content to a file or stdout.""" + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(content, encoding="utf-8") + click.echo(f"Report saved to: {output}") + else: + click.echo() + click.echo(content) + + +def optional_plan_summary(plan: Optional[Path]): + """Load optional plan JSON and return a display summary string.""" + if plan is None: + return None + if not plan.exists(): + click.echo(f"Error: Plan file not found: {plan}", err=True) + raise SystemExit(1) + from vlm.plan_render import preferred_plan_summary + from vlm.planner import load_plan + + return preferred_plan_summary(load_plan(plan)) + + +def run_command( + ctx: CLIContext, + fn: Callable[[], None], + *, + stage: str, + json_errors: bool = False, +) -> None: + """Run a command body with consistent CLI error handling.""" + try: + fn() + except FileNotFoundError as e: + command_error(ctx, f"Error: Input file not found: {e}", f"{stage} file not found: {e}") + except json.JSONDecodeError as e: + if json_errors: + command_error( + ctx, + f"Error: Failed to parse JSON file: {e}", + f"{stage} JSON parsing failed: {e}", + exc_info=True, + ) + raise + except ValueError as e: + command_error(ctx, f"Error: {e}", f"{stage} validation failed: {e}") + except OSError as e: + command_error( + ctx, + f"Error during {stage}: {e}", + f"{stage} I/O failed: {e}", + exc_info=True, + ) + except Exception as e: + click.echo(f"Error during {stage}: {e}", err=True) + ctx.logger.error(f"{stage} failed: {e}", exc_info=True) + raise SystemExit(1) from e diff --git a/src/vlm/commands/analyze.py b/src/vlm/commands/analyze.py index fa33499..a616ab9 100644 --- a/src/vlm/commands/analyze.py +++ b/src/vlm/commands/analyze.py @@ -6,7 +6,8 @@ from typing import Optional import click from vlm.analysis import analyze_series_completeness, detect_duplicates -from vlm.context import CLIContext +from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command +from vlm.context import CLIContext, pass_context from vlm.io import ( identities_to_analysis_input, load_identities_json, @@ -120,3 +121,23 @@ def analyze_cmd( f"Analysis completed: {len(completeness_results)} incomplete series, " f"{len(duplicate_groups)} duplicate groups, saved to {output}" ) + + +@click.command() +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json")) +@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json")) +@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None) +@pass_context +def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]): + """Analyze completeness and duplicates.""" + run_command( + ctx, + lambda: analyze_cmd( + ctx, + resolve_legacy_default_input_path(input, "input", "identities.json", "--input"), + output, + inventory, + ), + stage="analyze", + json_errors=True, + ) diff --git a/src/vlm/commands/config_cmd.py b/src/vlm/commands/config_cmd.py index a805bc8..e2b2d85 100644 --- a/src/vlm/commands/config_cmd.py +++ b/src/vlm/commands/config_cmd.py @@ -8,7 +8,7 @@ import click from vlm.cli_helpers import command_error from vlm.config import create_default_config, validate_config -from vlm.context import CLIContext +from vlm.context import CLIContext, pass_context def config_init_cmd(ctx: CLIContext, path: Path) -> None: @@ -60,3 +60,31 @@ def config_validate_cmd(ctx: CLIContext) -> None: for error in errors: click.echo(f" - {error}", err=True) command_error(ctx, "Configuration validation failed.", "Configuration validation failed") + + +@click.group(name="config") +@pass_context +def config_group(ctx: CLIContext): + """Manage VLM configuration.""" + + +@config_group.command("init") +@click.option("--path", type=click.Path(path_type=Path), default=default_config_path) +@pass_context +def config_init(ctx: CLIContext, path: Path): + """Create a default config file.""" + config_init_cmd(ctx, path) + + +@config_group.command("show") +@pass_context +def config_show(ctx: CLIContext): + """Show current configuration.""" + config_show_cmd(ctx) + + +@config_group.command("validate") +@pass_context +def config_validate(ctx: CLIContext): + """Validate configuration.""" + config_validate_cmd(ctx) diff --git a/src/vlm/commands/enrich.py b/src/vlm/commands/enrich.py index 3d90a01..2cd5d06 100644 --- a/src/vlm/commands/enrich.py +++ b/src/vlm/commands/enrich.py @@ -8,7 +8,8 @@ from typing import Optional import click -from vlm.context import CLIContext +from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command +from vlm.context import CLIContext, pass_context from vlm.enrichment import enrich_identities_data from vlm.io import load_json_file, save_json_file @@ -144,3 +145,37 @@ def enrich_cmd( stats["skipped"], refresh_mode, ) + + +@click.command() +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json")) +@click.option("--output", type=click.Path(path_type=Path), default=None) +@click.option("--refresh-changed-only", is_flag=True, default=False) +@click.option("--refresh-all", is_flag=True, default=False) +@click.option("--timeout", type=int, default=6, show_default=True) +@click.option("--retries", type=int, default=2, show_default=True) +@pass_context +def enrich( + ctx: CLIContext, + input: Path, + output: Optional[Path], + refresh_changed_only: bool, + refresh_all: bool, + timeout: int, + retries: int, +): + """Enrich identities with translation and reputation metadata.""" + run_command( + ctx, + lambda: enrich_cmd( + ctx, + resolve_legacy_default_input_path(input, "input", "identities.json", "--input"), + output, + refresh_changed_only, + refresh_all, + timeout, + retries, + ), + stage="enrich", + json_errors=True, + ) diff --git a/src/vlm/commands/execute.py b/src/vlm/commands/execute.py index dc77e17..e62976e 100644 --- a/src/vlm/commands/execute.py +++ b/src/vlm/commands/execute.py @@ -7,7 +7,8 @@ from typing import Optional import click -from vlm.context import CLIContext +from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command +from vlm.context import CLIContext, pass_context from vlm.executor import ExecutionEngine from vlm.plan_render import preferred_plan_summary from vlm.planner import load_plan @@ -258,3 +259,50 @@ def rollback_cmd(ctx: CLIContext, log: Optional[Path]) -> None: rollback_summary["successful"], rollback_summary["failed"], ) + + +@click.command() +@click.option("--plan", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json")) +@click.option("--confirm", is_flag=True, default=False) +@click.option("--yes", is_flag=True, default=False) +@click.option("--verbose-ops", is_flag=True, default=False) +@click.option("--preserve-directories", is_flag=True, default=False) +@click.option("--safe-mode", is_flag=True, default=False) +@click.option("--require-review", is_flag=True, default=False) +@click.option("--review-csv", type=click.Path(path_type=Path), default=None) +@pass_context +def execute( + ctx: CLIContext, + plan: Path, + confirm: bool, + yes: bool, + verbose_ops: bool, + preserve_directories: bool, + safe_mode: bool, + require_review: bool, + review_csv: Optional[Path], +): + """Execute plan (dry-run by default; use --confirm to apply).""" + run_command( + ctx, + lambda: execute_cmd( + ctx, + resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan"), + confirm, + yes, + verbose_ops, + preserve_directories, + safe_mode, + require_review=require_review, + review_csv=review_csv, + ), + stage="execute", + ) + + +@click.command() +@click.option("--log", type=click.Path(exists=True, path_type=Path), default=None) +@pass_context +def rollback(ctx: CLIContext, log: Optional[Path]): + """Rollback previous execution (best-effort).""" + run_command(ctx, lambda: rollback_cmd(ctx, log), stage="rollback") diff --git a/src/vlm/commands/quarantine_cmd.py b/src/vlm/commands/quarantine_cmd.py index a96ba48..e6471ac 100644 --- a/src/vlm/commands/quarantine_cmd.py +++ b/src/vlm/commands/quarantine_cmd.py @@ -8,7 +8,7 @@ from typing import Optional import click from vlm.cli_helpers import command_error -from vlm.context import CLIContext +from vlm.context import CLIContext, pass_context from vlm.quarantine import QuarantineManager from vlm.utils import format_size @@ -147,3 +147,34 @@ def quarantine_restore_cmd(ctx: CLIContext, file: Path) -> None: f"Failed to restore file: {e}", exc_info=True, ) + + +@click.group() +@pass_context +def quarantine(ctx: CLIContext): + """Manage quarantined files (movie and series in v1).""" + + +@quarantine.command("list") +@click.option("--category", type=click.STRING, default=None, help="Category filter (validated against configured categories)") +@pass_context +def quarantine_list(ctx: CLIContext, category: Optional[str]): + """List quarantined files.""" + quarantine_list_cmd(ctx, category) + + +@quarantine.command("add") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@click.option("--reason", type=str, default=None) +@pass_context +def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]): + """Quarantine a file.""" + quarantine_add_cmd(ctx, file, reason) + + +@quarantine.command("restore") +@click.argument("file", type=click.Path(exists=True, path_type=Path)) +@pass_context +def quarantine_restore(ctx: CLIContext, file: Path): + """Restore a file from quarantine.""" + quarantine_restore_cmd(ctx, file) diff --git a/src/vlm/commands/report.py b/src/vlm/commands/report.py index 7b41730..a6c8159 100644 --- a/src/vlm/commands/report.py +++ b/src/vlm/commands/report.py @@ -1,22 +1,25 @@ -"""Report CLI command implementations.""" +"""Report CLI commands.""" 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.cli_helpers import ( + command_error, + default_artifact_path, + emit_report, + optional_plan_summary, + resolve_legacy_default_input_path, + run_command, +) +from vlm.context import CLIContext, pass_context 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 ( + completeness_from_analysis, + duplicate_groups_from_analysis, generate_completeness_report, generate_duplicate_report, generate_inventory_report, @@ -24,290 +27,107 @@ from vlm.reports import ( ) -def report_inventory_cmd( - ctx: CLIContext, - format: str, - input: Path, - output: Optional[Path], +def _run_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]) -> None: + 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\n") + report_format = "csv" if format == "text" else format + content = generate_inventory_report(video_files, report_format, ctx.config.library_root) + emit_report(content, output) + ctx.logger.info("inventory report: %s files, format=%s", len(video_files), format) + + +def _run_completeness( + ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: 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, - ) + plan_summary = optional_plan_summary(plan) + click.echo(f"Loading analysis from: {input}") + analysis_data = load_analysis_json(input) + seasons = completeness_from_analysis(analysis_data) + click.echo(f"Loaded {len(seasons)} series with gaps\n") + content = generate_completeness_report( + seasons, format, ctx.config.library_root, plan_summary=plan_summary + ) + emit_report(content, output) + ctx.logger.info("completeness report: %s series", len(seasons)) -def report_duplicates_cmd( - ctx: CLIContext, - format: str, - input: Path, - output: Optional[Path], - plan: Optional[Path], +def _run_duplicates( + 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, - ) + plan_summary = optional_plan_summary(plan) + click.echo(f"Loading analysis from: {input}") + analysis_data = load_analysis_json(input) + groups = duplicate_groups_from_analysis(analysis_data) + click.echo(f"Loaded {len(groups)} duplicate groups\n") + content = generate_duplicate_report( + groups, format, ctx.config.library_root, plan_summary=plan_summary + ) + emit_report(content, output) + ctx.logger.info("duplicate report: %s groups", len(groups)) -def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None: - """Generate summary report.""" - config = ctx.config - logger = ctx.logger +def _run_summary(ctx: CLIContext, input: Path, output: Optional[Path]) -> None: + 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\n") + content = generate_summary_report(video_files, ctx.config.library_root) + emit_report(content, output) + ctx.logger.info("summary report: %s files", len(video_files)) - 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.group() +@pass_context +def report(ctx: CLIContext): + """Generate inventory, completeness, duplicate, and summary reports.""" - 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) +@report.command("inventory") +@click.option("--format", type=click.Choice(["csv", "json", "text"], case_sensitive=False), default="text") +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv")) +@click.option("--output", type=click.Path(path_type=Path), default=None) +@pass_context +def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]): + """List discovered files with metadata.""" + run_command(ctx, lambda: _run_inventory(ctx, format, input, output), stage="inventory report") - 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)) +@report.command("completeness") +@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text") +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json")) +@click.option("--output", type=click.Path(path_type=Path), default=None) +@click.option("--plan", type=click.Path(path_type=Path), default=None) +@pass_context +def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]): + """Show series with episode gaps.""" + run_command( + ctx, lambda: _run_completeness(ctx, format, input, output, plan), + stage="completeness report", json_errors=True, + ) - 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, - ) +@report.command("duplicates") +@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text") +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json")) +@click.option("--output", type=click.Path(path_type=Path), default=None) +@click.option("--plan", type=click.Path(path_type=Path), default=None) +@pass_context +def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]): + """Show duplicate groups with quality comparison.""" + run_command( + ctx, lambda: _run_duplicates(ctx, format, input, output, plan), + stage="duplicate report", json_errors=True, + ) + + +@report.command("summary") +@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv")) +@click.option("--output", type=click.Path(path_type=Path), default=None) +@pass_context +def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]): + """Show library statistics.""" + run_command(ctx, lambda: _run_summary(ctx, input, output), stage="summary report") diff --git a/src/vlm/commands/scan.py b/src/vlm/commands/scan.py index 4ab1879..28295e8 100644 --- a/src/vlm/commands/scan.py +++ b/src/vlm/commands/scan.py @@ -5,7 +5,8 @@ from typing import Optional import click -from vlm.context import CLIContext +from vlm.cli_helpers import default_artifact_path, run_command +from vlm.context import CLIContext, pass_context from vlm.scanner import load_inventory_csv, save_inventory_csv, scan_library from vlm.utils import format_size @@ -94,3 +95,24 @@ def scan_cmd( save_inventory_csv(video_files, output, config.library_root) click.echo("Inventory saved successfully!") logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}") + + +@click.command() +@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv")) +@click.option("--metadata/--no-metadata", default=True) +@click.option("--reuse-from", type=click.Path(exists=True, path_type=Path), default=None) +@click.option("--force-refresh-metadata", is_flag=True, default=False) +@pass_context +def scan( + ctx: CLIContext, + output: Path, + metadata: bool, + reuse_from: Optional[Path], + force_refresh_metadata: bool, +): + """Scan library and write inventory.csv.""" + run_command( + ctx, + lambda: scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata), + stage="scan", + ) diff --git a/src/vlm/commands/state_cmd.py b/src/vlm/commands/state_cmd.py index fcaf607..4c77819 100644 --- a/src/vlm/commands/state_cmd.py +++ b/src/vlm/commands/state_cmd.py @@ -8,7 +8,7 @@ from typing import Optional import click from vlm.cli_helpers import command_error -from vlm.context import CLIContext +from vlm.context import CLIContext, pass_context from vlm.state import StateManager @@ -137,3 +137,43 @@ def state_clear_cmd(ctx: CLIContext, file: Path) -> None: f"Failed to clear file state: {e}", exc_info=True, ) + + +@click.group() +@pass_context +def state(ctx: CLIContext): + """Track per-file workflow status.""" + + +@state.command("show") +@click.argument("file", type=click.Path(path_type=Path)) +@pass_context +def state_show(ctx: CLIContext, file: Path): + """Show state for a file.""" + state_show_cmd(ctx, file) + + +@state.command("set") +@click.argument("file", type=click.Path(path_type=Path)) +@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True) +@click.option("--reason", type=str, default=None) +@pass_context +def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]): + """Set state for a file.""" + state_set_cmd(ctx, file, status, reason) + + +@state.command("query") +@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True) +@pass_context +def state_query(ctx: CLIContext, status: str): + """List files with a given status.""" + state_query_cmd(ctx, status) + + +@state.command("clear") +@click.argument("file", type=click.Path(path_type=Path)) +@pass_context +def state_clear(ctx: CLIContext, file: Path): + """Clear state for a file.""" + state_clear_cmd(ctx, file) diff --git a/src/vlm/io.py b/src/vlm/io.py index fcaf1b2..504b5cb 100644 --- a/src/vlm/io.py +++ b/src/vlm/io.py @@ -479,7 +479,10 @@ def identities_to_plan_input( for s in series_data: result.append((_video_file_from_record(s), _series_identity_from_record(s))) for a in anime_data: - result.append((_video_file_from_record(a), None)) + if "title" in a and "needs_review" in a: + result.append((_video_file_from_record(a), _series_identity_from_record(a))) + else: + result.append((_video_file_from_record(a), None)) for o in other_data: result.append((_video_file_from_record(o), None)) diff --git a/src/vlm/models.py b/src/vlm/models.py index 662c275..92b4a90 100644 --- a/src/vlm/models.py +++ b/src/vlm/models.py @@ -333,7 +333,7 @@ class ParsedIdentitiesJSON(TypedDict, total=False): metadata: dict[str, object] movies: list[MovieIdentityRecord] series: list[SeriesIdentityRecord] - anime: list[IdentityRecord] + anime: list[SeriesIdentityRecord] other: list[IdentityRecord] diff --git a/src/vlm/parser.py b/src/vlm/parser.py index cbe97ab..b9262b4 100644 --- a/src/vlm/parser.py +++ b/src/vlm/parser.py @@ -282,6 +282,80 @@ def parse_series( ) +def parse_anime( + filename: str, + extensions: Optional[list[str]] = None, +) -> SeriesIdentity: + """Parse an anime filename into a plan-consumable series identity. + + Conservative: explicit ``SxxEyy`` / ``Season N - EP`` are organized; an + absolute episode (``Title - NN``) with no season info sets ``needs_review`` + so the planner treats it as a manual-review no-op (never silently moved). + """ + if extensions is None: + extensions = DEFAULT_VIDEO_EXTENSIONS + name = filename + for ext in extensions: + if name.lower().endswith(ext.lower()): + name = name[: -len(ext)] + break + + # Strip CRC32 hash brackets first so they are not mistaken for a release group + name = re.sub(r'\[[0-9A-Fa-f]{8}\]', '', name) + name = remove_release_groups(name) + name = remove_quality_tags(name) + + season = None + episodes: list[int] = [] + confidence = 0.0 + title_part = name + + m = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', name) + if m: + season = int(m.group(1)) + episodes = [int(m.group(2))] + confidence = 0.9 + title_part = name[: m.start()] + else: + m = re.search(r'[Ss]eason\s*(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name) + if m: + season = int(m.group(1)) + episodes = [int(m.group(2))] + confidence = 0.85 + title_part = name[: m.start()] + else: + m = re.search(r'\bS(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name) + if m: + season = int(m.group(1)) + episodes = [int(m.group(2))] + confidence = 0.85 + title_part = name[: m.start()] + else: + m = re.search(r'\s*[-_]\s*(\d{1,3})(?!\d)\s*$', name) + if m: + episodes = [int(m.group(1))] + confidence = 0.5 + title_part = name[: m.start()] + # season remains None -> needs_review + + if title_part and title_part.strip(): + title = humanize_parsed_title(remove_quality_tags(remove_release_groups(title_part))) + else: + title = humanize_parsed_title(name) + if not title.strip(): + title = humanize_parsed_title(filename) + + needs_review = season is None or not episodes + + return SeriesIdentity( + title=title, + season=season, + episodes=episodes, + confidence=confidence, + needs_review=needs_review, + original_filename=filename, + ) + def group_episodes(episodes: list[SeriesIdentity]) -> dict[tuple[str, int], list[SeriesIdentity]]: """Group parsed episodes by normalized series title and season number. diff --git a/src/vlm/plan_review.py b/src/vlm/plan_review.py index 57e90d2..a10a4f0 100644 --- a/src/vlm/plan_review.py +++ b/src/vlm/plan_review.py @@ -35,6 +35,7 @@ REVIEW_CSV_ENRICHED_FIELDS = [ "rel_dest", "quality_hint", "duplicate_group_id", + "sidecars", ] REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS @@ -246,6 +247,11 @@ def enrich_review_row( enriched["episode"] = str(ctx["episode"]) if ctx.get("duplicate_group_id"): enriched["duplicate_group_id"] = str(ctx["duplicate_group_id"]) + sidecars = ctx.get("sidecars") + if isinstance(sidecars, list) and sidecars: + names = [str(item.get("name", "")) for item in sidecars if isinstance(item, dict)] + if names: + enriched["sidecars"] = ", ".join(names) if identity_lookup and source_path: id_ctx = identity_lookup.get(source_path) or identity_lookup.get( diff --git a/src/vlm/providers/tmdb.py b/src/vlm/providers/tmdb.py index 5a1e6c1..de9404f 100644 --- a/src/vlm/providers/tmdb.py +++ b/src/vlm/providers/tmdb.py @@ -11,7 +11,7 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen -from vlm.providers.base import ProviderResult +from vlm.providers.base import ProviderResult, RequestRateLimiter class TMDBAuthError(RuntimeError): @@ -40,6 +40,7 @@ class TMDBProvider: min_interval_seconds: float = 0.25, backoff_base_seconds: float = 0.5, backoff_max_seconds: float = 4.0, + rate_limiter: Optional[RequestRateLimiter] = None, ) -> None: self.api_key = api_key self.bearer_token = bearer_token @@ -52,6 +53,7 @@ class TMDBProvider: self.min_interval_seconds = min_interval_seconds self.backoff_base_seconds = backoff_base_seconds self.backoff_max_seconds = backoff_max_seconds + self.rate_limiter = rate_limiter self._last_request_at = 0.0 self.last_request_count = 0 @@ -181,6 +183,9 @@ class TMDBProvider: return None def _wait_for_rate_limit(self) -> None: + if self.rate_limiter is not None: + self.rate_limiter.wait() + return if self.min_interval_seconds <= 0: return now = time.monotonic() diff --git a/src/vlm/quarantine.py b/src/vlm/quarantine.py index 95d6fa0..faaf1dd 100644 --- a/src/vlm/quarantine.py +++ b/src/vlm/quarantine.py @@ -113,11 +113,12 @@ class QuarantineManager: # Determine category from file path category = self._determine_category(file_path) - # Reject anime and other categories (v1 constraint) - if category not in ("movie", "series"): + # Reject categories outside the configured quarantine scope + supported_categories = self._supported_categories() + if category not in supported_categories: error_msg = ( f"Quarantine not supported for category '{category}'. " - f"Only 'movie' and 'series' categories are supported in v1." + f"Supported categories: {', '.join(sorted(supported_categories))}." ) log_operation( self.logger, @@ -359,6 +360,30 @@ class QuarantineManager: executed_at=executed_at ) + def _supported_categories(self) -> set: + """Return configured category keys supported by the quarantine lifecycle.""" + categories_config = self.config.categories or { + "movie": ["movie"], + "series": ["series"], + "anime": ["anime"] + } + return set(categories_config.keys()) + + def _category_from_directory(self, dir_name: str) -> Optional[str]: + """Map a category directory name to its configured category key.""" + dir_name = dir_name.lower() + if dir_name in self._supported_categories(): + return dir_name + categories_config = self.config.categories or { + "movie": ["movie"], + "series": ["series"], + "anime": ["anime"] + } + for category, dir_names in categories_config.items(): + if any(dir_name == name.lower() for name in dir_names): + return category + return None + def _determine_category(self, file_path: Path) -> str: """Determine the category of a file based on its path. @@ -678,20 +703,21 @@ class QuarantineManager: entries = [] # Determine which categories to query + supported = self._supported_categories() if category is not None: # Validate category - if category not in ("movie", "series"): + if category not in supported: log_operation( self.logger, logging.WARNING, - f"Invalid category '{category}' for listing. Only 'movie' and 'series' are supported.", + f"Invalid category '{category}' for listing. Supported categories: {', '.join(sorted(supported))}.", operation_type="quarantine" ) return [] categories = [category] else: # List from all supported categories - categories = ["movie", "series"] + categories = sorted(supported) # Load manifests from each category for cat in categories: @@ -720,7 +746,7 @@ class QuarantineManager: Returns: The path where the file was moved in quarantine, or None if not found """ - for category in ("movie", "series"): + for category in sorted(self._supported_categories()): manifest = self._load_manifest(category) for entry in manifest.entries: if entry.original_path == original_path: @@ -1014,14 +1040,11 @@ class QuarantineManager: if len(parts) < 2: return None - # First part should be category, second should be .quarantine - category = parts[0].lower() + # First part should be category directory, second should be .quarantine + dir_name = parts[0].lower() quarantine_dir = parts[1] if quarantine_dir != self.config.quarantine_dir: return None - if category in ("movie", "series"): - return category - - return None + return self._category_from_directory(dir_name) diff --git a/src/vlm/reports.py b/src/vlm/reports.py index bb9b7be..f3ff1f1 100644 --- a/src/vlm/reports.py +++ b/src/vlm/reports.py @@ -565,3 +565,70 @@ def _format_duration(duration_seconds: float) -> str: parts.append(f"{seconds}s") return " ".join(parts) + + +def completeness_from_analysis(analysis_data: dict) -> list[SeasonCompleteness]: + """Build SeasonCompleteness records from analysis JSON.""" + result: list[SeasonCompleteness] = [] + for row in analysis_data.get("completeness", []) or []: + result.append( + SeasonCompleteness( + series_title=row["series_title"], + season=row["season"], + episodes_found=row["episodes_found"], + episodes_missing=row["episodes_missing"], + ) + ) + return result + + +def duplicate_groups_from_analysis(analysis_data: dict) -> list[DuplicateGroup]: + """Build DuplicateGroup records from analysis JSON.""" + groups: list[DuplicateGroup] = [] + for dup in analysis_data.get("duplicates", []) or []: + identity_data = dup["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 dup.get("quality_comparison", []) + } + files: list[VideoFile] = [] + for file_path in dup.get("files", []) or []: + 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"), + ) + ) + groups.append( + DuplicateGroup( + identity=identity, + files=files, + quality_comparison=dup.get("quality_comparison", []), + ) + ) + return groups diff --git a/src/vlm/scanner.py b/src/vlm/scanner.py index 37b0d6e..afd8e7a 100644 --- a/src/vlm/scanner.py +++ b/src/vlm/scanner.py @@ -290,6 +290,56 @@ def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool: return file_extension in [ext.lower() for ext in video_extensions] +DEFAULT_SIDECAR_EXTENSIONS = (".srt", ".ass", ".sub", ".idx", ".sup", ".nfo") + + +def find_sidecar_companions( + video_path: Path, + sidecar_extensions: tuple = DEFAULT_SIDECAR_EXTENSIONS, +) -> list[Path]: + """Find sidecar files in the same directory that belong to a video file. + + Conservative matching: a companion must share the video's exact stem, + optionally followed by dot-separated alphabetic suffix tokens (e.g. + language or track tags such as ``zh`` or ``en.forced``). Numeric or + otherwise non-alphabetic tokens are rejected so unrelated files are + never associated. + + Args: + video_path: Path to the video file + sidecar_extensions: Sidecar extensions to consider (case-insensitive) + + Returns: + Sorted list of companion paths (empty when none are found). + """ + parent = video_path.parent + try: + with os.scandir(parent) as it: + entries = [e for e in it if e.is_file(follow_symlinks=False)] + except OSError: + return [] + + stem = video_path.stem + exts = {ext.lower() for ext in sidecar_extensions} + companions: list[Path] = [] + for entry in entries: + name = entry.name + suffix = Path(name).suffix.lower() + if suffix not in exts: + continue + base = name[: -len(suffix)] + if base == stem: + companions.append(Path(entry.path)) + continue + if not base.startswith(stem + "."): + continue + tokens = base[len(stem) + 1:].split(".") + if tokens and all(token and token.isalpha() for token in tokens): + companions.append(Path(entry.path)) + + return sorted(companions, key=lambda p: p.name) + + def _create_video_file( file_path: Path, library_root: Path, diff --git a/tests/test_cli_quarantine.py b/tests/test_cli_quarantine.py index 9614619..7b1ab26 100644 --- a/tests/test_cli_quarantine.py +++ b/tests/test_cli_quarantine.py @@ -177,24 +177,24 @@ class TestQuarantineAddCommand: assert "Reason: duplicate file" in result.output assert "successfully quarantined" in result.output - def test_add_anime_file_rejected(self, config_file, temp_library): - """Test that anime files are rejected.""" + def test_add_anime_file_supported(self, config_file, temp_library): + """Test that anime files can be quarantined (configured category).""" runner = CliRunner() # Create a test anime file anime_file = temp_library / "anime" / "Anime Show.mkv" anime_file.write_text("anime content") - # Try to quarantine (should fail) + # Quarantine should succeed result = runner.invoke(main, [ '--config', str(config_file), 'quarantine', 'add', str(anime_file) ]) - assert result.exit_code == 1 - assert "not supported" in result.output.lower() - assert anime_file.exists() # File should still exist + assert result.exit_code == 0 + assert "successfully quarantined" in result.output + assert not anime_file.exists() def test_add_nonexistent_file(self, config_file, temp_library): """Test adding a file that doesn't exist.""" diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py new file mode 100644 index 0000000..67e7e7d --- /dev/null +++ b/tests/test_docs_consistency.py @@ -0,0 +1,82 @@ +"""Verify vlm commands referenced in documentation exist in the CLI registry.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vlm.cli import main + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOC_GLOBS = [ + "README.md", + "CLAUDE.md", + "AGENTS.md", + "skills/**/*.md", +] + +COMMAND_PATTERN = re.compile( + r"^(?:uv run )?(?:\$ )?vlm\s+(.+)$", re.MULTILINE +) + + +def _extract_doc_commands() -> list[str]: + commands: list[str] = [] + for pattern in DOC_GLOBS: + for path in REPO_ROOT.glob(pattern): + text = path.read_text(encoding="utf-8") + for match in COMMAND_PATTERN.finditer(text): + raw = match.group(1).strip() + raw = raw.rstrip("\\").strip() + if raw.startswith("#") or not raw: + continue + commands.append(raw) + return commands + + +def _cli_command_names() -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for name, cmd in main.commands.items(): + result[name] = [] + if hasattr(cmd, "commands"): + result[name] = list(cmd.commands.keys()) + return result + + +@pytest.mark.parametrize( + "raw_cmd", + sorted(set(_extract_doc_commands())), + ids=lambda c: c[:60], +) +def test_documented_command_exists(raw_cmd: str): + parts = raw_cmd.split() + if not parts: + pytest.skip("empty command") + + first = parts[0] + if first in ("--help", "-h"): + return + + registry = _cli_command_names() + + if first not in registry: + pytest.fail(f"Documented command 'vlm {first}' not in CLI registry") + + if len(parts) > 1: + sub = parts[1] + if sub.startswith("-"): + return + subcommands = registry.get(first, []) + if subcommands and sub not in subcommands: + pytest.fail( + f"Documented subcommand 'vlm {first} {sub}' " + f"not in CLI registry (available: {subcommands})" + ) + + +def test_fake_command_is_caught(): + registry = _cli_command_names() + assert "nonexistent-command-xyz" not in registry diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py index 9403720..b89ece3 100644 --- a/tests/test_enrichment.py +++ b/tests/test_enrichment.py @@ -45,7 +45,7 @@ def test_enrich_incremental_cache_hit(tmp_path, monkeypatch): provider = DummyProvider() monkeypatch.setattr( "vlm.enrichment._build_providers", - lambda _config, request_timeout, retries: [provider], + lambda _config, request_timeout, retries, **_kwargs: [provider], ) identities = { @@ -113,7 +113,7 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch): provider = LowScoreProvider() monkeypatch.setattr( "vlm.enrichment._build_providers", - lambda _config, request_timeout, retries: [provider], + lambda _config, request_timeout, retries, **_kwargs: [provider], ) identities = { @@ -151,7 +151,7 @@ def test_enrich_refresh_all_bypasses_cache(tmp_path, monkeypatch): provider = DummyProvider() monkeypatch.setattr( "vlm.enrichment._build_providers", - lambda _config, request_timeout, retries: [provider], + lambda _config, request_timeout, retries, **_kwargs: [provider], ) identities = { @@ -227,7 +227,7 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch): provider = FlakyProvider() monkeypatch.setattr( "vlm.enrichment._build_providers", - lambda _config, request_timeout, retries: [provider], + lambda _config, request_timeout, retries, **_kwargs: [provider], ) identities = { @@ -331,7 +331,7 @@ def test_enrich_auth_failure_stops_immediately(tmp_path, monkeypatch): monkeypatch.setattr( "vlm.enrichment._build_providers", - lambda _config, request_timeout, retries: [AuthFailProvider()], + lambda _config, request_timeout, retries, **_kwargs: [AuthFailProvider()], ) identities = { @@ -387,8 +387,15 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa thread_ids: set[int] = set() lock = threading.Lock() - - def _fake_enrich(record, media_type, config_obj, request_timeout, retries): + seen_limiters: dict[str, object] = {} + def _fake_enrich(record, media_type, config_obj, request_timeout, retries, rate_limiters=None): + with lock: + if rate_limiters is not None and "tmdb" in rate_limiters: + limiter = rate_limiters["tmdb"] + if "limiter" not in seen_limiters: + seen_limiters["limiter"] = limiter + else: + assert limiter is seen_limiters["limiter"] time.sleep(0.01) with lock: thread_ids.add(threading.get_ident()) @@ -420,3 +427,81 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa assert stats["enriched"] == 8 assert stats["cache_hits"] == 0 assert len(thread_ids) > 1 + + +class _RateLimitedTMDB: + name = "tmdb" + + def __init__(self, api_key, *, rate_limiter=None, record_wait=None, wait_lock=None, **kwargs): + self.rate_limiter = rate_limiter + self.last_request_count = 0 + self._record_wait = record_wait + self._wait_lock = wait_lock + + def enrich(self, *, title, media_type, year=None): + if self.rate_limiter is not None: + self.rate_limiter.wait() + if self._record_wait is not None and self._wait_lock is not None: + with self._wait_lock: + self._record_wait.append(time.monotonic()) + self.last_request_count = 1 + return ProviderResult( + provider="tmdb", + canonical_id=f"tmdb:{title}", + title_zh="测试", + title_en=title, + translation_source="tmdb", + ) + + +def test_enrich_concurrent_workers_share_provider_rate_limiter(tmp_path, monkeypatch): + """Concurrent TMDB providers must admit requests through one limiter.""" + config = Config( + library_root=tmp_path, + enrichment_cache_db=tmp_path / "cache.db", + enrichment_providers=["tmdb"], + tmdb_api_key="fake", + translation_fallback_machine=False, + enrichment_max_concurrency=4, + ) + + identities = { + "metadata": {}, + "movies": [ + { + "path": f"/library/movie/Test.{i}.mkv", + "filename": f"Test.{i}.mkv", + "category": "movie", + "title": f"Test {i}", + "year": 2020, + "confidence": 0.9, + "needs_review": False, + } + for i in range(3) + ], + "series": [], + "anime": [], + "other": [], + } + + shared_limiters: dict[str, object] = {} + wait_times: list[float] = [] + wait_lock = threading.Lock() + + def _factory(api_key, **kwargs): + limiter = kwargs.pop("rate_limiter") + if "limiter" not in shared_limiters: + shared_limiters["limiter"] = limiter + else: + assert limiter is shared_limiters["limiter"] + return _RateLimitedTMDB(api_key, rate_limiter=limiter, record_wait=wait_times, wait_lock=wait_lock) + + monkeypatch.setattr("vlm.enrichment.TMDBProvider", _factory) + + _, stats = enrich_identities_data(identities, config, refresh_mode="refresh_all") + assert stats["enriched"] == 3 + assert len(wait_times) == 3 + # Shared limiter must serialize request starts at >= 0.25s apart + # (allow small tolerance for thread scheduling). + for earlier, later in zip(wait_times, wait_times[1:]): + assert later - earlier >= 0.2 diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index b794082..618b498 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -76,6 +76,63 @@ def test_build_identity_lookup_and_enrich(tmp_path): assert enriched[0]["source_name"] == "Show.S01E01.mkv" +def test_enrich_review_rows_sidecars_column(tmp_path): + op = FileOperation( + operation_type="move", + source_path=tmp_path / "dl/Show.S01E01.mkv", + destination_path=tmp_path / "lib/series/Show/Season 01/S01E01.mkv", + reason="organize", + has_conflict=False, + review_context={ + "title": "Show", + "category": "series", + "season": 1, + "episode": 1, + "sidecars": [ + {"name": "Show.S01E01.zh.srt", "source_path": str(tmp_path / "dl/Show.S01E01.zh.srt"), "proposed_destination_path": str(tmp_path / "lib/series/Show/Season 01/Show.S01E01.zh.srt")}, + {"name": "Show.S01E01.nfo", "source_path": str(tmp_path / "dl/Show.S01E01.nfo"), "proposed_destination_path": str(tmp_path / "lib/series/Show/Season 01/Show.S01E01.nfo")}, + ], + }, + ) + plan = _minimal_plan([op]) + rows = [ + { + "index": "1", + "operation_type": "move", + "risk_flags": "", + "source_path": str(op.source_path), + "destination_path": str(op.destination_path), + "reason": op.reason, + } + ] + enriched = enrich_review_rows(rows, plan, library_root=tmp_path / "lib") + assert enriched[0]["sidecars"] == "Show.S01E01.zh.srt, Show.S01E01.nfo" + + +def test_enrich_review_rows_sidecars_empty_by_default(tmp_path): + op = FileOperation( + operation_type="move", + source_path=tmp_path / "dl/Show.S01E01.mkv", + destination_path=tmp_path / "lib/series/Show/Season 01/S01E01.mkv", + reason="organize", + has_conflict=False, + review_context={"title": "Show", "category": "series", "season": 1, "episode": 1}, + ) + plan = _minimal_plan([op]) + rows = [ + { + "index": "1", + "operation_type": "move", + "risk_flags": "", + "source_path": str(op.source_path), + "destination_path": str(op.destination_path), + "reason": op.reason, + } + ] + enriched = enrich_review_rows(rows, plan, library_root=tmp_path / "lib") + assert enriched[0].get("sidecars", "") == "" + + def test_check_review_requirements_missing_csv(tmp_path): plan_path = tmp_path / "plan.json" op = FileOperation( @@ -137,6 +194,30 @@ def test_check_review_requirements_passes_after_apply_review(tmp_path): assert errors == [] +def test_check_review_requirements_detects_csv_path_mismatch(tmp_path): + plan_path = tmp_path / "plan.json" + csv_path = tmp_path / "review.csv" + other_csv = tmp_path / "other.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) + save_review_csv(rows, other_csv) + + 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, other_csv) + assert any("does not match" in e for e in errors) + + def test_planner_noop_manual_review_does_not_block_execute_gate(tmp_path): plan_path = tmp_path / "plan.json" op = FileOperation( diff --git a/tests/test_planner.py b/tests/test_planner.py index fb92c97..6a65221 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -63,6 +63,92 @@ def test_generate_plan_for_movie_with_year(config): assert "Some Movie (2020)" in operation.reason + +def test_generate_plan_attaches_sidecar_context(tmp_path): + """Test move operations carry review-visible sidecar associations.""" + config = Config( + library_root=tmp_path, + video_extensions=[".mp4", ".mkv", ".avi"], + movie_template="movie/{title} ({year})/", + movie_filename_template="{title} ({year}){ext}", + ) + + src_dir = tmp_path / "downloads" + src_dir.mkdir() + video = src_dir / "Movie (2020).mkv" + video.touch() + sub = src_dir / "Movie (2020).zh.srt" + sub.touch() + nfo = src_dir / "Movie (2020).nfo" + nfo.touch() + unrelated = src_dir / "Movie (2020).1.srt" + unrelated.touch() + + video_file = VideoFile( + path=video, + filename=video.name, + size_bytes=1000000, + modified_timestamp=datetime.now(timezone.utc), + category="movie", + ) + identity = MovieIdentity( + title="Movie", + year=2020, + confidence=0.9, + needs_review=False, + original_filename=video.name, + ) + + plan = generate_plan([(video_file, identity)], config) + operation = plan.operations[0] + assert operation.operation_type == "move" + sidecars = operation.review_context.get("sidecars") + assert sidecars is not None + assert [s["name"] for s in sidecars] == ["Movie (2020).nfo", "Movie (2020).zh.srt"] + dest_dir = operation.destination_path.parent + by_name = {s["name"]: s for s in sidecars} + assert by_name["Movie (2020).zh.srt"]["proposed_destination_path"] == str(dest_dir / "Movie (2020).zh.srt") + assert by_name["Movie (2020).nfo"]["proposed_destination_path"] == str(dest_dir / "Movie (2020).nfo") + # Numeric-suffix file must not be associated + assert all(s["name"] != "Movie (2020).1.srt" for s in sidecars) + + +def test_generate_plan_no_sidecars_for_noop(tmp_path): + """Test no-op operations carry no sidecar context.""" + config = Config( + library_root=tmp_path, + video_extensions=[".mp4", ".mkv", ".avi"], + movie_template="movie/{title} ({year})/", + movie_filename_template="{title} ({year}){ext}", + ) + + src_dir = tmp_path / "downloads" + src_dir.mkdir() + video = src_dir / "random_movie.mkv" + video.touch() + sub = src_dir / "random_movie.srt" + sub.touch() + + video_file = VideoFile( + path=video, + filename=video.name, + size_bytes=1000000, + modified_timestamp=datetime.now(timezone.utc), + category="movie", + ) + identity = MovieIdentity( + title="Random Movie", + year=None, + confidence=0.3, + needs_review=True, + original_filename=video.name, + ) + + plan = generate_plan([(video_file, identity)], config) + operation = plan.operations[0] + assert operation.operation_type == "no-op" + assert "sidecars" not in (operation.review_context or {}) + def test_generate_plan_for_movie_without_year(config): """Test plan generation for a movie without year (needs review).""" video_file = VideoFile( diff --git a/tests/test_quarantine.py b/tests/test_quarantine.py index 3c3ecad..c42f901 100644 --- a/tests/test_quarantine.py +++ b/tests/test_quarantine.py @@ -82,19 +82,26 @@ class TestQuarantineManager: assert expected_quarantine_path.exists() assert expected_quarantine_path.read_text() == "test content" - def test_quarantine_anime_file_rejected(self, manager, config): - """Test that quarantining anime files returns a failed result.""" + def test_quarantine_anime_file_round_trip(self, manager, config): + """Test that quarantining and restoring anime files works.""" # Create a test anime file anime_file = config.library_root / "anime" / "Test Anime.mkv" anime_file.write_text("test content") result = manager.quarantine_file(anime_file) - assert result.success is False - assert "Quarantine not supported for category 'anime'" in (result.error_message or "") + assert result.success is True + expected_quarantine_path = config.library_root / "anime" / ".quarantine" / "Test Anime.mkv" + assert not anime_file.exists() + assert expected_quarantine_path.exists() + assert expected_quarantine_path.read_text() == "test content" - # Verify file was not moved + # Restore and verify round trip + restore_result = manager.restore_from_quarantine(expected_quarantine_path) + assert restore_result.success is True assert anime_file.exists() + assert anime_file.read_text() == "test content" + assert not expected_quarantine_path.exists() def test_quarantine_other_file_rejected(self, manager, config): """Test that quarantining other files returns a failed result.""" @@ -590,7 +597,7 @@ class TestQuarantineListing: def test_list_quarantined_invalid_category(self, manager, config): """Test listing with invalid category returns empty list.""" - entries = manager.list_quarantined(category="anime") + entries = manager.list_quarantined(category="unconfigured") assert entries == [] entries = manager.list_quarantined(category="other") @@ -906,7 +913,7 @@ class TestQuarantineRestoration: category = manager._determine_category_from_quarantine(outside) assert category is None - # Unsupported category - anime = config.library_root / "anime" / ".quarantine" / "Anime.mkv" - category = manager._determine_category_from_quarantine(anime) + # Unsupported category (directory not in configured categories) + other = config.library_root / "other" / ".quarantine" / "Other.mkv" + category = manager._determine_category_from_quarantine(other) assert category is None diff --git a/vlm-expert.skill b/vlm-expert.skill deleted file mode 100644 index 87f9181..0000000 Binary files a/vlm-expert.skill and /dev/null differ