refactor CLI command modules and synchronize docs

This commit is contained in:
windyboy
2026-02-16 12:31:26 +08:00
parent 0be802eac5
commit 2dd329cba9
41 changed files with 1066 additions and 753 deletions
+6 -1
View File
@@ -2,7 +2,7 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
- Core package lives in `src/vlm/`. - Core package lives in `src/vlm/`.
- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Commands use `pass_context` and `CLIContext` from `context.py`; some command logic lives in `commands/` (e.g. `scan`, `analyze`, `plan`). - CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Commands use `pass_context` and `CLIContext` from `context.py`; command logic is modularized in `commands/` (e.g. `scan`, `parse`, `enrich`, `analyze`, `plan`, `execute`).
- Functional modules by concern: scanning (`scanner.py`), parsing (`parser.py`), enrichment (`enrichment.py`, `cache.py`, `providers/`), analysis/planning/execution (`analysis.py`, `planner.py`, `executor.py`), I/O helpers (`io.py`), utilities (`utils.py`), state/reporting/logging (`state.py`, `reports.py`, `logging_config.py`), config (`config.py`), models (`models.py`). - Functional modules by concern: scanning (`scanner.py`), parsing (`parser.py`), enrichment (`enrichment.py`, `cache.py`, `providers/`), analysis/planning/execution (`analysis.py`, `planner.py`, `executor.py`), I/O helpers (`io.py`), utilities (`utils.py`), state/reporting/logging (`state.py`, `reports.py`, `logging_config.py`), config (`config.py`), models (`models.py`).
- Tests live in `tests/` and mirror feature areas (e.g. `tests/test_scanner.py`, `tests/test_cli_state.py`, `tests/test_enrichment.py`). - Tests live in `tests/` and mirror feature areas (e.g. `tests/test_scanner.py`, `tests/test_cli_state.py`, `tests/test_enrichment.py`).
- Project metadata and tool config are in `pyproject.toml`. - Project metadata and tool config are in `pyproject.toml`.
@@ -36,3 +36,8 @@
## Security & Configuration Tips ## Security & Configuration Tips
- Do not commit local paths, personal media metadata, or generated state/log artifacts. - Do not commit local paths, personal media metadata, or generated state/log artifacts.
- Validate config changes against `vlm --help` and at least one end-to-end CLI flow before merging. - Validate config changes against `vlm --help` and at least one end-to-end CLI flow before merging.
## Documentation baseline
- Updated to reflect refactor results as of 2026-02-16.
- Canonical release notes are tracked in `CHANGELOG.md`.
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Video Library Manager - Architecture Review # Video Library Manager - Architecture Review
**Review Date**: 2026-02-13 **Review Date**: 2026-02-13
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Post-Audit Fixes Implementation Plan (2026-02-13) # Post-Audit Fixes Implementation Plan (2026-02-13)
## Objective ## Objective
+38
View File
@@ -0,0 +1,38 @@
# Changelog
## 2026-02-16
### Refactor Results
- Modularized CLI command implementations:
- Added `src/vlm/commands/parse.py`
- Added `src/vlm/commands/enrich.py`
- Added `src/vlm/commands/execute.py`
- Refactored `src/vlm/cli.py` to delegate `parse`, `enrich`, `execute`, and `rollback` to command modules
- Unified I/O layer for JSON handling:
- Added `load_json_file`, `save_json_file`, `save_analysis_json` in `src/vlm/io.py`
- Updated analyze/enrich paths to use unified I/O entry points
- Parser/config consistency improvements:
- Added shared `DEFAULT_VIDEO_EXTENSIONS` in `src/vlm/config.py`
- Updated `src/vlm/parser.py` to use the shared config constant instead of local hardcoded defaults
- Execution reliability hardening:
- Updated `src/vlm/executor.py` transaction logging to fail gracefully when `~/.vlm` is not writable (warn + continue)
- Test stability improvements:
- Standardized test timestamps to timezone-aware UTC (`datetime.now(timezone.utc)`)
- Added missing `timezone` imports where required
### Verification
- Full test suite passed after refactor:
- `477 passed`
### Documentation Sync
- Updated repository documentation set to match the refactor baseline:
- Core docs: `README.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`
- Skill docs: `skills/vlm-library-workflow/SKILL.md` and all references under `skills/vlm-library-workflow/references/`
- Legacy review/plan docs now explicitly marked as historical snapshots and redirected to `CHANGELOG.md` as the current source of truth
+5 -2
View File
@@ -1,5 +1,8 @@
# CLAUDE.md # CLAUDE.md
## Documentation Status
- Synchronized with refactor baseline on 2026-02-16 (see `CHANGELOG.md`).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
@@ -91,11 +94,11 @@ VLM follows a read-first, multi-stage pipeline:
### Module Organization ### Module Organization
- `cli.py` - Click-based CLI interface, global options, command registration - `cli.py` - Click-based CLI interface, global options, command registration
- `context.py` - CLIContext (config, paths) and pass_context for commands - `context.py` - CLIContext (config, paths) and pass_context for commands
- `commands/` - Command implementations (scan, analyze, plan) - `commands/` - Command implementations (scan, parse, enrich, analyze, plan, execute/rollback)
- `scanner.py` - File discovery using system `find` command, metadata extraction via ffprobe - `scanner.py` - File discovery using system `find` command, metadata extraction via ffprobe
- `parser.py` - Filename parsing using regex patterns (movies: title + year, series: SxxExx) - `parser.py` - Filename parsing using regex patterns (movies: title + year, series: SxxExx)
- `enrichment.py` - Enrichment pipeline; `cache.py` - SQLite cache; `providers/` - TMDB etc. - `enrichment.py` - Enrichment pipeline; `cache.py` - SQLite cache; `providers/` - TMDB etc.
- `io.py` - Load/save identities JSON/CSV, plan/analysis input helpers - `io.py` - Unified JSON/CSV I/O helpers (including analysis writer and data adapters)
- `utils.py` - UTC time, format_size, shared helpers - `utils.py` - UTC time, format_size, shared helpers
- `analysis.py` - Completeness checking (episode gaps) and duplicate detection - `analysis.py` - Completeness checking (episode gaps) and duplicate detection
- `duplicate_resolve.py` - Duplicate group resolution (by_quality, by_reputation, first_seen, manual) - `duplicate_resolve.py` - Duplicate group resolution (by_quality, by_reputation, first_seen, manual)
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# VLM 代码改进清单 # VLM 代码改进清单
本文档记录对 Video Library Manager (VLM) 项目的代码审查发现的问题及对应解决方案。排除 AI/OpenAPI 相关问题。 本文档记录对 Video Library Manager (VLM) 项目的代码审查发现的问题及对应解决方案。排除 AI/OpenAPI 相关问题。
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Fix Plan (Verified Issues Only) # Fix Plan (Verified Issues Only)
## Objective ## Objective
+13 -10
View File
@@ -1,5 +1,8 @@
# GEMINI.md # GEMINI.md
## Documentation Status
- Synced with repository refactor baseline on 2026-02-16 (source of truth: `CHANGELOG.md`).
This document provides a comprehensive overview of the Video Library Manager (VLM) project, intended to be used as instructional context for Gemini. This document provides a comprehensive overview of the Video Library Manager (VLM) project, intended to be used as instructional context for Gemini.
## Project Overview ## Project Overview
@@ -48,19 +51,19 @@ The project uses `uv` for dependency management.
The main entry point is the `vlm` command. The main entry point is the `vlm` command.
* Initialize configuration: `vlm config init` * Initialize configuration: `uv run vlm config init`
* Scan the library: `vlm scan` * Scan the library: `uv run vlm scan`
* Parse filenames: `vlm parse` * Parse filenames: `uv run vlm parse`
* Enrich metadata: `vlm enrich` * Enrich metadata: `uv run vlm enrich`
* Analyze the library: `vlm analyze` * Analyze the library: `uv run vlm analyze`
* Generate a plan: `vlm plan` * Generate a plan: `uv run vlm plan`
* Execute the plan (dry-run): `vlm execute` * Execute the plan (dry-run): `uv run vlm execute`
* Execute the plan (with confirmation): `vlm execute --confirm` * Execute the plan (with confirmation): `uv run vlm execute --confirm`
* Rollback the last execution: `vlm rollback` * Rollback the last execution: `uv run vlm rollback`
**Running tests:** **Running tests:**
* Run all tests: `pytest` * Run all tests: `uv run pytest`
## Development Conventions ## Development Conventions
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# VLM 改进实施计划(2026-02-13 # VLM 改进实施计划(2026-02-13
## 目标 ## 目标
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# VLM 项目与 Skill 改进建议(2026-02-13 # VLM 项目与 Skill 改进建议(2026-02-13
## 1. 评估范围与依据 ## 1. 评估范围与依据
+12 -2
View File
@@ -1,5 +1,12 @@
# Video Library Manager # Video Library Manager
## Documentation Status
- Last synchronized: **2026-02-16**
- CLI command refactor landed (`parse`, `enrich`, `execute`, `rollback` logic moved to `src/vlm/commands/`).
- Unified JSON I/O interfaces are available in `src/vlm/io.py`.
- Full test baseline after refactor: **477 passed**.
A Python-based CLI tool for managing personal video collections with a safety-first, human-in-the-loop approach. A Python-based CLI tool for managing personal video collections with a safety-first, human-in-the-loop approach.
## Features ## Features
@@ -806,8 +813,11 @@ src/vlm/
├── context.py # CLIContext and pass_context for commands ├── context.py # CLIContext and pass_context for commands
├── commands/ # Command implementations ├── commands/ # Command implementations
│ ├── scan.py # Scan command │ ├── scan.py # Scan command
│ ├── parse.py # Parse command
│ ├── enrich.py # Enrich command
│ ├── analyze.py # Analyze command │ ├── analyze.py # Analyze command
── plan.py # Plan command ── plan.py # Plan command
│ └── execute.py # Execute/Rollback commands
├── scanner.py # File discovery and metadata extraction ├── scanner.py # File discovery and metadata extraction
├── parser.py # Filename parsing (titles, years, episodes) ├── parser.py # Filename parsing (titles, years, episodes)
├── enrichment.py # Title/reputation enrichment pipeline ├── enrichment.py # Title/reputation enrichment pipeline
@@ -815,7 +825,7 @@ src/vlm/
├── providers/ # External metadata providers (TMDB, etc.) ├── providers/ # External metadata providers (TMDB, etc.)
│ ├── base.py # Provider interface │ ├── base.py # Provider interface
│ └── tmdb.py # TMDB API client │ └── tmdb.py # TMDB API client
├── io.py # JSON/CSV load/save, load_analysis_json, plan/analysis input helpers ├── io.py # Unified JSON/CSV I/O helpers (load/save JSON, analysis writer, plan/analysis adapters)
├── utils.py # UTC time, format_size, etc. ├── utils.py # UTC time, format_size, etc.
├── analysis.py # Completeness and duplicate detection ├── analysis.py # Completeness and duplicate detection
├── duplicate_resolve.py # Duplicate group keep-index (by_quality, by_reputation, by_reputation_quality_time, first_seen, manual) ├── duplicate_resolve.py # Duplicate group keep-index (by_quality, by_reputation, by_reputation_quality_time, first_seen, manual)
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Code Review Report (Verified) # Code Review Report (Verified)
## Scope ## Scope
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# TMDB Enrichment 重构执行计划 # TMDB Enrichment 重构执行计划
## 1. 目标与范围 ## 1. 目标与范围
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Video Library Manager (VLM) 深度审计报告 # Video Library Manager (VLM) 深度审计报告
**报告版本**1.0 **报告版本**1.0
+3
View File
@@ -1,3 +1,6 @@
> [!NOTE]
> Status: Historical snapshot. Current refactor results and validated baseline are tracked in `CHANGELOG.md` (updated 2026-02-16).
# Codex 架构复核报告 # Codex 架构复核报告
评审日期: 2026-02-13 评审日期: 2026-02-13
+3
View File
@@ -1,3 +1,6 @@
# Documentation Status
- Synced with refactor baseline on 2026-02-16.
--- ---
name: vlm-library-workflow 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, execute, rollback, and developer verification. Use when requests involve organizing a video library, producing or reviewing `inventory.csv`/`identities.json`/`analysis.json`/`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`) with a safety-first, human-in-the-loop workflow across scan, parse, enrich, analyze, plan, review-plan, execute, rollback, and developer verification. Use when requests involve organizing a video library, producing or reviewing `inventory.csv`/`identities.json`/`analysis.json`/`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).
@@ -1,3 +1,6 @@
# Documentation Status
- Updated to current CLI options on 2026-02-16.
# VLM CLI Reference # VLM CLI Reference
## Configuration ## Configuration
@@ -11,7 +14,7 @@
- `vlm enrich [--input JSON] [--output JSON] [--refresh-all]`: Fetch TMDB metadata. - `vlm enrich [--input JSON] [--output JSON] [--refresh-all]`: Fetch TMDB metadata.
- `vlm analyze [--input JSON] [--output JSON]`: Find gaps and duplicates. - `vlm analyze [--input JSON] [--output JSON]`: Find gaps and duplicates.
- `vlm plan [--input JSON] [--analysis JSON] [--output JSON]`: Generate operations. - `vlm plan [--input JSON] [--analysis JSON] [--output JSON]`: Generate operations.
- `vlm execute [--plan JSON] [--confirm]`: Move/Rename files. - `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. - `vlm rollback [--log PATH]`: Undo operations.
## Management & Reporting ## Management & Reporting
@@ -22,5 +25,5 @@
## Important Config Options (`~/.vlm/config.yaml`) ## Important Config Options (`~/.vlm/config.yaml`)
- `library_root`: Path to the video collection. - `library_root`: Path to the video collection.
- `templates`: Naming patterns for movies and series. - `templates`: Naming patterns for movies and series.
- `plan.duplicate_keep`: Strategy for duplicates (`by_quality`, `by_reputation`, `first_seen`, `manual`). - `plan.duplicate_keep`: Strategy for duplicates (`by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`).
- `enrichment.api_keys`: TMDB and OpenAI keys. - `enrichment.api_keys`: TMDB and OpenAI keys.
@@ -1,3 +1,6 @@
# Documentation Status
- Synced with refactor baseline on 2026-02-16.
# VLM Command Recipes # VLM Command Recipes
## Baseline ## Baseline
@@ -1,7 +1,10 @@
# Documentation Status
- Updated for the modular command architecture on 2026-02-16.
# VLM Developer Guide # VLM Developer Guide
## Project Structure ## Project Structure
- `src/vlm/cli.py`: Entry point and command definitions. - `src/vlm/cli.py`: Entry point and thin command wrappers.
- `src/vlm/parser.py`: Regex-based filename parsing logic. - `src/vlm/parser.py`: Regex-based filename parsing logic.
- `src/vlm/enrichment.py`: Pipeline for external metadata fetching. - `src/vlm/enrichment.py`: Pipeline for external metadata fetching.
- `src/vlm/providers/`: API implementations (e.g., TMDB). - `src/vlm/providers/`: API implementations (e.g., TMDB).
@@ -11,7 +14,7 @@
## Adding a New Command ## Adding a New Command
1. Create a new module in `src/vlm/commands/`. 1. Create a new module in `src/vlm/commands/`.
2. Define the command using `@click.command()`. 2. Define the command using `@click.command()`.
3. Register it in `src/vlm/cli.py` using `main.add_command()`. 3. Register it in `src/vlm/cli.py` with a Click-decorated function that delegates to the module implementation.
## Modifying the Parser ## Modifying the Parser
- The parser uses a sequence of regex patterns in `src/vlm/parser.py`. - The parser uses a sequence of regex patterns in `src/vlm/parser.py`.
@@ -21,8 +24,8 @@
## Data Models ## Data Models
See `src/vlm/models.py` for core data structures: See `src/vlm/models.py` for core data structures:
- `VideoFile`: Basic file metadata. - `VideoFile`: Basic file metadata.
- `MediaIdentity`: Parsed and enriched information. - `MovieIdentity` / `SeriesIdentity`: Parsed/enriched identity records.
- `PlanOperation`: Definition of a file move/rename/quarantine. - `FileOperation`: Definition of a move/rename/quarantine/no-op/preserve-directory operation.
## Testing ## Testing
- **Unit Tests**: `pytest` - **Unit Tests**: `pytest`
@@ -1,3 +1,6 @@
# Documentation Status
- Synced with refactor baseline on 2026-02-16.
# VLM Workflow Guide # VLM Workflow Guide
This guide details the standard end-to-end process for organizing a video library using VLM. This guide details the standard end-to-end process for organizing a video library using VLM.
+55 -550
View File
@@ -193,180 +193,20 @@ def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path])
vlm parse --input my_inventory.csv # Custom input vlm parse --input my_inventory.csv # Custom input
vlm parse --output parsed_identities.json # Custom output vlm parse --output parsed_identities.json # Custom output
""" """
from datetime import datetime, timezone
from vlm.parser import parse_movie, parse_series
from vlm.io import load_inventory_csv, save_identities_json
config = ctx.config
logger = ctx.logger
try: try:
# Display parse start message from vlm.commands.parse import parse_cmd
click.echo(f"Parsing identities from: {input}") parse_cmd(ctx, input, output, inventory)
# Load video metadata from inventory if provided
path_to_metadata = {}
if inventory:
click.echo(f"Loading video metadata from: {inventory}")
inventory_files = load_inventory_csv(inventory)
path_to_metadata = {str(vf.path): vf for vf in inventory_files}
click.echo(f"Loaded metadata for {len(path_to_metadata)} files")
click.echo()
# Load inventory via unified I/O layer
inventory_files = load_inventory_csv(input)
video_files = [
{
'path': str(vf.path),
'filename': vf.filename,
'category': vf.category,
}
for vf in inventory_files
]
click.echo(f"Loaded {len(video_files)} files from inventory")
click.echo()
# Parse identities based on category
movie_identities = []
series_identities = []
anime_files = []
other_files = []
def get_video_metadata(file_path: str) -> dict:
"""Extract video metadata from inventory if available."""
if not path_to_metadata:
return {}
vf = path_to_metadata.get(file_path)
if not vf:
return {}
return {
'size_bytes': vf.size_bytes,
'modified_timestamp': vf.modified_timestamp.isoformat(),
'resolution': vf.resolution,
'codec': vf.codec,
'duration_seconds': vf.duration_seconds,
'bitrate_kbps': vf.bitrate_kbps,
}
for vf in video_files:
filename = vf['filename']
category = vf['category']
file_path = vf['path']
video_metadata = get_video_metadata(file_path)
if category == 'movie':
identity = parse_movie(filename, extensions=config.video_extensions)
record = {
'path': file_path,
'filename': filename,
'category': category,
'title': identity.title,
'year': identity.year,
'confidence': identity.confidence,
'needs_review': identity.needs_review
}
if video_metadata:
record['video_metadata'] = video_metadata
movie_identities.append(record)
elif category == 'series':
identity = parse_series(filename, extensions=config.video_extensions)
record = {
'path': file_path,
'filename': filename,
'category': category,
'title': identity.title,
'season': identity.season,
'episodes': identity.episodes,
'confidence': identity.confidence,
'needs_review': identity.needs_review
}
if video_metadata:
record['video_metadata'] = video_metadata
series_identities.append(record)
elif category == 'anime':
# Anime files are not parsed in v1
anime_files.append({
'path': vf['path'],
'filename': filename,
'category': category,
'note': 'Anime parsing deferred in v1'
})
else:
# Other files are not parsed
other_files.append({
'path': vf['path'],
'filename': filename,
'category': category,
'note': 'Not categorized for parsing'
})
# Display parsing statistics
click.echo("Parsing complete!")
click.echo()
click.echo("Results by category:")
click.echo(f" Movies: {len(movie_identities)}")
# Count movies needing review
movies_need_review = sum(1 for m in movie_identities if m['needs_review'])
if movies_need_review > 0:
click.echo(f" - Need review: {movies_need_review}")
click.echo(f" Series: {len(series_identities)}")
# Count series needing review
series_need_review = sum(1 for s in series_identities if s['needs_review'])
if series_need_review > 0:
click.echo(f" - Need review: {series_need_review}")
click.echo(f" Anime: {len(anime_files)} (not parsed in v1)")
click.echo(f" Other: {len(other_files)} (not parsed)")
# Save parsed identities to JSON
click.echo()
click.echo(f"Saving parsed identities to: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Build JSON structure
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
# Use v2 schema if video metadata was embedded
schema_version = "2.0" if path_to_metadata else "1.0"
identities_data = {
'vlm_schema_version': schema_version,
'metadata': {
'generated': generation_timestamp,
'source_inventory': str(input),
'total_files': len(video_files)
},
'movies': movie_identities,
'series': series_identities,
'anime': anime_files,
'other': other_files
}
# Write JSON via unified I/O layer
save_identities_json(identities_data, output)
click.echo(f"Parsed identities saved successfully!")
logger.info(f"Parse completed: {len(movie_identities)} movies, {len(series_identities)} series, saved to {output}")
except FileNotFoundError: except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True) click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}") ctx.logger.error(f"Input file not found: {input}")
sys.exit(1) sys.exit(1)
except ValueError as e:
except Exception as e: click.echo(f"Error: {e}", err=True)
click.echo(f"Error during parsing: {e}", err=True) ctx.logger.error(f"Parse failed: {e}")
logger.error(f"Parse failed: {e}", exc_info=True) sys.exit(1)
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Parse file I/O failed: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
@@ -424,150 +264,32 @@ def enrich(
Applies incremental cache-backed enrichment to parsed identities and writes Applies incremental cache-backed enrichment to parsed identities and writes
results back into identities JSON. results back into identities JSON.
""" """
import json
from vlm.enrichment import enrich_identities_data
config = ctx.config
logger = ctx.logger
if output is None:
output = input
try: try:
click.echo(f"Enriching identities from: {input}") from vlm.commands.enrich import enrich_cmd
click.echo(f"Output file: {output}") enrich_cmd(
click.echo() ctx,
input,
with open(input, 'r', encoding='utf-8') as jsonfile: output,
identities_data = json.load(jsonfile) refresh_changed_only,
refresh_all,
if refresh_all and refresh_changed_only: timeout,
click.echo("Error: --refresh-all and --refresh-changed-only are mutually exclusive.", err=True) retries,
sys.exit(1)
if timeout < 1:
click.echo("Error: --timeout must be >= 1", err=True)
sys.exit(1)
if retries < 0:
click.echo("Error: --retries must be >= 0", err=True)
sys.exit(1)
refresh_mode = "incremental"
if refresh_all:
refresh_mode = "refresh_all"
elif refresh_changed_only:
refresh_mode = "refresh_changed_only"
total = (
len(identities_data.get('movies', []))
+ len(identities_data.get('series', []))
+ len(identities_data.get('anime', []))
)
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
progress_position = {"current": 0}
progress_bucket = {"value": -1}
is_tty = bool(getattr(sys.stderr, "isatty", lambda: False)())
def _enrich_progress(processed: int, total_count: int, metrics: dict[str, int]) -> None:
if total_count <= 0:
return
if is_tty and progress_state["bar"] is None:
bar = click.progressbar(
length=total_count,
label="Enriching records",
show_pos=True,
)
progress_state["bar"] = bar.__enter__()
step = processed - progress_position["current"]
if step > 0 and progress_state["bar"] is not None:
progress_state["bar"].update(step)
progress_position["current"] = processed
if not is_tty:
percent = int(processed * 100 / total_count)
bucket = percent // 5
if bucket > progress_bucket["value"] or processed == total_count:
progress_bucket["value"] = bucket
click.echo(
"Progress: "
f"{processed}/{total_count} ({percent}%) "
f"api_calls={metrics.get('api_calls', 0)} "
f"cache_hits={metrics.get('cache_hits', 0)} "
f"failed={metrics.get('failed', 0)}"
)
try:
if total == 0:
click.echo("No movie/series/anime records found to enrich.")
enriched_data, stats = enrich_identities_data(
identities_data,
config,
refresh_mode=refresh_mode,
request_timeout=timeout,
retries=retries,
logger=logger,
progress_callback=_enrich_progress,
)
finally:
if progress_state["bar"] is not None:
progress_state["bar"].__exit__(None, None, None)
click.echo()
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as jsonfile:
json.dump(enriched_data, jsonfile, indent=2, ensure_ascii=False)
total_records = int(stats['total']) if stats['total'] else 0
cache_hits = int(stats['cache_hits'])
hit_rate = (cache_hits / total_records * 100.0) if total_records else 0.0
click.echo("Enrichment complete!")
click.echo(f" Total records: {total_records}")
click.echo(f" Refresh mode: {refresh_mode}")
click.echo(f" Enriched now: {stats['enriched']}")
click.echo(f" Cache hits: {stats['cache_hits']}")
click.echo(f" Cache hit rate: {hit_rate:.1f}%")
click.echo(f" API calls: {stats['api_calls']}")
click.echo(f" Failed requests: {stats['failed']}")
click.echo(f" Skipped: {stats['skipped']}")
click.echo(f" Needs review: {stats['needs_review']}")
skip_reasons = stats.get("skip_reasons", {})
if isinstance(skip_reasons, dict):
non_zero = [f"{name}={count}" for name, count in sorted(skip_reasons.items()) if int(count) > 0]
if non_zero:
click.echo(f" Skip reasons: {' '.join(non_zero)}")
failed_items = stats.get('failed_items', [])
if isinstance(failed_items, list) and failed_items:
click.echo(" Failure sample:")
for item in failed_items[:3]:
click.echo(
f" - [{item.get('provider', 'unknown')}] {item.get('title', '')}: {item.get('reason', '')}"
)
logger.info(
"Enrich completed: total=%s enriched=%s cache_hits=%s failed=%s skipped=%s mode=%s",
stats['total'],
stats['enriched'],
stats['cache_hits'],
stats['failed'],
stats['skipped'],
refresh_mode,
) )
except FileNotFoundError: except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True) click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}") ctx.logger.error(f"Input file not found: {input}")
sys.exit(1) sys.exit(1)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True) click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed during enrich: {e}", exc_info=True) ctx.logger.error(f"JSON parsing failed during enrich: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
except Exception as e: except ValueError as e:
click.echo(f"Error during enrichment: {e}", err=True) click.echo(f"Error: {e}", err=True)
logger.error(f"Enrich failed: {e}", exc_info=True) ctx.logger.error(f"Enrich validation failed: {e}")
sys.exit(1)
except OSError as e:
click.echo(f"Error reading/writing files: {e}", err=True)
ctx.logger.error(f"Enrich file I/O failed: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
@@ -791,8 +513,20 @@ def review_plan_cmd(
default=False, default=False,
help='Print per-operation dry-run logs at INFO level' help='Print per-operation dry-run logs at INFO level'
) )
@click.option(
'--preserve-directories',
is_flag=True,
default=False,
help='Preserve empty source directories instead of allowing them to be destroyed'
)
@click.option(
'--safe-mode',
is_flag=True,
default=False,
help='Enable safe mode: prevent any operations that would destroy directories'
)
@pass_context @pass_context
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool): def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool, preserve_directories: bool, safe_mode: bool):
"""Execute plan (defaults to dry-run, requires --confirm). """Execute plan (defaults to dry-run, requires --confirm).
Executes file operations from a plan. Defaults to dry-run mode which Executes file operations from a plan. Defaults to dry-run mode which
@@ -806,155 +540,20 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops:
vlm execute --confirm # Actually execute operations (with prompt) vlm execute --confirm # Actually execute operations (with prompt)
vlm execute --confirm --yes # Execute without confirmation prompt vlm execute --confirm --yes # Execute without confirmation prompt
""" """
from vlm.planner import load_plan
from vlm.executor import ExecutionEngine
config = ctx.config
logger = ctx.logger
try: try:
# Determine execution mode from vlm.commands.execute import execute_cmd
mode = "execute" if confirm else "dry-run" execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
# Display execution start message
click.echo(f"Loading execution plan from: {plan}")
click.echo()
# Load execution plan
execution_plan = load_plan(plan)
# Display plan summary
click.echo(f"Execution plan loaded: {execution_plan.plan_id}")
click.echo(f"Created at: {execution_plan.created_at}")
click.echo(f"Total operations: {len(execution_plan.operations)}")
if execution_plan.human_summary:
click.echo()
click.echo(execution_plan.human_summary)
elif execution_plan.summary or execution_plan.summary_by_reason:
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
parts = [f"操作统计:共 {s.get('total', len(execution_plan.operations))} 条(move {s.get('move', 0)}rename {s.get('rename', 0)}quarantine {s.get('quarantine', 0)}no-op {s.get('no-op', 0)}"]
if by_r:
parts.append("原因分布:" + "".join(f"{r}: {c}" for r, c in list(by_r.items())[:5]))
click.echo()
click.echo("\n".join(parts))
click.echo()
# Display mode warning
if mode == "dry-run":
click.echo("⚠️ DRY-RUN MODE - No files will be modified")
click.echo(" Use --confirm to actually execute operations")
else:
click.echo("⚠️ EXECUTE MODE - Files will be modified!")
click.echo(" This operation cannot be undone without rollback")
click.echo()
if not yes:
if not click.confirm("Are you sure you want to proceed?"):
click.echo("Execution cancelled.")
return
else:
click.echo("Auto-approved via --yes flag")
click.echo()
click.echo(f"Executing {len(execution_plan.operations)} operations...")
click.echo()
# Load state manager to update file statuses during execution
from vlm.state import StateManager
state_path = Path.home() / ".vlm" / "state.json"
state_manager = StateManager(state_path)
# Create execution engine and execute plan
engine = ExecutionEngine(
logger=logger,
config=config,
verbose_operations=verbose_ops,
state_manager=state_manager
)
results, summary, rollback_log = engine.execute_plan(
execution_plan,
mode=mode,
confirmed=confirm
)
# Display execution progress (show some operations)
if mode == "dry-run":
click.echo("Sample operations (dry-run):")
# Show first 5 operations as examples
for i, result in enumerate(results[:5]):
op = result.operation
if op.operation_type != "no-op":
click.echo(f" [{i+1}] {op.operation_type}: {op.source_path.name}")
if op.destination_path:
click.echo(f" -> {op.destination_path}")
if len(results) > 5:
click.echo(f" ... and {len(results) - 5} more operations")
else:
# In execute mode, show progress for all operations
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op" and not op.has_conflict:
status = "" if result.success else ""
click.echo(f" [{i+1}/{len(results)}] {status} {op.operation_type}: {op.source_path.name}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
# Display execution summary
click.echo()
click.echo("=" * 60)
click.echo(f"Execution Summary ({mode} mode)")
click.echo("=" * 60)
click.echo(f" Total operations: {summary['total']}")
click.echo(f" Successful: {summary['successful']}")
click.echo(f" Failed: {summary['failed']}")
click.echo(f" Skipped: {summary['skipped']}")
click.echo()
# Save rollback log if in execute mode
if mode == "execute" and rollback_log:
# Save to ~/.vlm/rollback/ directory
rollback_dir = Path.home() / ".vlm" / "rollback"
rollback_dir.mkdir(parents=True, exist_ok=True)
rollback_path = rollback_dir / f"rollback_{rollback_log.log_id}.json"
engine.save_rollback_log(rollback_log, rollback_path)
click.echo(f"Rollback log saved to: {rollback_path}")
click.echo()
click.echo("To undo these operations, run:")
click.echo(f" vlm rollback --log {rollback_path}")
click.echo()
# Log completion
if mode == "dry-run":
click.echo("Dry-run complete! No files were modified.")
click.echo("Review the operations above and use --confirm to execute.")
else:
if summary['failed'] > 0:
click.echo(f"⚠️ Execution completed with {summary['failed']} failures.")
click.echo(" Check the log file for details.")
else:
click.echo("✓ Execution completed successfully!")
logger.info(
f"Execution completed in {mode} mode: "
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
)
except FileNotFoundError: except FileNotFoundError:
click.echo(f"Error: Plan file not found: {plan}", err=True) click.echo(f"Error: File not found: {plan}", err=True)
logger.error(f"Plan file not found: {plan}") ctx.logger.error(f"Execution file not found: {plan}")
sys.exit(1) sys.exit(1)
except ValueError as e: except ValueError as e:
click.echo(f"Error: {e}", err=True) click.echo(f"Error: {e}", err=True)
logger.error(f"Execution failed: {e}") ctx.logger.error(f"Execution validation failed: {e}")
sys.exit(1) sys.exit(1)
except OSError as e:
except Exception as e:
click.echo(f"Error during execution: {e}", err=True) click.echo(f"Error during execution: {e}", err=True)
logger.error(f"Execution failed: {e}", exc_info=True) ctx.logger.error(f"Execution I/O failed: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
@@ -1177,114 +776,20 @@ def rollback(ctx: CLIContext, log: Optional[Path]):
vlm rollback --log rollback_<uuid>.json # Use specific log vlm rollback --log rollback_<uuid>.json # Use specific log
vlm rollback --log ~/.vlm/rollback/rollback_*.json vlm rollback --log ~/.vlm/rollback/rollback_*.json
""" """
from vlm.executor import ExecutionEngine
config = ctx.config
logger = ctx.logger
try: try:
# If no log specified, find the most recent rollback log from vlm.commands.execute import rollback_cmd
if log is None: rollback_cmd(ctx, log)
rollback_dir = Path.home() / ".vlm" / "rollback"
if not rollback_dir.exists():
click.echo("Error: No rollback logs found.", err=True)
click.echo(f"Rollback directory does not exist: {rollback_dir}", err=True)
sys.exit(1)
# Find all rollback log files
rollback_logs = sorted(rollback_dir.glob("rollback_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if not rollback_logs:
click.echo("Error: No rollback logs found.", err=True)
click.echo(f"No rollback_*.json files in: {rollback_dir}", err=True)
sys.exit(1)
# Use the most recent log
log = rollback_logs[0]
click.echo(f"Using most recent rollback log: {log}")
click.echo()
# Display rollback start message
click.echo(f"Loading rollback log from: {log}")
click.echo()
# Create execution engine
engine = ExecutionEngine(logger=logger, config=config)
# Load rollback log
rollback_log = engine.load_rollback_log(log)
# Display rollback log info
click.echo(f"Rollback log loaded: {rollback_log.log_id}")
click.echo(f"Original execution: {rollback_log.execution_plan_id}")
click.echo(f"Executed at: {rollback_log.executed_at.strftime('%Y-%m-%d %H:%M:%S')}")
click.echo(f"Operations to rollback: {len(rollback_log.operations)}")
click.echo()
# Display warning
click.echo("⚠️ ROLLBACK OPERATION - Best-effort restoration")
click.echo(" This will attempt to move files back to their original locations.")
click.echo(" Some operations may fail if files have been modified or moved.")
click.echo()
if not click.confirm("Are you sure you want to proceed with rollback?"):
click.echo("Rollback cancelled.")
return
click.echo()
click.echo(f"Rolling back {len(rollback_log.operations)} operations...")
click.echo()
# Perform rollback
results, summary = engine.rollback(rollback_log)
# Display rollback progress
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op":
status = "" if result.success else ""
click.echo(f" [{i+1}/{len(results)}] {status} Rollback: {op.destination_path.name if op.destination_path else op.source_path.name}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
# Display rollback summary
click.echo()
click.echo("=" * 60)
click.echo("Rollback Summary")
click.echo("=" * 60)
click.echo(f" Total operations: {summary['total']}")
click.echo(f" Successful: {summary['successful']}")
click.echo(f" Failed: {summary['failed']}")
click.echo(f" Skipped: {summary['skipped']}")
click.echo()
# Display completion message
if summary['failed'] > 0:
click.echo(f"⚠️ Rollback completed with {summary['failed']} failures.")
click.echo(" Check the log file for details.")
click.echo(" Some files may not have been restored to their original locations.")
else:
click.echo("✓ Rollback completed successfully!")
click.echo(" All files have been restored to their original locations.")
logger.info(
f"Rollback completed: "
f"{summary['successful']} successful, {summary['failed']} failed, {summary['skipped']} skipped"
)
except FileNotFoundError as e: except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True) click.echo(f"Error: {e}", err=True)
logger.error(f"Rollback log not found: {e}") ctx.logger.error(f"Rollback log not found: {e}")
sys.exit(1) sys.exit(1)
except ValueError as e: except ValueError as e:
click.echo(f"Error: Invalid rollback log format: {e}", err=True) click.echo(f"Error: {e}", err=True)
logger.error(f"Invalid rollback log: {e}") ctx.logger.error(f"Rollback failed: {e}")
sys.exit(1) sys.exit(1)
except OSError as e:
except Exception as e:
click.echo(f"Error during rollback: {e}", err=True) click.echo(f"Error during rollback: {e}", err=True)
logger.error(f"Rollback failed: {e}", exc_info=True) ctx.logger.error(f"Rollback failed: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
+14 -17
View File
@@ -1,6 +1,5 @@
"""Analyze command implementation.""" """Analyze command implementation."""
import json
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -8,9 +7,13 @@ import click
from vlm.analysis import analyze_series_completeness, detect_duplicates from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.context import CLIContext from vlm.context import CLIContext
from vlm.io import identities_to_analysis_input, load_identities_json, load_inventory_csv from vlm.io import (
identities_to_analysis_input,
load_identities_json,
load_inventory_csv,
save_analysis_json,
)
from vlm.models import MovieIdentity, SeriesIdentity from vlm.models import MovieIdentity, SeriesIdentity
from vlm.utils import utc_now
def analyze_cmd( def analyze_cmd(
@@ -68,7 +71,6 @@ def analyze_cmd(
click.echo(f"Saving analysis results to: {output}") click.echo(f"Saving analysis results to: {output}")
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
completeness_list = [ completeness_list = [
{ {
"series_title": c.series_title, "series_title": c.series_title,
@@ -96,19 +98,14 @@ def analyze_cmd(
"quality_comparison": d.quality_comparison, "quality_comparison": d.quality_comparison,
} }
) )
analysis_data = { save_analysis_json(
"vlm_schema_version": "1.0", completeness=completeness_list,
"metadata": { duplicates=duplicates_list,
"generated": generation_timestamp, source_identities=input,
"source_identities": str(input), total_movies=len(movies_data),
"total_movies": len(movies_data), total_series=len(series_data),
"total_series": len(series_data), output=output,
}, )
"completeness": completeness_list,
"duplicates": duplicates_list,
}
with open(output, "w", encoding="utf-8") as jsonfile:
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
click.echo("Analysis results saved successfully!") click.echo("Analysis results saved successfully!")
logger.info( logger.info(
+146
View File
@@ -0,0 +1,146 @@
"""Enrich command implementation."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.enrichment import enrich_identities_data
from vlm.io import load_json_file, save_json_file
def enrich_cmd(
ctx: CLIContext,
input: Path,
output: Optional[Path],
refresh_changed_only: bool,
refresh_all: bool,
timeout: int,
retries: int,
) -> None:
"""Enrich identities with translation and reputation metadata."""
logger = ctx.logger
config = ctx.config
if output is None:
output = input
click.echo(f"Enriching identities from: {input}")
click.echo(f"Output file: {output}")
click.echo()
identities_data = load_json_file(input)
if refresh_all and refresh_changed_only:
raise ValueError("--refresh-all and --refresh-changed-only are mutually exclusive.")
if timeout < 1:
raise ValueError("--timeout must be >= 1")
if retries < 0:
raise ValueError("--retries must be >= 0")
refresh_mode = "incremental"
if refresh_all:
refresh_mode = "refresh_all"
elif refresh_changed_only:
refresh_mode = "refresh_changed_only"
total = (
len(identities_data.get("movies", []))
+ len(identities_data.get("series", []))
+ len(identities_data.get("anime", []))
)
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
progress_position = {"current": 0}
progress_bucket = {"value": -1}
is_tty = bool(getattr(sys.stderr, "isatty", lambda: False)())
def _enrich_progress(processed: int, total_count: int, metrics: dict[str, int]) -> None:
if total_count <= 0:
return
if is_tty and progress_state["bar"] is None:
bar = click.progressbar(
length=total_count,
label="Enriching records",
show_pos=True,
)
progress_state["bar"] = bar.__enter__()
step = processed - progress_position["current"]
if step > 0 and progress_state["bar"] is not None:
progress_state["bar"].update(step)
progress_position["current"] = processed
if not is_tty:
percent = int(processed * 100 / total_count)
bucket = percent // 5
if bucket > progress_bucket["value"] or processed == total_count:
progress_bucket["value"] = bucket
click.echo(
"Progress: "
f"{processed}/{total_count} ({percent}%) "
f"api_calls={metrics.get('api_calls', 0)} "
f"cache_hits={metrics.get('cache_hits', 0)} "
f"failed={metrics.get('failed', 0)}"
)
try:
if total == 0:
click.echo("No movie/series/anime records found to enrich.")
enriched_data, stats = enrich_identities_data(
identities_data,
config,
refresh_mode=refresh_mode,
request_timeout=timeout,
retries=retries,
logger=logger,
progress_callback=_enrich_progress,
)
finally:
if progress_state["bar"] is not None:
progress_state["bar"].__exit__(None, None, None)
click.echo()
save_json_file(enriched_data, output)
total_records = int(stats["total"]) if stats["total"] else 0
cache_hits = int(stats["cache_hits"])
hit_rate = (cache_hits / total_records * 100.0) if total_records else 0.0
click.echo("Enrichment complete!")
click.echo(f" Total records: {total_records}")
click.echo(f" Refresh mode: {refresh_mode}")
click.echo(f" Enriched now: {stats['enriched']}")
click.echo(f" Cache hits: {stats['cache_hits']}")
click.echo(f" Cache hit rate: {hit_rate:.1f}%")
click.echo(f" API calls: {stats['api_calls']}")
click.echo(f" Failed requests: {stats['failed']}")
click.echo(f" Skipped: {stats['skipped']}")
click.echo(f" Needs review: {stats['needs_review']}")
skip_reasons = stats.get("skip_reasons", {})
if isinstance(skip_reasons, dict):
non_zero = [f"{name}={count}" for name, count in sorted(skip_reasons.items()) if int(count) > 0]
if non_zero:
click.echo(f" Skip reasons: {' '.join(non_zero)}")
failed_items = stats.get("failed_items", [])
if isinstance(failed_items, list) and failed_items:
click.echo(" Failure sample:")
for item in failed_items[:3]:
click.echo(f" - [{item.get('provider', 'unknown')}] {item.get('title', '')}: {item.get('reason', '')}")
logger.info(
"Enrich completed: total=%s enriched=%s cache_hits=%s failed=%s skipped=%s mode=%s",
stats["total"],
stats["enriched"],
stats["cache_hits"],
stats["failed"],
stats["skipped"],
refresh_mode,
)
+257
View File
@@ -0,0 +1,257 @@
"""Execute and rollback command implementations."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.executor import ExecutionEngine
from vlm.planner import load_plan
from vlm.state import StateManager
def _validate_plan_structure(execution_plan) -> list[str]:
"""Validate source/destination paths for move/rename operations."""
validation_errors: list[str] = []
for operation in execution_plan.operations:
if operation.operation_type in ("move", "rename"):
if not operation.source_path.exists():
validation_errors.append(f"Source file does not exist: {operation.source_path}")
elif not operation.source_path.is_file():
validation_errors.append(f"Source path is not a file: {operation.source_path}")
for operation in execution_plan.operations:
if operation.operation_type in ("move", "rename") and operation.destination_path:
dest_dir = operation.destination_path.parent
if not dest_dir.exists() and not dest_dir.parent.exists():
validation_errors.append(f"Cannot create destination directory (parent missing): {dest_dir}")
return validation_errors
def execute_cmd(
ctx: CLIContext,
plan: Path,
confirm: bool,
yes: bool,
verbose_ops: bool,
preserve_directories: bool,
safe_mode: bool,
) -> None:
"""Execute an execution plan in dry-run or execute mode."""
config = ctx.config
logger = ctx.logger
mode = "execute" if confirm else "dry-run"
click.echo(f"Loading execution plan from: {plan}")
click.echo()
execution_plan = load_plan(plan)
if preserve_directories:
click.echo("Directory preservation is enabled (plan metadata/operations will be honored).")
click.echo()
if safe_mode:
click.echo("Safe mode enabled - validating plan for directory preservation...")
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
if emptied_dirs:
raise ValueError(
"SAFE MODE VIOLATION: plan would empty directories. "
"Regenerate/adjust the plan to preserve directory structure."
)
click.echo("Safe mode validation passed - no directories would be destroyed")
click.echo()
click.echo("Validating directory structure...")
validation_errors = _validate_plan_structure(execution_plan)
if validation_errors:
formatted = "\n".join(f" - {error}" for error in validation_errors[:10])
if len(validation_errors) > 10:
formatted += f"\n ... and {len(validation_errors) - 10} more errors"
raise ValueError(f"DIRECTORY STRUCTURE VALIDATION FAILED:\n{formatted}")
click.echo("Directory structure validation passed")
click.echo()
click.echo(f"Execution plan loaded: {execution_plan.plan_id}")
click.echo(f"Created at: {execution_plan.created_at}")
click.echo(f"Total operations: {len(execution_plan.operations)}")
if execution_plan.human_summary:
click.echo()
click.echo(execution_plan.human_summary)
elif execution_plan.summary or execution_plan.summary_by_reason:
s = execution_plan.summary or {}
by_r = execution_plan.summary_by_reason or {}
parts = [
"操作统计:共 "
f"{s.get('total', len(execution_plan.operations))}"
f"move {s.get('move', 0)}rename {s.get('rename', 0)}"
f"quarantine {s.get('quarantine', 0)}no-op {s.get('no-op', 0)}"
]
if by_r:
parts.append("原因分布:" + "".join(f"{r}: {c}" for r, c in list(by_r.items())[:5]))
click.echo()
click.echo("\n".join(parts))
click.echo()
if mode == "dry-run":
click.echo("DRY-RUN MODE - No files will be modified")
click.echo("Use --confirm to actually execute operations")
else:
click.echo("EXECUTE MODE - Files will be modified")
click.echo()
if not yes and not click.confirm("Are you sure you want to proceed?"):
click.echo("Execution cancelled.")
return
if yes:
click.echo("Auto-approved via --yes flag")
click.echo()
click.echo(f"Executing {len(execution_plan.operations)} operations...")
click.echo()
state_path = Path.home() / ".vlm" / "state.json"
state_manager = StateManager(state_path)
engine = ExecutionEngine(
logger=logger,
config=config,
verbose_operations=verbose_ops,
state_manager=state_manager,
)
results, summary, rollback_log = engine.execute_plan(
execution_plan,
mode=mode,
confirmed=confirm,
)
if mode == "dry-run":
click.echo("Sample operations (dry-run):")
for i, result in enumerate(results[:5]):
op = result.operation
if op.operation_type != "no-op":
click.echo(f" [{i + 1}] {op.operation_type}: {op.source_path.name}")
if op.destination_path:
click.echo(f" -> {op.destination_path}")
if len(results) > 5:
click.echo(f" ... and {len(results) - 5} more operations")
else:
for i, result in enumerate(results):
op = result.operation
if op.operation_type != "no-op" and not op.has_conflict:
status = "OK" if result.success else "FAIL"
click.echo(f" [{i + 1}/{len(results)}] {status} {op.operation_type}: {op.source_path.name}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
click.echo()
click.echo("=" * 60)
click.echo(f"Execution Summary ({mode} mode)")
click.echo("=" * 60)
click.echo(f" Total operations: {summary['total']}")
click.echo(f" Successful: {summary['successful']}")
click.echo(f" Failed: {summary['failed']}")
click.echo(f" Skipped: {summary['skipped']}")
click.echo()
if mode == "execute" and rollback_log:
rollback_dir = Path.home() / ".vlm" / "rollback"
rollback_dir.mkdir(parents=True, exist_ok=True)
rollback_path = rollback_dir / f"rollback_{rollback_log.log_id}.json"
engine.save_rollback_log(rollback_log, rollback_path)
click.echo(f"Rollback log saved to: {rollback_path}")
click.echo()
click.echo("To undo these operations, run:")
click.echo(f" vlm rollback --log {rollback_path}")
click.echo()
if mode == "dry-run":
click.echo("Dry-run complete! No files were modified.")
click.echo("Review the operations above and use --confirm to execute.")
elif summary["failed"] > 0:
click.echo(f"Execution completed with {summary['failed']} failures.")
click.echo("Check the log file for details.")
else:
click.echo("Execution completed successfully!")
logger.info(
"Execution completed in %s mode: %s successful, %s failed, %s skipped",
mode,
summary["successful"],
summary["failed"],
summary["skipped"],
)
def rollback_cmd(ctx: CLIContext, log: Optional[Path]) -> None:
"""Rollback a prior execution from rollback log."""
config = ctx.config
logger = ctx.logger
if log is None:
rollback_dir = Path.home() / ".vlm" / "rollback"
if not rollback_dir.exists():
raise FileNotFoundError(f"No rollback logs found. Rollback directory does not exist: {rollback_dir}")
rollback_logs = sorted(
rollback_dir.glob("rollback_*.json"), key=lambda p: p.stat().st_mtime, reverse=True
)
if not rollback_logs:
raise FileNotFoundError(f"No rollback logs found. No rollback_*.json files in: {rollback_dir}")
log = rollback_logs[0]
click.echo(f"Loading rollback log: {log}")
engine = ExecutionEngine(logger=logger, config=config)
rollback_log = engine.load_rollback_log(log)
click.echo(f"Rollback log loaded: {rollback_log.log_id}")
click.echo(f"Plan ID: {rollback_log.execution_plan_id}")
click.echo(f"Executed at: {rollback_log.executed_at}")
click.echo(f"Operations to rollback: {len(rollback_log.operations)}")
click.echo()
if not click.confirm("Proceed with rollback?"):
click.echo("Rollback cancelled.")
return
click.echo(f"Rolling back {len(rollback_log.operations)} operations...")
click.echo()
click.echo("Performing rollback...")
rollback_results, rollback_summary = engine.rollback(rollback_log)
click.echo()
click.echo("Rollback Summary")
click.echo("=" * 60)
click.echo(f" Total operations: {rollback_summary['total']}")
click.echo(f" Successful: {rollback_summary['successful']}")
click.echo(f" Failed: {rollback_summary['failed']}")
click.echo(f" Skipped: {rollback_summary['skipped']}")
click.echo()
if rollback_summary["failed"] > 0:
click.echo("Failed operations:")
for result in rollback_results:
if not result.success:
op = result.operation
click.echo(f" - {op.operation_type}: {op.source_path}")
if result.error_message:
click.echo(f" Error: {result.error_message}")
click.echo()
if rollback_summary["failed"] == 0:
click.echo("Rollback completed successfully!")
else:
click.echo(f"Rollback completed with {rollback_summary['failed']} failures.")
logger.info(
"Rollback completed: %s successful, %s failed",
rollback_summary["successful"],
rollback_summary["failed"],
)
+165
View File
@@ -0,0 +1,165 @@
"""Parse command implementation."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.io import load_inventory_csv, save_identities_json
from vlm.parser import parse_movie, parse_series
from vlm.utils import utc_now
def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]) -> None:
"""Parse identities from scanned inventory."""
config = ctx.config
logger = ctx.logger
click.echo(f"Parsing identities from: {input}")
path_to_metadata: dict[str, object] = {}
if inventory:
click.echo(f"Loading video metadata from: {inventory}")
inventory_files = load_inventory_csv(inventory)
path_to_metadata = {str(vf.path): vf for vf in inventory_files}
click.echo(f"Loaded metadata for {len(path_to_metadata)} files")
click.echo()
inventory_files = load_inventory_csv(input)
video_files = [
{
"path": str(vf.path),
"filename": vf.filename,
"category": vf.category,
}
for vf in inventory_files
]
click.echo(f"Loaded {len(video_files)} files from inventory")
click.echo()
movie_identities: list[dict] = []
series_identities: list[dict] = []
anime_files: list[dict] = []
other_files: list[dict] = []
def get_video_metadata(file_path: str) -> dict:
"""Extract video metadata from inventory if available."""
if not path_to_metadata:
return {}
vf = path_to_metadata.get(file_path)
if not vf:
return {}
return {
"size_bytes": vf.size_bytes,
"modified_timestamp": vf.modified_timestamp.isoformat(),
"resolution": vf.resolution,
"codec": vf.codec,
"duration_seconds": vf.duration_seconds,
"bitrate_kbps": vf.bitrate_kbps,
}
for vf in video_files:
filename = vf["filename"]
category = vf["category"]
file_path = vf["path"]
video_metadata = get_video_metadata(file_path)
if category == "movie":
identity = parse_movie(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"category": category,
"title": identity.title,
"year": identity.year,
"confidence": identity.confidence,
"needs_review": identity.needs_review,
}
if video_metadata:
record["video_metadata"] = video_metadata
movie_identities.append(record)
elif category == "series":
identity = parse_series(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"category": category,
"title": identity.title,
"season": identity.season,
"episodes": identity.episodes,
"confidence": identity.confidence,
"needs_review": identity.needs_review,
}
if video_metadata:
record["video_metadata"] = video_metadata
series_identities.append(record)
elif category == "anime":
anime_files.append(
{
"path": vf["path"],
"filename": filename,
"category": category,
"note": "Anime parsing deferred in v1",
}
)
else:
other_files.append(
{
"path": vf["path"],
"filename": filename,
"category": category,
"note": "Not categorized for parsing",
}
)
click.echo("Parsing complete!")
click.echo()
click.echo("Results by category:")
click.echo(f" Movies: {len(movie_identities)}")
movies_need_review = sum(1 for m in movie_identities if m["needs_review"])
if movies_need_review > 0:
click.echo(f" - Need review: {movies_need_review}")
click.echo(f" Series: {len(series_identities)}")
series_need_review = sum(1 for s in series_identities if s["needs_review"])
if series_need_review > 0:
click.echo(f" - Need review: {series_need_review}")
click.echo(f" Anime: {len(anime_files)} (not parsed in v1)")
click.echo(f" Other: {len(other_files)} (not parsed)")
click.echo()
click.echo(f"Saving parsed identities to: {output}")
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
schema_version = "2.0" if path_to_metadata else "1.0"
identities_data = {
"vlm_schema_version": schema_version,
"metadata": {
"generated": generation_timestamp,
"source_inventory": str(input),
"total_files": len(video_files),
},
"movies": movie_identities,
"series": series_identities,
"anime": anime_files,
"other": other_files,
}
save_identities_json(identities_data, output)
click.echo("Parsed identities saved successfully!")
logger.info(
"Parse completed: %s movies, %s series, saved to %s",
len(movie_identities),
len(series_identities),
output,
)
+9
View File
@@ -81,6 +81,15 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
click.echo(f" ⚠️ Conflicts detected: {conflicts}") click.echo(f" ⚠️ Conflicts detected: {conflicts}")
click.echo(" Review the plan file for details on conflicting operations.") click.echo(" Review the plan file for details on conflicting operations.")
# Check for directory warnings
directory_warning = execution_plan.metadata.get("directory_warning", False)
if directory_warning:
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
click.echo()
click.echo(f" ⚠️ Directory preservation warning: {len(emptied_dirs)} directories will be emptied")
click.echo(" These directories will be preserved but may be empty after execution.")
click.echo(" Review the plan file for details on preserved directories.")
click.echo() click.echo()
click.echo(f"Saving execution plan to: {output}") click.echo(f"Saving execution plan to: {output}")
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
+7 -7
View File
@@ -5,15 +5,17 @@ from pathlib import Path
from typing import Optional from typing import Optional
import yaml import yaml
DEFAULT_VIDEO_EXTENSIONS = [
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
]
@dataclass @dataclass
class Config: class Config:
"""Configuration for Video Library Manager.""" """Configuration for Video Library Manager."""
library_root: Path library_root: Path
video_extensions: list[str] = field(default_factory=lambda: [ video_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_VIDEO_EXTENSIONS))
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
])
movie_template: str = "movie/{title} ({year})/" movie_template: str = "movie/{title} ({year})/"
series_template: str = "series/{title}/Season {season:02d}/" series_template: str = "series/{title}/Season {season:02d}/"
movie_filename_template: str = "{title} ({year}){ext}" movie_filename_template: str = "{title} ({year}){ext}"
@@ -74,9 +76,7 @@ def load_config(path: Path) -> Config:
library_root = Path(library_root_str).expanduser() library_root = Path(library_root_str).expanduser()
video_extensions = data.get("video_extensions", [ video_extensions = data.get("video_extensions", list(DEFAULT_VIDEO_EXTENSIONS))
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
])
templates = data.get("templates", {}) templates = data.get("templates", {})
movie_template = templates.get("movie_dir", "movie/{title} ({year})/") movie_template = templates.get("movie_dir", "movie/{title} ({year})/")
@@ -149,7 +149,7 @@ def create_default_config(path: Path) -> Config:
"""Create a default configuration file and return the Config object.""" """Create a default configuration file and return the Config object."""
default_config = Config( default_config = Config(
library_root=Path.home() / "Videos", library_root=Path.home() / "Videos",
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"], video_extensions=list(DEFAULT_VIDEO_EXTENSIONS),
) )
enrichment_content = { enrichment_content = {
+48 -6
View File
@@ -93,8 +93,17 @@ class ExecutionEngine:
transaction_log = None transaction_log = None
if mode == "execute": if mode == "execute":
log_path = Path.home() / ".vlm" / "transaction.json" log_path = Path.home() / ".vlm" / "transaction.json"
transaction_log = TransactionLog(log_path) try:
transaction_log.start_transaction(plan) transaction_log = TransactionLog(log_path)
transaction_log.start_transaction(plan)
except OSError as exc:
log_operation(
self.logger,
logging.WARNING,
f"Transaction log disabled (cannot write {log_path}): {exc}",
operation_type="execute",
)
transaction_log = None
# Execute all operations # Execute all operations
results = [] results = []
@@ -105,9 +114,18 @@ class ExecutionEngine:
# Update transaction and state logs in execute mode # Update transaction and state logs in execute mode
if mode == "execute": if mode == "execute":
if transaction_log: if transaction_log:
transaction_log.mark_operation_complete( try:
i, result.success, result.error_message transaction_log.mark_operation_complete(
) i, result.success, result.error_message
)
except OSError as exc:
log_operation(
self.logger,
logging.WARNING,
f"Failed to update transaction log: {exc}",
operation_type="execute",
)
transaction_log = None
# Update file state if successful and not a no-op # Update file state if successful and not a no-op
if result.success and operation.operation_type != "no-op" and self.state_manager: if result.success and operation.operation_type != "no-op" and self.state_manager:
@@ -126,7 +144,15 @@ class ExecutionEngine:
if mode == "execute": if mode == "execute":
if transaction_log: if transaction_log:
status = "completed" if all(r.success for r in results) else "failed" status = "completed" if all(r.success for r in results) else "failed"
transaction_log.complete_transaction(status=status) try:
transaction_log.complete_transaction(status=status)
except OSError as exc:
log_operation(
self.logger,
logging.WARNING,
f"Failed to finalize transaction log: {exc}",
operation_type="execute",
)
if self.state_manager: if self.state_manager:
self.state_manager.save() self.state_manager.save()
@@ -189,6 +215,22 @@ class ExecutionEngine:
executed_at=executed_at executed_at=executed_at
) )
# Handle preserve-directory operations
if operation.operation_type == "preserve-directory":
log_operation(
self.logger,
logging.DEBUG,
f"Preserving directory: {operation.reason}",
operation_type="execute",
file_path=operation.source_path
)
return OperationResult(
operation=operation,
success=True,
error_message=None,
executed_at=executed_at
)
# Handle quarantine operations (no destination_path; use QuarantineManager) # Handle quarantine operations (no destination_path; use QuarantineManager)
if operation.operation_type == "quarantine": if operation.operation_type == "quarantine":
if not self._quarantine_manager: if not self._quarantine_manager:
+44 -7
View File
@@ -15,34 +15,71 @@ from vlm.scanner import load_inventory_csv, save_inventory_csv
__all__ = [ __all__ = [
"load_inventory_csv", "load_inventory_csv",
"save_inventory_csv", "save_inventory_csv",
"load_json_file",
"save_json_file",
"load_identities_json", "load_identities_json",
"save_identities_json", "save_identities_json",
"load_analysis_json", "load_analysis_json",
"save_analysis_json",
"identities_to_plan_input", "identities_to_plan_input",
"identities_to_analysis_input", "identities_to_analysis_input",
] ]
def load_json_file(path: Path) -> dict:
"""Load a JSON object from disk."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json_file(data: dict, path: Path) -> None:
"""Save a JSON object to disk."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def load_analysis_json(path: Path) -> dict: def load_analysis_json(path: Path) -> dict:
"""Load analysis result from JSON file (metadata, completeness, duplicates). """Load analysis result from JSON file (metadata, completeness, duplicates).
Caller should check file existence and handle missing/invalid keys. Caller should check file existence and handle missing/invalid keys.
""" """
with open(path, "r", encoding="utf-8") as f: return load_json_file(path)
return json.load(f)
def load_identities_json(path: Path) -> dict: def load_identities_json(path: Path) -> dict:
"""Load identities from JSON file.""" """Load identities from JSON file."""
with open(path, "r", encoding="utf-8") as f: return load_json_file(path)
return json.load(f)
def save_identities_json(data: dict, path: Path) -> None: def save_identities_json(data: dict, path: Path) -> None:
"""Save identities dict to JSON file.""" """Save identities dict to JSON file."""
path.parent.mkdir(parents=True, exist_ok=True) save_json_file(data, path)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def save_analysis_json(
*,
completeness: list[dict],
duplicates: list[dict],
source_identities: Path,
total_movies: int,
total_series: int,
output: Path,
) -> None:
"""Save analysis result JSON using the canonical schema."""
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
analysis_data = {
"vlm_schema_version": "1.0",
"metadata": {
"generated": generation_timestamp,
"source_identities": str(source_identities),
"total_movies": total_movies,
"total_series": total_series,
},
"completeness": completeness,
"duplicates": duplicates,
}
save_json_file(analysis_data, output)
def _video_file_from_record(record: dict) -> VideoFile: def _video_file_from_record(record: dict) -> VideoFile:
+2 -2
View File
@@ -113,9 +113,9 @@ class SeriesIdentity:
@dataclass @dataclass
class FileOperation: class FileOperation:
"""Represents a single file operation in an execution plan. """Represents a single file operation in an execution plan.
Attributes: Attributes:
operation_type: Type of operation ("move", "rename", "quarantine", "no-op") operation_type: Type of operation ("move", "rename", "quarantine", "no-op", "preserve-directory")
source_path: Source file path source_path: Source file path
destination_path: Destination file path (None for no-op operations) destination_path: Destination file path (None for no-op operations)
reason: Human-readable reason for the operation reason: Human-readable reason for the operation
+1 -6
View File
@@ -7,14 +7,9 @@ logical identities such as movie titles/years and series titles/seasons/episodes
import re import re
from typing import Optional from typing import Optional
from vlm.config import DEFAULT_VIDEO_EXTENSIONS
from vlm.models import MovieIdentity, SeriesIdentity from vlm.models import MovieIdentity, SeriesIdentity
# Default extensions used when extensions param is not provided (matches config default)
DEFAULT_VIDEO_EXTENSIONS = [
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
]
# Quality tags to remove from titles # Quality tags to remove from titles
QUALITY_TAGS = [ QUALITY_TAGS = [
r'\b1080p\b', r'\b720p\b', r'\b480p\b', r'\b2160p\b', r'\b1080p\b', r'\b720p\b', r'\b480p\b', r'\b2160p\b',
+58 -2
View File
@@ -40,6 +40,42 @@ def _is_sample_path(path: Path) -> bool:
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem)) return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem))
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
"""Analyze which directories will be emptied by the plan."""
# Get all source directories that have files being moved/renamed
source_dirs = set()
moved_files = set()
for operation in operations:
if operation.operation_type in ("move", "rename"):
source_dirs.add(operation.source_path.parent)
moved_files.add(operation.source_path)
emptied_dirs = []
for dir_path in source_dirs:
# Only analyze directories that actually exist
if not dir_path.exists():
continue
# Count files that will remain in this directory after operations
remaining_count = 0
try:
for item in dir_path.iterdir():
if item.is_file() and item not in moved_files:
remaining_count += 1
except (OSError, PermissionError):
# If we can't read the directory, skip analysis
continue
# If no files will remain, this directory will be emptied
if remaining_count == 0:
emptied_dirs.append(dir_path)
return {
"emptied_directories": emptied_dirs,
"warning_required": len(emptied_dirs) > 0
}
def generate_plan( def generate_plan(
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]], identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
config: Config, config: Config,
@@ -120,10 +156,29 @@ def generate_plan(
conflict_reason=None, conflict_reason=None,
) )
# Analyze directory impact and add preservation operations
directory_analysis = _analyze_directory_impact(operations)
if directory_analysis["warning_required"]:
# Add directory preservation operations for emptied directories
for emptied_dir in directory_analysis["emptied_directories"]:
operations.append(FileOperation(
operation_type="preserve-directory",
source_path=emptied_dir,
destination_path=None,
reason=f"Preserve empty source directory: {emptied_dir.name}",
has_conflict=False,
conflict_reason=None
))
summary = _generate_summary(operations) summary = _generate_summary(operations)
summary_by_reason = _generate_summary_by_reason(operations) summary_by_reason = _generate_summary_by_reason(operations)
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata) human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
# Add directory warnings to metadata
if directory_analysis["warning_required"]:
metadata["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
metadata["directory_warning"] = True
return ExecutionPlan( return ExecutionPlan(
plan_id=str(uuid.uuid4()), plan_id=str(uuid.uuid4()),
created_at=utc_now(), created_at=utc_now(),
@@ -445,9 +500,10 @@ def _generate_summary(operations: list[FileOperation]) -> dict:
"move": 0, "move": 0,
"rename": 0, "rename": 0,
"quarantine": 0, "quarantine": 0,
"no-op": 0 "no-op": 0,
"preserve-directory": 0
} }
for operation in operations: for operation in operations:
op_type = operation.operation_type op_type = operation.operation_type
if op_type in summary: if op_type in summary:
+29 -29
View File
@@ -5,7 +5,7 @@ Tests series completeness analysis, duplicate detection, and quality comparison.
import pytest import pytest
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
from vlm.models import SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup from vlm.models import SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup
from vlm.analysis import analyze_series_completeness, detect_duplicates, compare_quality from vlm.analysis import analyze_series_completeness, detect_duplicates, compare_quality
@@ -193,7 +193,7 @@ class TestDuplicateDetection:
Path("/movies/The.Matrix.1999.1080p.mkv"), Path("/movies/The.Matrix.1999.1080p.mkv"),
"The.Matrix.1999.1080p.mkv", "The.Matrix.1999.1080p.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264" codec="h264"
@@ -202,7 +202,7 @@ class TestDuplicateDetection:
Path("/movies/The.Matrix.1999.720p.mkv"), Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv", "The.Matrix.1999.720p.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1280x720", resolution="1280x720",
codec="h264" codec="h264"
@@ -211,7 +211,7 @@ class TestDuplicateDetection:
Path("/movies/Inception.2010.mkv"), Path("/movies/Inception.2010.mkv"),
"Inception.2010.mkv", "Inception.2010.mkv",
1500000000, 1500000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
), ),
] ]
@@ -239,7 +239,7 @@ class TestDuplicateDetection:
Path("/series/Breaking.Bad.S01E01.1080p.mkv"), Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
"Breaking.Bad.S01E01.1080p.mkv", "Breaking.Bad.S01E01.1080p.mkv",
1500000000, 1500000000,
datetime.now(), datetime.now(timezone.utc),
"series", "series",
resolution="1920x1080" resolution="1920x1080"
), ),
@@ -247,7 +247,7 @@ class TestDuplicateDetection:
Path("/series/Breaking.Bad.S01E01.720p.mkv"), Path("/series/Breaking.Bad.S01E01.720p.mkv"),
"Breaking.Bad.S01E01.720p.mkv", "Breaking.Bad.S01E01.720p.mkv",
800000000, 800000000,
datetime.now(), datetime.now(timezone.utc),
"series", "series",
resolution="1280x720" resolution="1280x720"
), ),
@@ -255,7 +255,7 @@ class TestDuplicateDetection:
Path("/series/Breaking.Bad.S01E02.mkv"), Path("/series/Breaking.Bad.S01E02.mkv"),
"Breaking.Bad.S01E02.mkv", "Breaking.Bad.S01E02.mkv",
1200000000, 1200000000,
datetime.now(), datetime.now(timezone.utc),
"series" "series"
), ),
] ]
@@ -278,8 +278,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -294,8 +294,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -311,8 +311,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -327,8 +327,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -347,7 +347,7 @@ class TestDuplicateDetection:
Path("/movies/Test.Movie.2020.1080p.mkv"), Path("/movies/Test.Movie.2020.1080p.mkv"),
"Test.Movie.2020.1080p.mkv", "Test.Movie.2020.1080p.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264", codec="h264",
@@ -358,7 +358,7 @@ class TestDuplicateDetection:
Path("/movies/Test.Movie.2020.720p.mkv"), Path("/movies/Test.Movie.2020.720p.mkv"),
"Test.Movie.2020.720p.mkv", "Test.Movie.2020.720p.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1280x720", resolution="1280x720",
codec="h264", codec="h264",
@@ -395,9 +395,9 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -413,8 +413,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -429,8 +429,8 @@ class TestDuplicateDetection:
] ]
files = [ files = [
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
] ]
result = detect_duplicates(list(zip(identities, files))) result = detect_duplicates(list(zip(identities, files)))
@@ -448,7 +448,7 @@ class TestQualityComparison:
Path("/test/file1.mkv"), Path("/test/file1.mkv"),
"file1.mkv", "file1.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264", codec="h264",
@@ -459,7 +459,7 @@ class TestQualityComparison:
Path("/test/file2.mkv"), Path("/test/file2.mkv"),
"file2.mkv", "file2.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1280x720", resolution="1280x720",
codec="h265", codec="h265",
@@ -490,7 +490,7 @@ class TestQualityComparison:
Path("/test/file1.mkv"), Path("/test/file1.mkv"),
"file1.mkv", "file1.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080" resolution="1920x1080"
# codec, duration, bitrate not available # codec, duration, bitrate not available
@@ -499,7 +499,7 @@ class TestQualityComparison:
Path("/test/file2.mkv"), Path("/test/file2.mkv"),
"file2.mkv", "file2.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
# No optional metadata # No optional metadata
), ),
@@ -532,7 +532,7 @@ class TestQualityComparison:
Path("/test/file.mkv"), Path("/test/file.mkv"),
"file.mkv", "file.mkv",
1500000000, 1500000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264" codec="h264"
+8 -8
View File
@@ -6,7 +6,7 @@ Each test validates a specific property from the design document.
import pytest import pytest
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
from hypothesis import given, strategies as st, settings from hypothesis import given, strategies as st, settings
from vlm.models import ( from vlm.models import (
SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup
@@ -74,7 +74,7 @@ def video_file_strategy(draw, filename=None, category="movie"):
path = Path(f"/{category}/{filename}") path = Path(f"/{category}/{filename}")
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000)) size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
modified_timestamp = datetime.now() modified_timestamp = datetime.now(timezone.utc)
# Optional metadata # Optional metadata
has_metadata = draw(st.booleans()) has_metadata = draw(st.booleans())
@@ -226,7 +226,7 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
Path(f"/movies/{filename}"), Path(f"/movies/{filename}"),
filename, filename,
1000000000 + i * 100000000, 1000000000 + i * 100000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
)) ))
@@ -272,7 +272,7 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
Path(f"/series/{filename}"), Path(f"/series/{filename}"),
filename, filename,
1000000000 + i * 100000000, 1000000000 + i * 100000000,
datetime.now(), datetime.now(timezone.utc),
"series" "series"
)) ))
@@ -321,7 +321,7 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
Path(f"/movies/{filename}"), Path(f"/movies/{filename}"),
filename, filename,
1000000000 + i * 100000000, 1000000000 + i * 100000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264", codec="h264",
@@ -333,7 +333,7 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
Path(f"/movies/{filename}"), Path(f"/movies/{filename}"),
filename, filename,
1000000000 + i * 100000000, 1000000000 + i * 100000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
)) ))
@@ -429,7 +429,7 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
Path(f"/movies/{filename}"), Path(f"/movies/{filename}"),
filename, filename,
1000000000 + j * 500000000, 1000000000 + j * 500000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080" if j == 0 else "1280x720", resolution="1920x1080" if j == 0 else "1280x720",
codec="h264" codec="h264"
@@ -492,7 +492,7 @@ def test_property_44_summary_report_accuracy(file_count, categories):
Path(f"/{category}/{filename}"), Path(f"/{category}/{filename}"),
filename, filename,
size, size,
datetime.now(), datetime.now(timezone.utc),
category category
)) ))
+4 -4
View File
@@ -2,7 +2,7 @@
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -46,7 +46,7 @@ def sample_rollback_log(tmp_path):
), ),
success=True, success=True,
error_message=None, error_message=None,
executed_at=datetime.now() executed_at=datetime.now(timezone.utc)
), ),
OperationResult( OperationResult(
operation=FileOperation( operation=FileOperation(
@@ -59,14 +59,14 @@ def sample_rollback_log(tmp_path):
), ),
success=True, success=True,
error_message=None, error_message=None,
executed_at=datetime.now() executed_at=datetime.now(timezone.utc)
) )
] ]
rollback_log = RollbackLog( rollback_log = RollbackLog(
log_id="test-log-id", log_id="test-log-id",
execution_plan_id="test-plan-id", execution_plan_id="test-plan-id",
executed_at=datetime.now(), executed_at=datetime.now(timezone.utc),
operations=operations operations=operations
) )
+14 -14
View File
@@ -4,7 +4,7 @@ Tests execution mode handling, dry-run simulation, and actual file operations.
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -73,7 +73,7 @@ def sample_plan(temp_test_dir):
return ExecutionPlan( return ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 1, "rename": 1} summary={"move": 1, "rename": 1}
) )
@@ -208,7 +208,7 @@ class TestDryRunSimulation:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[no_op_operation], operations=[no_op_operation],
summary={"no-op": 1} summary={"no-op": 1}
) )
@@ -233,7 +233,7 @@ class TestDryRunSimulation:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[conflicted_operation], operations=[conflicted_operation],
summary={"move": 1} summary={"move": 1}
) )
@@ -285,7 +285,7 @@ class TestExecuteMode:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[operation], operations=[operation],
summary={"move": 1} summary={"move": 1}
) )
@@ -319,7 +319,7 @@ class TestExecuteMode:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[operation], operations=[operation],
summary={"move": 1} summary={"move": 1}
) )
@@ -352,7 +352,7 @@ class TestExecuteMode:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[conflicted_operation], operations=[conflicted_operation],
summary={"move": 1} summary={"move": 1}
) )
@@ -427,7 +427,7 @@ class TestRollbackLog:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 2} summary={"move": 2}
) )
@@ -480,7 +480,7 @@ class TestExecutionSummary:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 2, "no-op": 1} summary={"move": 2, "no-op": 1}
) )
@@ -573,7 +573,7 @@ class TestRollbackLogSaving:
data = json.load(f) data = json.load(f)
# Verify timestamps are in ISO format # Verify timestamps are in ISO format
from datetime import datetime from datetime import datetime, timezone
executed_at = datetime.fromisoformat(data["executed_at"]) executed_at = datetime.fromisoformat(data["executed_at"])
assert executed_at is not None assert executed_at is not None
@@ -670,7 +670,7 @@ class TestRollbackExecution:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 2} summary={"move": 2}
) )
@@ -759,7 +759,7 @@ class TestRollbackExecution:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 1, "no-op": 1} summary={"move": 1, "no-op": 1}
) )
@@ -877,7 +877,7 @@ class TestRollbackExecution:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=operations, operations=operations,
summary={"move": 2} summary={"move": 2}
) )
@@ -920,7 +920,7 @@ class TestRollbackExecution:
plan = ExecutionPlan( plan = ExecutionPlan(
plan_id=str(uuid4()), plan_id=str(uuid4()),
created_at=datetime.now(), created_at=datetime.now(timezone.utc),
operations=[ operations=[
FileOperation( FileOperation(
operation_type="quarantine", operation_type="quarantine",
+46 -46
View File
@@ -1,7 +1,7 @@
"""Unit tests for plan generator.""" """Unit tests for plan generator."""
import pytest import pytest
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from vlm.config import Config from vlm.config import Config
@@ -36,7 +36,7 @@ def test_generate_plan_for_movie_with_year(config):
path=Path("/mnt/nas/videos/movie/Some.Movie.2020.1080p.mkv"), path=Path("/mnt/nas/videos/movie/Some.Movie.2020.1080p.mkv"),
filename="Some.Movie.2020.1080p.mkv", filename="Some.Movie.2020.1080p.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -67,7 +67,7 @@ def test_generate_plan_for_movie_without_year(config):
path=Path("/mnt/nas/videos/movie/random_movie.mkv"), path=Path("/mnt/nas/videos/movie/random_movie.mkv"),
filename="random_movie.mkv", filename="random_movie.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -94,7 +94,7 @@ def test_generate_plan_for_series_with_season_and_episode(config):
path=Path("/mnt/nas/videos/series/Show.Name.S01E05.mkv"), path=Path("/mnt/nas/videos/series/Show.Name.S01E05.mkv"),
filename="Show.Name.S01E05.mkv", filename="Show.Name.S01E05.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
@@ -123,7 +123,7 @@ def test_generate_plan_blocks_series_with_high_season(config):
path=Path("/mnt/nas/videos/series/Show.Name.S20E01.mkv"), path=Path("/mnt/nas/videos/series/Show.Name.S20E01.mkv"),
filename="Show.Name.S20E01.mkv", filename="Show.Name.S20E01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
identity = SeriesIdentity( identity = SeriesIdentity(
@@ -147,7 +147,7 @@ def test_generate_plan_blocks_series_with_high_episode(config):
path=Path("/mnt/nas/videos/series/Show.Name.S01E120.mkv"), path=Path("/mnt/nas/videos/series/Show.Name.S01E120.mkv"),
filename="Show.Name.S01E120.mkv", filename="Show.Name.S01E120.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
identity = SeriesIdentity( identity = SeriesIdentity(
@@ -171,7 +171,7 @@ def test_generate_plan_sample_is_noop_by_default(config):
path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"), path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"),
filename="Show.Name.Sample.S01E01.mkv", filename="Show.Name.Sample.S01E01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
identity = SeriesIdentity( identity = SeriesIdentity(
@@ -196,7 +196,7 @@ def test_generate_plan_sample_can_be_included_via_config(config):
path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"), path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"),
filename="Show.Name.Sample.S01E01.mkv", filename="Show.Name.Sample.S01E01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
identity = SeriesIdentity( identity = SeriesIdentity(
@@ -219,7 +219,7 @@ def test_generate_plan_for_series_without_season(config):
path=Path("/mnt/nas/videos/series/ambiguous_show.mkv"), path=Path("/mnt/nas/videos/series/ambiguous_show.mkv"),
filename="ambiguous_show.mkv", filename="ambiguous_show.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
@@ -247,7 +247,7 @@ def test_generate_plan_for_anime_category(config):
path=Path("/mnt/nas/videos/anime/Some.Anime.01.mkv"), path=Path("/mnt/nas/videos/anime/Some.Anime.01.mkv"),
filename="Some.Anime.01.mkv", filename="Some.Anime.01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="anime" category="anime"
) )
@@ -267,7 +267,7 @@ def test_generate_plan_for_other_category(config):
path=Path("/mnt/nas/videos/other/random.mkv"), path=Path("/mnt/nas/videos/other/random.mkv"),
filename="random.mkv", filename="random.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="other" category="other"
) )
@@ -286,7 +286,7 @@ def test_generate_plan_preserves_category_boundaries(config):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -311,7 +311,7 @@ def test_generate_plan_for_multi_episode_file(config):
path=Path("/mnt/nas/videos/series/Show.S01E01E02.mkv"), path=Path("/mnt/nas/videos/series/Show.S01E01E02.mkv"),
filename="Show.S01E01E02.mkv", filename="Show.S01E01E02.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
@@ -338,7 +338,7 @@ def test_generate_plan_file_already_at_target(config):
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv"), path=Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv"),
filename="Some Movie (2020).mkv", filename="Some Movie (2020).mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -364,7 +364,7 @@ def test_generate_plan_rename_vs_move(config):
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/old_name.mkv"), path=Path("/mnt/nas/videos/movie/Some Movie (2020)/old_name.mkv"),
filename="old_name.mkv", filename="old_name.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -385,7 +385,7 @@ def test_generate_plan_rename_vs_move(config):
path=Path("/mnt/nas/videos/movie/wrong_dir/Some Movie (2020).mkv"), path=Path("/mnt/nas/videos/movie/wrong_dir/Some Movie (2020).mkv"),
filename="Some Movie (2020).mkv", filename="Some Movie (2020).mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -403,7 +403,7 @@ def test_generate_plan_summary(config):
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
filename="Movie1.2020.mkv", filename="Movie1.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -420,7 +420,7 @@ def test_generate_plan_summary(config):
path=Path("/mnt/nas/videos/movie/Movie2.mkv"), path=Path("/mnt/nas/videos/movie/Movie2.mkv"),
filename="Movie2.mkv", filename="Movie2.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -437,7 +437,7 @@ def test_generate_plan_summary(config):
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"), path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
filename="Anime.01.mkv", filename="Anime.01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="anime" category="anime"
), ),
None None
@@ -448,7 +448,7 @@ def test_generate_plan_summary(config):
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"), path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
filename="Show.S01E01.mkv", filename="Show.S01E01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
), ),
SeriesIdentity( SeriesIdentity(
@@ -480,14 +480,14 @@ def test_generate_plan_with_analysis_by_quality(config):
path=p_720, path=p_720,
filename="Test.2020.720p.WEB-DL.mkv", filename="Test.2020.720p.WEB-DL.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
vf_1080 = VideoFile( vf_1080 = VideoFile(
path=p_1080, path=p_1080,
filename="Test.2020.1080p.BluRay.mkv", filename="Test.2020.1080p.BluRay.mkv",
size_bytes=2000000, size_bytes=2000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
identity = MovieIdentity( identity = MovieIdentity(
@@ -528,14 +528,14 @@ def test_generate_plan_with_analysis_by_reputation_quality_time_reason(config):
path=p_old, path=p_old,
filename="Test.2020.720p.WEB-DL.mkv", filename="Test.2020.720p.WEB-DL.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
vf_new = VideoFile( vf_new = VideoFile(
path=p_new, path=p_new,
filename="Test.2020.1080p.BluRay.mkv", filename="Test.2020.1080p.BluRay.mkv",
size_bytes=2000000, size_bytes=2000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
identity = MovieIdentity( identity = MovieIdentity(
@@ -574,14 +574,14 @@ def test_generate_plan_with_analysis_by_reputation_missing_scores_uses_fallback_
path=p_web, path=p_web,
filename="Test.2020.2160p.WEB-DL.mkv", filename="Test.2020.2160p.WEB-DL.mkv",
size_bytes=3000000, size_bytes=3000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
vf_bluray = VideoFile( vf_bluray = VideoFile(
path=p_bluray, path=p_bluray,
filename="Test.2020.1080p.BluRay.mkv", filename="Test.2020.1080p.BluRay.mkv",
size_bytes=2000000, size_bytes=2000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
identity = MovieIdentity( identity = MovieIdentity(
@@ -622,14 +622,14 @@ def test_generate_plan_human_summary_marks_disc_files_as_high_risk(config):
path=p_disc1, path=p_disc1,
filename="The.Best.of.Youth.DISC1.mkv", filename="The.Best.of.Youth.DISC1.mkv",
size_bytes=2000000, size_bytes=2000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
vf2 = VideoFile( vf2 = VideoFile(
path=p_disc2, path=p_disc2,
filename="The.Best.of.Youth.DISC2.mkv", filename="The.Best.of.Youth.DISC2.mkv",
size_bytes=1800000, size_bytes=1800000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie", category="movie",
) )
identity = MovieIdentity( identity = MovieIdentity(
@@ -669,7 +669,7 @@ def test_generate_plan_with_different_extensions(config):
path=Path(f"/mnt/nas/videos/movie/Movie.2020{ext}"), path=Path(f"/mnt/nas/videos/movie/Movie.2020{ext}"),
filename=f"Movie.2020{ext}", filename=f"Movie.2020{ext}",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -704,7 +704,7 @@ def test_conflict_detection_for_movie(config, tmp_path):
path=tmp_path / "movie" / "Some.Movie.2020.1080p.mkv", path=tmp_path / "movie" / "Some.Movie.2020.1080p.mkv",
filename="Some.Movie.2020.1080p.mkv", filename="Some.Movie.2020.1080p.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -741,7 +741,7 @@ def test_conflict_detection_for_series(config, tmp_path):
path=tmp_path / "series" / "Show.Name.S01E05.1080p.mkv", path=tmp_path / "series" / "Show.Name.S01E05.1080p.mkv",
filename="Show.Name.S01E05.1080p.mkv", filename="Show.Name.S01E05.1080p.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
@@ -776,7 +776,7 @@ def test_no_conflict_when_destination_does_not_exist(config, tmp_path):
path=source_dir / "Some.Movie.2020.mkv", path=source_dir / "Some.Movie.2020.mkv",
filename="Some.Movie.2020.mkv", filename="Some.Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -814,7 +814,7 @@ def test_conflict_detection_with_multiple_files(config, tmp_path):
path=tmp_path / "movie" / "Movie1.2020.mkv", path=tmp_path / "movie" / "Movie1.2020.mkv",
filename="Movie1.2020.mkv", filename="Movie1.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -831,7 +831,7 @@ def test_conflict_detection_with_multiple_files(config, tmp_path):
path=tmp_path / "movie" / "Movie2.2021.mkv", path=tmp_path / "movie" / "Movie2.2021.mkv",
filename="Movie2.2021.mkv", filename="Movie2.2021.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -864,7 +864,7 @@ def test_save_plan_to_json(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -909,7 +909,7 @@ def test_load_plan_from_json(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -963,7 +963,7 @@ def test_save_and_load_plan_with_conflicts(config, tmp_path):
path=tmp_path / "movie" / "Movie.2020.mkv", path=tmp_path / "movie" / "Movie.2020.mkv",
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -1003,7 +1003,7 @@ def test_save_and_load_plan_with_no_op_operations(config, tmp_path):
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"), path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
filename="Anime.01.mkv", filename="Anime.01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="anime" category="anime"
), ),
None None
@@ -1014,7 +1014,7 @@ def test_save_and_load_plan_with_no_op_operations(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie.mkv"), path=Path("/mnt/nas/videos/movie/Movie.mkv"),
filename="Movie.mkv", filename="Movie.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -1048,7 +1048,7 @@ def test_save_plan_json_is_human_readable(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -1089,7 +1089,7 @@ def test_save_plan_with_multiple_operations(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
filename="Movie1.2020.mkv", filename="Movie1.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
), ),
MovieIdentity( MovieIdentity(
@@ -1106,7 +1106,7 @@ def test_save_plan_with_multiple_operations(config, tmp_path):
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"), path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
filename="Show.S01E01.mkv", filename="Show.S01E01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
), ),
SeriesIdentity( SeriesIdentity(
@@ -1124,7 +1124,7 @@ def test_save_plan_with_multiple_operations(config, tmp_path):
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"), path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
filename="Anime.01.mkv", filename="Anime.01.mkv",
size_bytes=500000, size_bytes=500000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="anime" category="anime"
), ),
None None
@@ -1176,7 +1176,7 @@ def test_plan_json_includes_all_required_fields(config, tmp_path):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -1214,7 +1214,7 @@ def test_movie_rejected_by_review_generates_noop(config):
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"), path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
filename="Movie.2020.mkv", filename="Movie.2020.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="movie" category="movie"
) )
@@ -1238,7 +1238,7 @@ def test_series_rejected_by_review_generates_noop(config):
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"), path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
filename="Show.S01E01.mkv", filename="Show.S01E01.mkv",
size_bytes=1000000, size_bytes=1000000,
modified_timestamp=datetime.now(), modified_timestamp=datetime.now(timezone.utc),
category="series" category="series"
) )
+1 -1
View File
@@ -3,7 +3,7 @@
import json import json
import pytest import pytest
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
from vlm.quarantine import QuarantineManager from vlm.quarantine import QuarantineManager
from vlm.config import Config from vlm.config import Config
+23 -23
View File
@@ -185,7 +185,7 @@ class TestInventoryReport:
Path("/test.mkv"), Path("/test.mkv"),
"test.mkv", "test.mkv",
1000, 1000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
) )
] ]
@@ -377,7 +377,7 @@ class TestDuplicateReport:
Path("/movies/The.Matrix.1999.1080p.mkv"), Path("/movies/The.Matrix.1999.1080p.mkv"),
"The.Matrix.1999.1080p.mkv", "The.Matrix.1999.1080p.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264", codec="h264",
@@ -388,7 +388,7 @@ class TestDuplicateReport:
Path("/movies/The.Matrix.1999.720p.mkv"), Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv", "The.Matrix.1999.720p.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1280x720", resolution="1280x720",
codec="h264" codec="h264"
@@ -448,14 +448,14 @@ class TestDuplicateReport:
Path("/movies/Inception.2010.1080p.mkv"), Path("/movies/Inception.2010.1080p.mkv"),
"Inception.2010.1080p.mkv", "Inception.2010.1080p.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
), ),
VideoFile( VideoFile(
Path("/movies/Inception.2010.720p.mkv"), Path("/movies/Inception.2010.720p.mkv"),
"Inception.2010.720p.mkv", "Inception.2010.720p.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
), ),
] ]
@@ -501,14 +501,14 @@ class TestDuplicateReport:
Path("/series/Breaking.Bad.S01E01.1080p.mkv"), Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
"Breaking.Bad.S01E01.1080p.mkv", "Breaking.Bad.S01E01.1080p.mkv",
1500000000, 1500000000,
datetime.now(), datetime.now(timezone.utc),
"series" "series"
), ),
VideoFile( VideoFile(
Path("/series/Breaking.Bad.S01E01.720p.mkv"), Path("/series/Breaking.Bad.S01E01.720p.mkv"),
"Breaking.Bad.S01E01.720p.mkv", "Breaking.Bad.S01E01.720p.mkv",
800000000, 800000000,
datetime.now(), datetime.now(timezone.utc),
"series" "series"
), ),
] ]
@@ -564,8 +564,8 @@ class TestDuplicateReport:
# Create two duplicate groups with different sizes # Create two duplicate groups with different sizes
identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv") identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv")
files1 = [ files1 = [
VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(), "movie"), VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(), "movie"), VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(timezone.utc), "movie"),
] ]
quality1 = [ quality1 = [
{'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000}, {'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000},
@@ -574,8 +574,8 @@ class TestDuplicateReport:
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv") identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv")
files2 = [ files2 = [
VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(), "movie"), VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(timezone.utc), "movie"),
] ]
quality2 = [ quality2 = [
{'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000}, {'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000},
@@ -600,8 +600,8 @@ class TestDuplicateReport:
"""Sort order should use quality_comparison sizes when VideoFile sizes are zero.""" """Sort order should use quality_comparison sizes when VideoFile sizes are zero."""
identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv") identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv")
files1 = [ files1 = [
VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(), "movie"), VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(), "movie"), VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(timezone.utc), "movie"),
] ]
quality1 = [ quality1 = [
{"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000}, {"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000},
@@ -610,8 +610,8 @@ class TestDuplicateReport:
identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv") identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv")
files2 = [ files2 = [
VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(), "movie"), VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(), "movie"), VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(timezone.utc), "movie"),
] ]
quality2 = [ quality2 = [
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000}, {"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
@@ -632,12 +632,12 @@ class TestSummaryReport:
def test_generate_summary_report(self): def test_generate_summary_report(self):
"""Test generating summary report with various files.""" """Test generating summary report with various files."""
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(), "anime"), VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(), "other"), VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "other"),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
@@ -677,8 +677,8 @@ class TestSummaryReport:
def test_generate_summary_report_single_category(self): def test_generate_summary_report_single_category(self):
"""Test generating summary report with files in single category.""" """Test generating summary report with files in single category."""
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
] ]
library_root = Path("/mnt/nas/videos") library_root = Path("/mnt/nas/videos")
+9 -9
View File
@@ -5,7 +5,7 @@ Tests the complete workflow from analysis to report generation.
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timezone
from vlm.models import SeriesIdentity, VideoFile, MovieIdentity from vlm.models import SeriesIdentity, VideoFile, MovieIdentity
from vlm.analysis import analyze_series_completeness, detect_duplicates from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.reports import generate_completeness_report, generate_duplicate_report, generate_summary_report from vlm.reports import generate_completeness_report, generate_duplicate_report, generate_summary_report
@@ -59,7 +59,7 @@ class TestReportsIntegration:
Path("/movies/The.Matrix.1999.1080p.mkv"), Path("/movies/The.Matrix.1999.1080p.mkv"),
"The.Matrix.1999.1080p.mkv", "The.Matrix.1999.1080p.mkv",
2000000000, 2000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1920x1080", resolution="1920x1080",
codec="h264" codec="h264"
@@ -68,7 +68,7 @@ class TestReportsIntegration:
Path("/movies/The.Matrix.1999.720p.mkv"), Path("/movies/The.Matrix.1999.720p.mkv"),
"The.Matrix.1999.720p.mkv", "The.Matrix.1999.720p.mkv",
1000000000, 1000000000,
datetime.now(), datetime.now(timezone.utc),
"movie", "movie",
resolution="1280x720", resolution="1280x720",
codec="h264" codec="h264"
@@ -77,7 +77,7 @@ class TestReportsIntegration:
Path("/movies/Inception.2010.mkv"), Path("/movies/Inception.2010.mkv"),
"Inception.2010.mkv", "Inception.2010.mkv",
1500000000, 1500000000,
datetime.now(), datetime.now(timezone.utc),
"movie" "movie"
), ),
] ]
@@ -107,11 +107,11 @@ class TestReportsIntegration:
"""Test summary report generation with mixed file types.""" """Test summary report generation with mixed file types."""
# Create test files # Create test files
files = [ files = [
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(), "movie"), VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"), VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(), "anime"), VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
] ]
# Generate summary report # Generate summary report
+1 -1
View File
@@ -92,7 +92,7 @@ class TestLoadSaveState:
store = StateStore( store = StateStore(
states={}, states={},
version='1.0', version='1.0',
last_updated=datetime.now() last_updated=datetime.now(timezone.utc)
) )
save_state(store, state_path) save_state(store, state_path)