refactor default artifacts workspace and path compatibility
This commit is contained in:
@@ -47,3 +47,7 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Generated workflow artifacts
|
||||
artifacts/
|
||||
runs/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user