refactor default artifacts workspace and path compatibility

This commit is contained in:
windyboy
2026-02-16 13:23:01 +08:00
parent 2dd329cba9
commit caa6881fd2
8 changed files with 314 additions and 79 deletions
+4
View File
@@ -47,3 +47,7 @@ Thumbs.db
# Logs
*.log
# Generated workflow artifacts
artifacts/
runs/
+13
View File
@@ -2,6 +2,19 @@
## 2026-02-16
### Artifact Path Governance
- Changed default workflow artifact outputs from repository root to `artifacts/`:
- `artifacts/inventory.csv`
- `artifacts/identities.json`
- `artifacts/analysis.json`
- `artifacts/plan.json`
- `artifacts/plan_manual_review.csv`
- Added `workspace_dir` to configuration (`~/.vlm/config.yaml`) to customize default artifact directory.
- Stage-A compatibility: when a command uses default input path and only legacy root artifact exists, CLI now prints a migration warning and falls back to the legacy file.
- Added ignore rules for generated artifact directories in `.gitignore` (`artifacts/`, `runs/`).
- Removed temporary process document `ARTIFACTS_REFACTOR_PLAN_2026-02-16.md`; `CHANGELOG.md` remains the canonical change record.
### Refactor Results
- Modularized CLI command implementations:
+40 -36
View File
@@ -48,6 +48,7 @@ This creates `~/.vlm/config.yaml`. Edit it to set your library root:
```yaml
library_root: "/mnt/Downloads" # Change this to your video library path
workspace_dir: "artifacts" # Default workspace for generated files
```
### 2. Scan Your Library
@@ -58,14 +59,14 @@ Discover all video files in your library:
vlm scan
```
This creates `inventory.csv` with all discovered files and their metadata.
This creates `artifacts/inventory.csv` with all discovered files and their metadata.
**What happens:**
- Discovers video files using the system `find` command
- Extracts file metadata (size, modification time)
- Categorizes files based on directory structure (movie/series/anime/other)
- Extracts video metadata using ffprobe (if available)
- Saves results to `inventory.csv`
- Saves results to `artifacts/inventory.csv`
### 3. Parse Filenames
@@ -78,10 +79,10 @@ vlm parse
**For accurate duplicate resolution by quality, embed video metadata:**
```bash
vlm parse --inventory inventory.csv
vlm parse --inventory artifacts/inventory.csv
```
This creates `identities.json` with parsed information. When using `--inventory`, video metadata (size, resolution, codec) is embedded, enabling accurate quality comparison during duplicate analysis.
This creates `artifacts/identities.json` with parsed information. When using `--inventory`, video metadata (size, resolution, codec) is embedded, enabling accurate quality comparison during duplicate analysis.
**What it extracts:**
- **Movies**: Title and year (e.g., "Inception (2010)")
@@ -91,13 +92,13 @@ This creates `identities.json` with parsed information. When using `--inventory`
### 4. Enrich Titles and Reputation (Optional but Recommended)
Add translation and reputation metadata to `identities.json`:
Add translation and reputation metadata to `artifacts/identities.json`:
```bash
vlm enrich
```
This updates `identities.json` in place and adds fields like:
This updates `artifacts/identities.json` in place and adds fields like:
- `title_zh`, `title_en`, `display_title`
- `reputation_score`, `reputation_votes`, `reputation_source`
- `review_status`, `enrichment_confidence`
@@ -123,7 +124,7 @@ Detect episode gaps and duplicates:
vlm analyze
```
This creates `analysis.json` with:
This creates `artifacts/analysis.json` with:
- Series with missing episodes
- Duplicate files with quality comparison
@@ -138,10 +139,10 @@ vlm plan
To let the plan automatically resolve duplicate groups (keep one file per group by reputation, quarantine the rest), pass the analysis file:
```bash
vlm plan --analysis analysis.json
vlm plan --analysis artifacts/analysis.json
```
This creates `plan.json` with:
This creates `artifacts/plan.json` with:
- Proposed operations (move, rename, quarantine, no-op)
- **Summary**: counts by operation type and by reason
- **Human summary** (中文): short narrative for quick review
@@ -158,7 +159,7 @@ Duplicate keep strategy is configurable in `~/.vlm/config.yaml` under `plan.dupl
- `first_seen` - Keep the first file in each duplicate group.
- `manual` - Do not generate quarantine operations; duplicates are listed in analysis only.
**Review the plan** by opening `plan.json` in your editor, or read the human summary when you run `vlm execute`. You can edit the plan JSON if needed.
**Review the plan** by opening `artifacts/plan.json` in your editor, or read the human summary when you run `vlm execute`. You can edit the plan JSON if needed.
### 7. Execute (Dry-Run First)
@@ -177,7 +178,7 @@ vlm execute --confirm
**Important**: This creates a rollback log in `~/.vlm/rollback/` for reverting changes.
Execution safeguards:
- Even if a manually edited `plan.json` contains an unsafe destination, execution rejects paths outside `library_root`
- Even if a manually edited `artifacts/plan.json` contains an unsafe destination, execution rejects paths outside `library_root`
- Summary counters treat conflict skips separately from real failures (`failed`/`skipped` are mutually exclusive)
### 8. Rollback (If Needed)
@@ -201,26 +202,26 @@ vlm config init
# 2. Scan your library
vlm scan
# Output: inventory.csv with 1234 files discovered
# Output: artifacts/inventory.csv with 1234 files discovered
# 3. Parse filenames with metadata embedding (recommended for duplicate resolution)
vlm parse --inventory inventory.csv
# Output: identities.json with parsed titles, episodes, and embedded video metadata (v2 schema)
vlm parse --inventory artifacts/inventory.csv
# Output: artifacts/identities.json with parsed titles, episodes, and embedded video metadata (v2 schema)
# 4. Enrich identities (translation + reputation)
vlm enrich
# Output: identities.json updated in place (incremental cache enabled)
# Output: artifacts/identities.json updated in place (incremental cache enabled)
# 5. Analyze for gaps and duplicates
vlm analyze
# Output: analysis.json with 5 series with gaps, 12 duplicate groups (accurate quality comparison)
# Output: artifacts/analysis.json with 5 series with gaps, 12 duplicate groups (accurate quality comparison)
# 6. Generate execution plan (optionally use analysis for duplicate handling)
vlm plan --analysis analysis.json
# Output: plan.json with operations, human summary, and duplicate quarantine decisions
vlm plan --analysis artifacts/analysis.json
# Output: artifacts/plan.json with operations, human summary, and duplicate quarantine decisions
# 7. Review the plan
cat plan.json | less
cat artifacts/plan.json | less
# or open in your editor
# 8. Dry-run to preview
@@ -253,7 +254,7 @@ vlm config validate
### Scanning
```bash
# Scan with default output (inventory.csv)
# Scan with default output (artifacts/inventory.csv)
vlm scan
# Scan with custom output file
@@ -274,7 +275,7 @@ vlm scan --output my_library.csv
vlm parse
# Parse with metadata embedding (v2 schema - enables quality comparison)
vlm parse --inventory inventory.csv
vlm parse --inventory artifacts/inventory.csv
# Parse with custom input/output
vlm parse --input my_inventory.csv --output my_identities.json
@@ -290,7 +291,7 @@ vlm parse --input my_inventory.csv --output my_identities.json --inventory my_in
### Enrichment
```bash
# Enrich identities in place (default: identities.json)
# Enrich identities in place (default: artifacts/identities.json)
vlm enrich
# Enrich custom file and write to another file
@@ -323,7 +324,7 @@ vlm analyze --input my_identities.json --output my_analysis.json
vlm plan
# Use analysis so duplicate groups become "keep one + quarantine rest" (by_reputation by default)
vlm plan --analysis analysis.json
vlm plan --analysis artifacts/analysis.json
# Custom input/output
vlm plan --input my_identities.json --output my_plan.json
@@ -382,12 +383,12 @@ vlm report inventory --format json --output inventory_report.json
# Generate completeness report (series with gaps)
vlm report completeness
# Include plan content summary in the report (human_summary from plan.json)
vlm report completeness --plan plan.json
# Include plan content summary in the report (human_summary from artifacts/plan.json)
vlm report completeness --plan artifacts/plan.json
# Generate duplicates report
vlm report duplicates
vlm report duplicates --plan plan.json
vlm report duplicates --plan artifacts/plan.json
# Generate summary statistics
vlm report summary
@@ -418,6 +419,9 @@ The configuration file (`~/.vlm/config.yaml`) controls VLM's behavior:
# Required: Root directory of your video library
library_root: "/mnt/nas/videos"
# Workspace directory for generated artifacts
workspace_dir: "artifacts"
# Video file extensions to recognize
video_extensions:
- .mp4
@@ -512,10 +516,10 @@ Validation flow:
```bash
# 1) run small incremental pass
vlm enrich --input identities.json --refresh-changed-only
vlm enrich --input artifacts/identities.json --refresh-changed-only
# 2) then full refresh if output looks correct
vlm enrich --input identities.json --refresh-all
vlm enrich --input artifacts/identities.json --refresh-all
```
### Template Variables
@@ -592,7 +596,7 @@ vlm report summary
```bash
# 1. Scan and parse
vlm scan
vlm parse --inventory inventory.csv
vlm parse --inventory artifacts/inventory.csv
vlm enrich
# 2. Analyze completeness
@@ -600,7 +604,7 @@ vlm analyze
# 3. View report (optionally include plan summary if you have a plan)
vlm report completeness
vlm report completeness --plan plan.json
vlm report completeness --plan artifacts/plan.json
```
### Scenario 3: Finding and Removing Duplicates
@@ -608,7 +612,7 @@ vlm report completeness --plan plan.json
```bash
# 1. Scan and parse (with --inventory for accurate quality comparison)
vlm scan
vlm parse --inventory inventory.csv
vlm parse --inventory artifacts/inventory.csv
vlm enrich
# 2. Analyze for duplicates
@@ -618,9 +622,9 @@ vlm analyze
vlm report duplicates
# 4. Generate plan with analysis: VLM keeps one file per duplicate group (by reputation) and quarantines the rest
vlm plan --analysis analysis.json
vlm plan --analysis artifacts/analysis.json
# 5. Review plan (human summary in plan.json and when you run execute)
# 5. Review plan (human summary in artifacts/plan.json and when you run execute)
vlm execute
vlm execute --confirm
@@ -628,7 +632,7 @@ vlm execute --confirm
vlm quarantine add /path/to/lower/quality/file.mkv --reason "duplicate - lower quality"
# Report with plan context
vlm report duplicates --plan plan.json
vlm report duplicates --plan artifacts/plan.json
```
### Scenario 4: Reorganizing Your Library
@@ -639,13 +643,13 @@ vlm report duplicates --plan plan.json
# 2. Scan and parse
vlm scan
vlm parse --inventory inventory.csv
vlm parse --inventory artifacts/inventory.csv
vlm enrich
# 3. Generate plan
vlm plan
# 4. Review plan.json carefully
# 4. Review artifacts/plan.json carefully
# 5. Dry-run to preview
vlm execute
+102 -39
View File
@@ -12,6 +12,7 @@ from typing import Optional
import click
import yaml
from click.core import ParameterSource
from vlm.config import Config, load_config, create_default_config, validate_config
from vlm.context import CLIContext, pass_context
@@ -24,6 +25,57 @@ def default_config_path() -> Path:
return Path.home() / ".vlm" / "config.yaml"
def _workspace_dir_from_context() -> Path:
"""Resolve workspace directory from CLI context at runtime."""
click_ctx = click.get_current_context(silent=True)
if click_ctx and isinstance(click_ctx.obj, CLIContext):
return click_ctx.obj.config.workspace_dir
return Path("artifacts")
def default_artifact_path(filename: str) -> Path:
"""Build default artifact path for a filename at runtime."""
return _workspace_dir_from_context() / filename
def _is_default_parameter(parameter_name: str) -> bool:
"""Check whether a parameter value came from Click default."""
click_ctx = click.get_current_context(silent=True)
if click_ctx is None:
return False
return click_ctx.get_parameter_source(parameter_name) == ParameterSource.DEFAULT
def resolve_legacy_default_input_path(
current_path: Path,
parameter_name: str,
legacy_filename: str,
option_name: str,
) -> Path:
"""Fallback to legacy root path when default workspace file is missing.
This keeps stage-A compatibility for users who still have legacy artifacts
in repository root while printing a migration warning.
"""
if not _is_default_parameter(parameter_name):
return current_path
workspace_default = default_artifact_path(legacy_filename)
legacy_default = Path(legacy_filename)
if current_path != workspace_default:
return current_path
if current_path.exists() or not legacy_default.exists():
return current_path
click.echo(
"Warning: detected legacy default input at "
f"{legacy_default.resolve()}. Please migrate to "
f"{workspace_default.resolve()} (example: {option_name} {workspace_default.resolve()}).",
err=True,
)
return legacy_default
@click.group()
@click.option(
'--config',
@@ -107,8 +159,8 @@ def main(ctx, config: Path, log_level: Optional[str]):
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('inventory.csv'),
help='Output file for inventory (default: inventory.csv)'
default=lambda: default_artifact_path('inventory.csv'),
help='Output file for inventory (default: artifacts/inventory.csv)'
)
@click.option(
'--metadata/--no-metadata',
@@ -160,15 +212,15 @@ def scan(
@main.command()
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('identities.json'),
help='Output file for parsed identities (default: identities.json)'
default=lambda: default_artifact_path('identities.json'),
help='Output file for parsed identities (default: artifacts/identities.json)'
)
@click.option(
'--inventory',
@@ -194,6 +246,7 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
vlm parse --output parsed_identities.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
from vlm.commands.parse import parse_cmd
parse_cmd(ctx, input, output, inventory)
except FileNotFoundError:
@@ -213,9 +266,9 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
@main.command()
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('identities.json'),
help='Path to identities JSON file (default: identities.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to identities JSON file (default: artifacts/identities.json)'
)
@click.option(
'--output',
@@ -265,6 +318,7 @@ def enrich(
results back into identities JSON.
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.enrich import enrich_cmd
enrich_cmd(
ctx,
@@ -296,15 +350,15 @@ def enrich(
@main.command()
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('identities.json'),
help='Path to parsed identities JSON file (default: identities.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to parsed identities JSON file (default: artifacts/identities.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('analysis.json'),
help='Path to save analysis results (default: analysis.json)'
default=lambda: default_artifact_path('analysis.json'),
help='Path to save analysis results (default: artifacts/analysis.json)'
)
@click.option(
'--inventory',
@@ -327,6 +381,7 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path
vlm analyze --output my_analysis.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.analyze import analyze_cmd
analyze_cmd(ctx, input, output, inventory)
except FileNotFoundError:
@@ -346,15 +401,15 @@ def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path
@main.command()
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('identities.json'),
help='Path to parsed identities JSON file (default: identities.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('identities.json'),
help='Path to parsed identities JSON file (default: artifacts/identities.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('plan.json'),
help='Path to save execution plan (default: plan.json)'
default=lambda: default_artifact_path('plan.json'),
help='Path to save execution plan (default: artifacts/plan.json)'
)
@click.option(
'--analysis',
@@ -377,6 +432,7 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
vlm plan --output my_plan.json # Custom output
"""
try:
input = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output, analysis)
except FileNotFoundError:
@@ -397,14 +453,14 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('plan.json'),
help='Path to execution plan JSON file (default: plan.json)'
default=lambda: default_artifact_path('plan.json'),
help='Path to execution plan JSON file (default: artifacts/plan.json)'
)
@click.option(
'--output',
type=click.Path(path_type=Path),
default=Path('plan_manual_review.csv'),
help='Path to save manual review CSV (default: plan_manual_review.csv)'
default=lambda: default_artifact_path('plan_manual_review.csv'),
help='Path to save manual review CSV (default: artifacts/plan_manual_review.csv)'
)
@click.option(
'--season-threshold',
@@ -435,6 +491,7 @@ def review_plan_cmd(
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)
@@ -491,9 +548,9 @@ def review_plan_cmd(
@main.command()
@click.option(
'--plan',
type=click.Path(exists=True, path_type=Path),
default=Path('plan.json'),
help='Path to execution plan JSON file (default: plan.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('plan.json'),
help='Path to execution plan JSON file (default: artifacts/plan.json)'
)
@click.option(
'--confirm',
@@ -541,6 +598,7 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops:
vlm execute --confirm --yes # Execute without confirmation prompt
"""
try:
plan = resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan")
from vlm.commands.execute import execute_cmd
execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
except FileNotFoundError:
@@ -827,9 +885,9 @@ def report(ctx: CLIContext):
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
)
@click.option(
'--output',
@@ -856,6 +914,7 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
logger = ctx.logger
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
@@ -905,9 +964,9 @@ def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('analysis.json'),
help='Input analysis JSON file (default: analysis.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('analysis.json'),
help='Input analysis JSON file (default: artifacts/analysis.json)'
)
@click.option(
'--output',
@@ -942,6 +1001,7 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
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():
@@ -1017,9 +1077,9 @@ def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optio
)
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('analysis.json'),
help='Input analysis JSON file (default: analysis.json)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('analysis.json'),
help='Input analysis JSON file (default: artifacts/analysis.json)'
)
@click.option(
'--output',
@@ -1056,6 +1116,7 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
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():
@@ -1165,9 +1226,9 @@ def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optiona
@report.command('summary')
@click.option(
'--input',
type=click.Path(exists=True, path_type=Path),
default=Path('inventory.csv'),
help='Input inventory CSV file (default: inventory.csv)'
type=click.Path(path_type=Path),
default=lambda: default_artifact_path('inventory.csv'),
help='Input inventory CSV file (default: artifacts/inventory.csv)'
)
@click.option(
'--output',
@@ -1194,6 +1255,7 @@ def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
logger = ctx.logger
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
# Load inventory from CSV
click.echo(f"Loading inventory from: {input}")
@@ -1511,6 +1573,7 @@ def config_show(ctx: CLIContext):
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}")
+9
View File
@@ -22,6 +22,7 @@ class Config:
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
log_level: str = "INFO"
quarantine_dir: str = ".quarantine"
workspace_dir: Path = Path("artifacts")
categories: dict[str, list[str]] = field(default_factory=lambda: {
"movie": ["movie", "movies"],
"series": ["series", "tv", "shows"],
@@ -85,6 +86,7 @@ def load_config(path: Path) -> Config:
series_filename_template = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
quarantine_dir = data.get("quarantine_dir", ".quarantine")
workspace_dir = Path(data.get("workspace_dir", "artifacts")).expanduser()
log_level = data.get("log_level", "INFO")
categories = data.get("categories", {
"movie": ["movie", "movies"],
@@ -116,6 +118,7 @@ def load_config(path: Path) -> Config:
series_filename_template=series_filename_template,
log_level=log_level,
quarantine_dir=quarantine_dir,
workspace_dir=workspace_dir,
categories=categories,
enrichment_enabled=enrichment.get("enabled", True),
enrichment_incremental=enrichment.get("incremental", True),
@@ -202,6 +205,7 @@ def create_default_config(path: Path) -> Config:
"series_filename": default_config.series_filename_template,
},
"quarantine_dir": default_config.quarantine_dir,
"workspace_dir": str(default_config.workspace_dir),
"log_level": default_config.log_level,
"categories": default_config.categories,
"enrichment": enrichment_content,
@@ -278,6 +282,11 @@ def validate_config(config: Config) -> list[str]:
elif config.quarantine_dir.startswith("/") or config.quarantine_dir.startswith("\\"):
errors.append("quarantine_dir must be relative to category root, not absolute")
if not isinstance(config.workspace_dir, Path):
errors.append("workspace_dir must be a Path object")
elif not str(config.workspace_dir).strip():
errors.append("workspace_dir cannot be empty")
if not config.categories:
errors.append("categories cannot be empty")
elif not isinstance(config.categories, dict):
+114
View File
@@ -0,0 +1,114 @@
"""Tests for CLI artifact default paths and legacy compatibility warnings."""
import json
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from vlm.cli import main
def _write_config(path: Path, library_root: Path, workspace_dir: str | None = None) -> None:
workspace_line = f"\nworkspace_dir: {workspace_dir}" if workspace_dir else ""
path.write_text(
(
f"library_root: {library_root}\n"
"video_extensions:\n"
" - .mp4\n"
" - .mkv\n"
"categories:\n"
" movie: [movie, movies]\n"
" series: [series, tv, shows]\n"
" anime: [anime]\n"
f"{workspace_line}\n"
),
encoding="utf-8",
)
def _write_inventory_csv(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"# vlm inventory\n"
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
"/library/movie/Matrix (1999).mkv,Matrix (1999).mkv,1000000,2024-01-01T00:00:00,movie,,,\n",
encoding="utf-8",
)
def test_scan_default_output_uses_artifacts_dir(tmp_path):
"""Scan should write to artifacts/inventory.csv by default."""
library_root = tmp_path / "library"
library_root.mkdir(parents=True)
config_file = tmp_path / "config.yaml"
_write_config(config_file, library_root)
runner = CliRunner()
with patch("vlm.commands.scan.scan_library", return_value=[]), patch(
"vlm.commands.scan.save_inventory_csv"
) as mock_save:
result = runner.invoke(main, ["--config", str(config_file), "scan"])
assert result.exit_code == 0
assert mock_save.call_count == 1
assert mock_save.call_args.args[1] == Path("artifacts/inventory.csv")
def test_scan_default_output_uses_workspace_dir_from_config(tmp_path):
"""Scan should honor configured workspace_dir for default output."""
library_root = tmp_path / "library"
library_root.mkdir(parents=True)
config_file = tmp_path / "config.yaml"
_write_config(config_file, library_root, workspace_dir="work/cache")
runner = CliRunner()
with patch("vlm.commands.scan.scan_library", return_value=[]), patch(
"vlm.commands.scan.save_inventory_csv"
) as mock_save:
result = runner.invoke(main, ["--config", str(config_file), "scan"])
assert result.exit_code == 0
assert mock_save.call_count == 1
assert mock_save.call_args.args[1] == Path("work/cache/inventory.csv")
def test_parse_default_input_falls_back_to_legacy_root_file_with_warning(tmp_path):
"""Parse should warn and fallback to legacy root inventory.csv during stage-A migration."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
library_root = Path("library")
library_root.mkdir(parents=True)
config_file = Path("config.yaml")
_write_config(config_file, library_root)
_write_inventory_csv(Path("inventory.csv"))
result = runner.invoke(main, ["--config", str(config_file), "parse"])
assert result.exit_code == 0
assert "Warning: detected legacy default input at" in result.output
assert "example: --input" in result.output
assert Path("artifacts/identities.json").exists()
data = json.loads(Path("artifacts/identities.json").read_text(encoding="utf-8"))
assert data["metadata"]["source_inventory"] == "inventory.csv"
def test_parse_default_input_no_warning_when_artifacts_input_exists(tmp_path):
"""When artifacts input exists, parse should use it without migration warning."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
library_root = Path("library")
library_root.mkdir(parents=True)
config_file = Path("config.yaml")
_write_config(config_file, library_root)
_write_inventory_csv(Path("artifacts/inventory.csv"))
_write_inventory_csv(Path("inventory.csv"))
result = runner.invoke(main, ["--config", str(config_file), "parse"])
assert result.exit_code == 0
assert "Warning: detected legacy default input at" not in result.output
+4 -4
View File
@@ -326,9 +326,9 @@ class TestCLIReports:
'--input', str(tmp_path / "nonexistent.csv")
])
# Verify error (Click validates file existence before our code runs)
# Verify runtime missing-file handling
assert result.exit_code != 0
assert "does not exist" in result.output
assert "Error: Input file not found:" in result.output
def test_report_completeness_missing_file(self, tmp_path):
"""Test completeness report with missing input file."""
@@ -338,6 +338,6 @@ class TestCLIReports:
'--input', str(tmp_path / "nonexistent.json")
])
# Verify error (Click validates file existence before our code runs)
# Verify runtime missing-file handling
assert result.exit_code != 0
assert "does not exist" in result.output
assert "Error: Input file not found:" in result.output
+28
View File
@@ -20,6 +20,7 @@ class TestConfig:
assert config.series_template == "series/{title}/Season {season:02d}/"
assert config.log_level == "INFO"
assert config.quarantine_dir == ".quarantine"
assert config.workspace_dir == Path("artifacts")
assert config.enrichment_providers == ["tmdb"]
assert config.plan_max_season == 15
assert config.plan_max_episode == 100
@@ -80,6 +81,7 @@ class TestLoadConfig:
'series_filename': 'S{season:02d}E{episode:02d}{ext}'
},
'quarantine_dir': '.quarantine',
'workspace_dir': 'artifacts',
'log_level': 'DEBUG',
'plan': {
'duplicate_keep': 'by_quality',
@@ -100,6 +102,7 @@ class TestLoadConfig:
assert config.series_template == 'series/{title}/Season {season:02d}/'
assert config.log_level == 'DEBUG'
assert config.quarantine_dir == '.quarantine'
assert config.workspace_dir == Path('artifacts')
assert config.duplicate_keep == 'by_quality'
assert config.plan_max_season == 12
assert config.plan_max_episode == 80
@@ -192,6 +195,20 @@ class TestLoadConfig:
assert ".mp4" in config.video_extensions
assert config.movie_template == "movie/{title} ({year})/"
assert config.log_level == "INFO"
assert config.workspace_dir == Path("artifacts")
def test_load_config_with_workspace_dir(self, tmp_path):
"""Test loading config with explicit workspace_dir."""
config_file = tmp_path / "config.yaml"
config_data = {
'library_root': '/mnt/nas/videos',
'workspace_dir': 'work/artifacts'
}
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
config = load_config(config_file)
assert config.workspace_dir == Path('work/artifacts')
def test_load_config_with_custom_categories(self, tmp_path):
"""Test loading config with custom categories."""
@@ -282,6 +299,7 @@ class TestCreateDefaultConfig:
assert 'templates' in data
assert 'log_level' in data
assert 'quarantine_dir' in data
assert 'workspace_dir' in data
assert 'enrichment' in data
assert 'enrich' in data
assert 'plan' in data
@@ -308,6 +326,7 @@ class TestCreateDefaultConfig:
# Configs should be equivalent
assert loaded_config.library_root == created_config.library_root
assert loaded_config.video_extensions == created_config.video_extensions
assert loaded_config.workspace_dir == created_config.workspace_dir
assert loaded_config.movie_template == created_config.movie_template
assert loaded_config.log_level == created_config.log_level
@@ -417,6 +436,15 @@ class TestValidateConfig:
assert len(errors) > 0
assert any("quarantine_dir" in err for err in errors)
def test_validate_workspace_dir_type(self):
"""workspace_dir must be a Path object."""
config = Config(
library_root=Path("/mnt/nas/videos"),
workspace_dir="artifacts", # type: ignore[arg-type]
)
errors = validate_config(config)
assert any("workspace_dir must be a Path object" in err for err in errors)
def test_validate_multiple_errors(self):
"""Test validating config with multiple errors."""