feat: add configurable category mappings for directory recognition
Allow users to configure multiple directory names per category (movie/series/anime) to support variations like "movies", "tv", "films". This enables proper categorization of files in directories that don't match the hardcoded singular forms, solving the issue where 635 files in "/mnt/Downloads/movies/" were incorrectly categorized as "other".
Configuration example:
categories:
movie: [movie, movies, films]
series: [series, tv, shows]
anime: [anime]
Changes include comprehensive validation, backward-compatible defaults, case-insensitive matching, and full test coverage (395 tests passing).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
aa0dc8ec47
commit
259e7506d7
@@ -0,0 +1,199 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Video Library Manager (VLM) is a Python CLI tool for managing personal video collections with a safety-first, human-in-the-loop approach. All file operations are reversible, require explicit confirmation, and generate reviewable execution plans before making changes.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Install package in editable mode
|
||||
uv pip install -e .
|
||||
|
||||
# Install with dev dependencies (pytest, hypothesis)
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_scanner.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Running the CLI
|
||||
```bash
|
||||
# Verify CLI works
|
||||
vlm --help
|
||||
|
||||
# Initialize config
|
||||
vlm config init
|
||||
|
||||
# Common workflow
|
||||
vlm scan # Discover files
|
||||
vlm parse # Extract identities
|
||||
vlm analyze # Detect gaps/duplicates
|
||||
vlm plan # Generate execution plan
|
||||
vlm execute # Dry-run (default)
|
||||
vlm execute --confirm # Actually execute
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Workflow
|
||||
VLM follows a read-first, multi-stage pipeline:
|
||||
1. **Scan** → discovers video files, extracts metadata via ffprobe (optional), saves to inventory.csv
|
||||
2. **Parse** → extracts titles/years/seasons/episodes from filenames, saves to identities.json
|
||||
3. **Analyze** → detects episode gaps and duplicates, saves to analysis.json
|
||||
4. **Plan** → generates reviewable execution plan (plan.json) with file operations
|
||||
5. **Execute** → performs file operations (dry-run by default, --confirm to execute)
|
||||
6. **Rollback** → reverses executed operations (best-effort)
|
||||
|
||||
### Module Organization
|
||||
- `cli.py` - Click-based CLI interface, command definitions, all user-facing commands
|
||||
- `scanner.py` - File discovery using system `find` command, metadata extraction via ffprobe
|
||||
- `parser.py` - Filename parsing using regex patterns (movies: title + year, series: SxxExx)
|
||||
- `analysis.py` - Completeness checking (episode gaps) and duplicate detection
|
||||
- `planner.py` - Execution plan generation with conflict detection
|
||||
- `executor.py` - File operations (move/rename/quarantine) with rollback logging
|
||||
- `quarantine.py` - Quarantine management with manifest tracking
|
||||
- `state.py` - File state tracking across workflow stages
|
||||
- `reports.py` - Report generation (inventory, completeness, duplicates, summary)
|
||||
- `config.py` - YAML configuration loading and validation
|
||||
- `models.py` - Dataclass definitions for all data structures
|
||||
- `logging_config.py` - Logging setup with fallback to console if file logging fails
|
||||
|
||||
### Key Data Structures
|
||||
All defined in `models.py`:
|
||||
- `VideoFile` - represents discovered video file with metadata
|
||||
- `MovieIdentity` / `SeriesIdentity` - parsed identity with confidence score
|
||||
- `ExecutionPlan` - collection of file operations with summary
|
||||
- `FileOperation` - single operation (move/rename/quarantine/no-op) with conflict detection
|
||||
- `RollbackLog` - log of executed operations for reversal
|
||||
- `QuarantineEntry` - quarantined file with original location
|
||||
- `FileState` - workflow state (reviewed/ignored/planned/executed/quarantined)
|
||||
|
||||
### File Categorization
|
||||
Based on top-level directory within library root, matched against configured category mappings (case-insensitive).
|
||||
|
||||
Files in unmapped directories are categorized as "other" and skipped by planner/quarantine operations.
|
||||
|
||||
### Parsing Patterns (Hardcoded)
|
||||
**Movies** (high confidence):
|
||||
- `{title} ({year})`
|
||||
- `{title}.{year}`
|
||||
|
||||
**Series** (high confidence):
|
||||
- `S{season:02d}E{episode:02d}`
|
||||
- `{season}x{episode}`
|
||||
|
||||
Patterns are hardcoded in parser.py, not user-configurable.
|
||||
|
||||
### Configuration
|
||||
Default location: `~/.vlm/config.yaml`
|
||||
|
||||
Key settings:
|
||||
- `library_root` - root directory to scan (required)
|
||||
- `video_extensions` - list of extensions to recognize
|
||||
- `templates.movie_dir` / `templates.series_dir` - directory structure templates
|
||||
- `templates.movie_filename` / `templates.series_filename` - filename templates
|
||||
- `quarantine_dir` - name of quarantine directory (default: `.quarantine`)
|
||||
- `log_level` - logging verbosity
|
||||
- `categories` - mapping of category names to directory name lists
|
||||
|
||||
### Category Mappings
|
||||
|
||||
Categories are determined by matching the top-level directory name against configured mappings:
|
||||
|
||||
**Default mappings:**
|
||||
```yaml
|
||||
categories:
|
||||
movie: [movie]
|
||||
series: [series]
|
||||
anime: [anime]
|
||||
```
|
||||
|
||||
**Custom mappings** support multiple directory names per category:
|
||||
```yaml
|
||||
categories:
|
||||
movie: [movie, movies, films]
|
||||
series: [series, tv, shows, television]
|
||||
anime: [anime]
|
||||
```
|
||||
|
||||
This allows files in `/library/movies/` or `/library/films/` to be recognized as the "movie" category. Directory matching is case-insensitive.
|
||||
|
||||
**Migration Note:** If you have existing directories with non-standard names (like "movies" or "tv"), update your config.yaml and re-run `vlm scan` to fix categorization. No files will be moved.
|
||||
|
||||
### File Discovery
|
||||
Uses system `find` command for speed, falls back to Python recursion if unavailable. Hidden paths (starting with `.`) are skipped automatically.
|
||||
|
||||
### Timestamp Handling
|
||||
All timestamps stored in UTC using ISO 8601 format (`YYYY-MM-DDTHH:MM:SS`). The scanner normalizes naive timestamps to UTC using system timezone.
|
||||
|
||||
### Logging
|
||||
Logs to `~/.vlm/vlm.log` by default. If log directory is unwritable, falls back to console-only logging and continues execution (does not fail).
|
||||
|
||||
### Error Handling
|
||||
- Configuration errors: display helpful message, create default config, continue
|
||||
- Missing ffprobe: skip metadata extraction, log debug message, continue
|
||||
- File access errors: log error, skip file, continue scanning
|
||||
- Invalid YAML: display error, use default config
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Framework: pytest with hypothesis for property-based tests
|
||||
- Test file naming: `test_*.py`
|
||||
- Test function naming: `test_*`
|
||||
- Test class naming: `Test*`
|
||||
- Use `CliRunner` for CLI integration tests
|
||||
- Prefer narrow unit tests for module logic plus targeted CLI integration tests
|
||||
- Add tests with each behavior change, including error paths and edge cases
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
### Safety Protocol
|
||||
- NEVER permanently delete files - use quarantine instead
|
||||
- All file operations create rollback logs when executed with --confirm
|
||||
- Execution plans detect destination conflicts and mark operations
|
||||
- Default mode is dry-run; --confirm required for actual execution
|
||||
- Quarantine is reversible via restore command
|
||||
|
||||
### Anime Handling
|
||||
Anime files are discovered and categorized but NOT parsed in v1 (deferred for future implementation).
|
||||
|
||||
### State Management
|
||||
State tracking is optional and allows marking files as reviewed/ignored/planned/executed/quarantined. State is persisted to `~/.vlm/state.json`.
|
||||
|
||||
### Quarantine Constraints
|
||||
Only movie and series files can be quarantined in v1 (anime and other categories rejected).
|
||||
|
||||
### Template Variables
|
||||
Available for path/filename templates:
|
||||
- Movies: `{title}`, `{year}`, `{ext}`
|
||||
- Series: `{title}`, `{season}`, `{episode}`, `{ext}`
|
||||
|
||||
Format specifiers like `{season:02d}` are supported.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Python 3.10+ idioms
|
||||
- 4-space indentation
|
||||
- PEP 8 naming: `snake_case` for functions/variables, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants
|
||||
- Type hints for public functions and non-trivial internal APIs
|
||||
- Modules focused on single responsibility
|
||||
- No formatter/linter enforced - maintain consistency with existing files
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
- Clear, imperative commit subjects (e.g., "fix logging fallback for unwritable log dir")
|
||||
- Keep commits focused - avoid mixing refactors and behavior changes
|
||||
- Include co-author tag: `Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
A Python-based CLI tool for managing personal video collections with a safety-first, human-in-the-loop approach.
|
||||
|
||||
## Features
|
||||
|
||||
- **Safety-First Design**: All file operations are reversible with rollback support
|
||||
- **Human-in-the-Loop**: Explicit confirmation required before making any changes
|
||||
- **Comprehensive Analysis**: Detect episode gaps and duplicate files
|
||||
- **Rich Metadata**: Extract video resolution, codec, duration, and bitrate
|
||||
- **Flexible Organization**: Customizable directory structure and naming templates
|
||||
- **State Tracking**: Track file status throughout the workflow
|
||||
- **Detailed Reporting**: Generate inventory, completeness, and duplicate reports
|
||||
|
||||
## Installation
|
||||
|
||||
This project uses `uv` for Python package management. To install:
|
||||
@@ -14,26 +24,610 @@ uv pip install -e .
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
### 1. Initialize Configuration
|
||||
|
||||
First, create a configuration file:
|
||||
|
||||
```bash
|
||||
vlm --help
|
||||
vlm config init
|
||||
```
|
||||
|
||||
Reports and inventory exports store `modified_timestamp` values in UTC (`YYYY-MM-DDTHH:MM:SS`).
|
||||
This creates `~/.vlm/config.yaml`. Edit it to set your library root:
|
||||
|
||||
If file logging cannot be initialized (for example, unwritable log directory), VLM falls back to console logging and continues running.
|
||||
```yaml
|
||||
library_root: "/mnt/Downloads" # Change this to your video library path
|
||||
```
|
||||
|
||||
### 2. Scan Your Library
|
||||
|
||||
Discover all video files in your library:
|
||||
|
||||
```bash
|
||||
vlm scan
|
||||
```
|
||||
|
||||
This creates `inventory.csv` with all discovered files and their metadata.
|
||||
|
||||
**What happens:**
|
||||
- Discovers video files using the system `find` command
|
||||
- Extracts file metadata (size, modification time)
|
||||
- Categorizes files based on directory structure (movie/series/anime/other)
|
||||
- Extracts video metadata using ffprobe (if available)
|
||||
- Saves results to `inventory.csv`
|
||||
|
||||
### 3. Parse Filenames
|
||||
|
||||
Extract titles, years, seasons, and episodes from filenames:
|
||||
|
||||
```bash
|
||||
vlm parse
|
||||
```
|
||||
|
||||
This creates `identities.json` with parsed information.
|
||||
|
||||
**What it extracts:**
|
||||
- **Movies**: Title and year (e.g., "Inception (2010)")
|
||||
- **Series**: Title, season, and episode numbers (e.g., "Breaking Bad S01E01")
|
||||
- **Confidence scores**: Indicates parsing reliability
|
||||
|
||||
### 4. Analyze Your Library
|
||||
|
||||
Detect episode gaps and duplicates:
|
||||
|
||||
```bash
|
||||
vlm analyze
|
||||
```
|
||||
|
||||
This creates `analysis.json` with:
|
||||
- Series with missing episodes
|
||||
- Duplicate files with quality comparison
|
||||
|
||||
### 5. Generate Execution Plan
|
||||
|
||||
Create a reviewable plan of file operations:
|
||||
|
||||
```bash
|
||||
vlm plan
|
||||
```
|
||||
|
||||
This creates `plan.json` with proposed operations (move, rename, quarantine).
|
||||
|
||||
**Review the plan** by opening `plan.json` in your editor. You can edit it if needed.
|
||||
|
||||
### 6. Execute (Dry-Run First)
|
||||
|
||||
Preview what will happen without making changes:
|
||||
|
||||
```bash
|
||||
vlm execute
|
||||
```
|
||||
|
||||
When ready to actually move/rename files:
|
||||
|
||||
```bash
|
||||
vlm execute --confirm
|
||||
```
|
||||
|
||||
**Important**: This creates a rollback log in `~/.vlm/rollback/` for reverting changes.
|
||||
|
||||
### 7. Rollback (If Needed)
|
||||
|
||||
If you need to undo the operations:
|
||||
|
||||
```bash
|
||||
vlm rollback
|
||||
```
|
||||
|
||||
This uses the most recent rollback log to restore files to their original locations.
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
Here's a complete workflow from start to finish:
|
||||
|
||||
```bash
|
||||
# 1. Initialize configuration
|
||||
vlm config init
|
||||
# Edit ~/.vlm/config.yaml to set your library_root
|
||||
|
||||
# 2. Scan your library
|
||||
vlm scan
|
||||
# Output: inventory.csv with 1234 files discovered
|
||||
|
||||
# 3. Parse filenames
|
||||
vlm parse
|
||||
# Output: identities.json with parsed titles and episodes
|
||||
|
||||
# 4. Analyze for gaps and duplicates
|
||||
vlm analyze
|
||||
# Output: analysis.json with 5 series with gaps, 12 duplicate groups
|
||||
|
||||
# 5. Generate execution plan
|
||||
vlm plan
|
||||
# Output: plan.json with 456 operations proposed
|
||||
|
||||
# 6. Review the plan
|
||||
cat plan.json | less
|
||||
# or open in your editor
|
||||
|
||||
# 7. Dry-run to preview
|
||||
vlm execute
|
||||
# Shows what would happen without making changes
|
||||
|
||||
# 8. Execute with confirmation
|
||||
vlm execute --confirm
|
||||
# Actually performs the file operations
|
||||
|
||||
# 9. If needed, rollback
|
||||
vlm rollback
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Initialize config file
|
||||
vlm config init
|
||||
|
||||
# Show current configuration
|
||||
vlm config show
|
||||
|
||||
# Validate configuration
|
||||
vlm config validate
|
||||
```
|
||||
|
||||
### Scanning
|
||||
|
||||
```bash
|
||||
# Scan with default output (inventory.csv)
|
||||
vlm scan
|
||||
|
||||
# Scan with custom output file
|
||||
vlm scan --output my_library.csv
|
||||
```
|
||||
|
||||
**Scan behavior:**
|
||||
- Hidden paths are skipped (any component starting with `.`)
|
||||
- File matching uses configured `video_extensions` (default: `.mp4`, `.mkv`, `.avi`, `.mov`, `.wmv`, `.flv`, `.webm`, `.m4v`)
|
||||
- If `ffprobe` is available, resolution/codec/duration/bitrate are included
|
||||
- Timestamps stored in UTC format (`YYYY-MM-DDTHH:MM:SS`)
|
||||
|
||||
### Parsing
|
||||
|
||||
```bash
|
||||
# Parse with default files
|
||||
vlm parse
|
||||
|
||||
# Parse with custom input/output
|
||||
vlm parse --input my_inventory.csv --output my_identities.json
|
||||
```
|
||||
|
||||
### Analysis
|
||||
|
||||
```bash
|
||||
# Analyze with default files
|
||||
vlm analyze
|
||||
|
||||
# Analyze with custom files
|
||||
vlm analyze --input my_identities.json --output my_analysis.json
|
||||
```
|
||||
|
||||
### Planning
|
||||
|
||||
```bash
|
||||
# Generate plan with default files
|
||||
vlm plan
|
||||
|
||||
# Generate plan with custom files
|
||||
vlm plan --input my_identities.json --output my_plan.json
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
# Dry-run (preview only, no changes)
|
||||
vlm execute
|
||||
|
||||
# Execute with confirmation (actually makes changes)
|
||||
vlm execute --confirm
|
||||
|
||||
# Use custom plan file
|
||||
vlm execute --plan my_plan.json --confirm
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
# Rollback using most recent log
|
||||
vlm rollback
|
||||
|
||||
# Rollback using specific log
|
||||
vlm rollback --log ~/.vlm/rollback/rollback_<uuid>.json
|
||||
```
|
||||
|
||||
### Quarantine Management
|
||||
|
||||
```bash
|
||||
# List quarantined files
|
||||
vlm quarantine list
|
||||
|
||||
# List by category
|
||||
vlm quarantine list --category movie
|
||||
|
||||
# Quarantine a file
|
||||
vlm quarantine add /path/to/file.mkv --reason "duplicate"
|
||||
|
||||
# Restore from quarantine
|
||||
vlm quarantine restore /path/to/.quarantine/file.mkv
|
||||
```
|
||||
|
||||
### Reporting
|
||||
|
||||
```bash
|
||||
# Generate inventory report (text format)
|
||||
vlm report inventory
|
||||
|
||||
# Generate in different formats
|
||||
vlm report inventory --format csv
|
||||
vlm report inventory --format json --output inventory_report.json
|
||||
|
||||
# Generate completeness report (series with gaps)
|
||||
vlm report completeness
|
||||
|
||||
# Generate duplicates report
|
||||
vlm report duplicates
|
||||
|
||||
# Generate summary statistics
|
||||
vlm report summary
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```bash
|
||||
# Show state for a file
|
||||
vlm state show /path/to/file.mkv
|
||||
|
||||
# Set state for a file
|
||||
vlm state set /path/to/file.mkv --status reviewed
|
||||
vlm state set /path/to/file.mkv --status ignored --reason "duplicate"
|
||||
|
||||
# Query files by status
|
||||
vlm state query --status ignored
|
||||
|
||||
# Clear state for a file
|
||||
vlm state clear /path/to/file.mkv
|
||||
```
|
||||
|
||||
## Configuration File
|
||||
|
||||
The configuration file (`~/.vlm/config.yaml`) controls VLM's behavior:
|
||||
|
||||
```yaml
|
||||
# Required: Root directory of your video library
|
||||
library_root: "/mnt/nas/videos"
|
||||
|
||||
# Video file extensions to recognize
|
||||
video_extensions:
|
||||
- .mp4
|
||||
- .mkv
|
||||
- .avi
|
||||
- .mov
|
||||
- .wmv
|
||||
- .flv
|
||||
- .webm
|
||||
- .m4v
|
||||
|
||||
# Directory structure templates
|
||||
templates:
|
||||
movie_dir: "movie/{title} ({year})/"
|
||||
series_dir: "series/{title}/Season {season:02d}/"
|
||||
movie_filename: "{title} ({year}){ext}"
|
||||
series_filename: "S{season:02d}E{episode:02d}{ext}"
|
||||
|
||||
# Quarantine directory name (relative to category root)
|
||||
quarantine_dir: ".quarantine"
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
log_level: "INFO"
|
||||
|
||||
# Category mappings (directory name to category)
|
||||
categories:
|
||||
movie: [movie, movies, films]
|
||||
series: [series, tv, shows]
|
||||
anime: [anime]
|
||||
```
|
||||
|
||||
### Template Variables
|
||||
|
||||
**Movies:**
|
||||
- `{title}` - Movie title
|
||||
- `{year}` - Release year
|
||||
- `{ext}` - File extension
|
||||
|
||||
**Series:**
|
||||
- `{title}` - Series title
|
||||
- `{season}` - Season number
|
||||
- `{episode}` - Episode number
|
||||
- `{ext}` - File extension
|
||||
|
||||
Format specifiers are supported (e.g., `{season:02d}` for zero-padded numbers).
|
||||
|
||||
## File Categorization
|
||||
|
||||
VLM categorizes files based on the top-level directory in your library, matched against configured category mappings. By default:
|
||||
|
||||
- `movie/` → "movie"
|
||||
- `series/` → "series"
|
||||
- `anime/` → "anime"
|
||||
- Anything else → "other"
|
||||
|
||||
You can customize these mappings to support multiple directory names per category:
|
||||
|
||||
```yaml
|
||||
categories:
|
||||
movie: [movie, movies, films]
|
||||
series: [series, tv, shows, television]
|
||||
anime: [anime]
|
||||
```
|
||||
|
||||
This allows directories like `/library/movies/` or `/library/tv/` to be correctly categorized. Directory matching is case-insensitive.
|
||||
|
||||
Example:
|
||||
```
|
||||
/mnt/Downloads/
|
||||
├── movies/ # Category: movie (plural form)
|
||||
│ └── Inception (2010).mkv
|
||||
├── tv/ # Category: series (tv variant)
|
||||
│ └── Breaking Bad S01E01.mkv
|
||||
├── anime/
|
||||
│ └── Attack on Titan E01.mkv # Category: anime
|
||||
└── documentaries/
|
||||
└── Planet Earth.mkv # Category: other
|
||||
```
|
||||
|
||||
**Migration Note:** If you have existing directories with non-standard names (like "movies" or "tv"), update your `categories` configuration in `config.yaml` and re-run `vlm scan` to fix categorization. No files will be moved.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: First Time Setup
|
||||
|
||||
```bash
|
||||
# 1. Install
|
||||
uv pip install -e .
|
||||
|
||||
# 2. Initialize config
|
||||
vlm config init
|
||||
# Edit ~/.vlm/config.yaml
|
||||
|
||||
# 3. Scan library
|
||||
vlm scan
|
||||
|
||||
# 4. Check what was found
|
||||
vlm report summary
|
||||
```
|
||||
|
||||
### Scenario 2: Finding Missing Episodes
|
||||
|
||||
```bash
|
||||
# 1. Scan and parse
|
||||
vlm scan
|
||||
vlm parse
|
||||
|
||||
# 2. Analyze completeness
|
||||
vlm analyze
|
||||
|
||||
# 3. View report
|
||||
vlm report completeness
|
||||
```
|
||||
|
||||
### Scenario 3: Finding and Removing Duplicates
|
||||
|
||||
```bash
|
||||
# 1. Scan and parse
|
||||
vlm scan
|
||||
vlm parse
|
||||
|
||||
# 2. Analyze for duplicates
|
||||
vlm analyze
|
||||
|
||||
# 3. View duplicates with quality comparison
|
||||
vlm report duplicates
|
||||
|
||||
# 4. Manually quarantine lower quality files
|
||||
vlm quarantine add /path/to/lower/quality/file.mkv --reason "duplicate - lower quality"
|
||||
|
||||
# 5. Or generate plan and let VLM suggest operations
|
||||
vlm plan
|
||||
vlm execute
|
||||
```
|
||||
|
||||
### Scenario 4: Reorganizing Your Library
|
||||
|
||||
```bash
|
||||
# 1. Customize templates in config
|
||||
# Edit ~/.vlm/config.yaml to set your preferred structure
|
||||
|
||||
# 2. Scan and parse
|
||||
vlm scan
|
||||
vlm parse
|
||||
|
||||
# 3. Generate plan
|
||||
vlm plan
|
||||
|
||||
# 4. Review plan.json carefully
|
||||
|
||||
# 5. Dry-run to preview
|
||||
vlm execute
|
||||
|
||||
# 6. Execute when satisfied
|
||||
vlm execute --confirm
|
||||
|
||||
# 7. If something goes wrong, rollback
|
||||
vlm rollback
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
VLM logs to `~/.vlm/vlm.log` by default. If the log directory is unwritable, it falls back to console logging and continues running.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### ffprobe Not Found
|
||||
|
||||
If you see warnings about ffprobe:
|
||||
|
||||
```bash
|
||||
# Install ffmpeg (includes ffprobe)
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# macOS
|
||||
brew install ffmpeg
|
||||
|
||||
# Windows
|
||||
# Download from https://ffmpeg.org/download.html
|
||||
```
|
||||
|
||||
Without ffprobe, VLM still works but won't extract video metadata (resolution, codec, etc.).
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
If VLM can't find your config:
|
||||
|
||||
```bash
|
||||
# Check config location
|
||||
ls -la ~/.vlm/config.yaml
|
||||
|
||||
# Reinitialize if needed
|
||||
vlm config init
|
||||
|
||||
# Validate current config
|
||||
vlm config validate
|
||||
```
|
||||
|
||||
### Permission Errors
|
||||
|
||||
If you can't access certain files:
|
||||
|
||||
```bash
|
||||
# Check permissions
|
||||
ls -la /path/to/library
|
||||
|
||||
# VLM logs errors but continues scanning other files
|
||||
# Check log file for details
|
||||
cat ~/.vlm/vlm.log
|
||||
```
|
||||
|
||||
### Rollback Fails
|
||||
|
||||
Rollback is **best-effort** and may fail if:
|
||||
- Files have been moved/deleted since execution
|
||||
- Destination paths are occupied
|
||||
- Permissions have changed
|
||||
|
||||
Always keep backups of important files!
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **No Permanent Deletion**: VLM never deletes files. Use quarantine instead.
|
||||
- **Dry-Run Default**: Execute command defaults to dry-run mode
|
||||
- **Explicit Confirmation**: `--confirm` flag required to make actual changes
|
||||
- **Rollback Logs**: All operations logged for reversal
|
||||
- **Conflict Detection**: Plan generation detects destination conflicts
|
||||
- **Best-Effort Rollback**: Attempt to restore files to original locations
|
||||
|
||||
## Limitations
|
||||
|
||||
### Version 1.0
|
||||
|
||||
- **Anime parsing not implemented**: Anime files are discovered and categorized but not parsed
|
||||
- **Movie/Series only for quarantine**: Only movie and series categories can be quarantined
|
||||
- **Hardcoded parsing patterns**: Filename patterns are not user-configurable
|
||||
- **Local files only**: Designed for local or mounted network storage
|
||||
- **Best-effort rollback**: Rollback may not succeed if files have been modified
|
||||
|
||||
## Development
|
||||
|
||||
Run tests:
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_scanner.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=vlm tests/
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Python 3.10+ idioms
|
||||
- PEP 8 naming conventions
|
||||
- Type hints for public functions
|
||||
- 4-space indentation
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/vlm/
|
||||
├── cli.py # Click-based CLI interface
|
||||
├── scanner.py # File discovery and metadata extraction
|
||||
├── parser.py # Filename parsing (titles, years, episodes)
|
||||
├── analysis.py # Completeness and duplicate detection
|
||||
├── planner.py # Execution plan generation
|
||||
├── executor.py # File operations and rollback
|
||||
├── quarantine.py # Quarantine management
|
||||
├── state.py # File state tracking
|
||||
├── reports.py # Report generation
|
||||
├── config.py # Configuration management
|
||||
├── models.py # Data structures
|
||||
└── logging_config.py # Logging setup
|
||||
|
||||
tests/
|
||||
├── test_scanner.py # Scanner tests
|
||||
├── test_parser.py # Parser tests
|
||||
├── test_analysis.py # Analysis tests
|
||||
├── test_planner.py # Planner tests
|
||||
└── ... # More test files
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- uv (Python package manager)
|
||||
- ffmpeg (optional, for video metadata extraction)
|
||||
- **Python** >= 3.10
|
||||
- **uv** (Python package manager)
|
||||
- **ffmpeg** (optional, for video metadata extraction)
|
||||
- **find** command (standard on Unix-like systems)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Write tests for new features
|
||||
2. Follow existing code style
|
||||
3. Update documentation
|
||||
4. Add clear commit messages
|
||||
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- [Click](https://click.palletsprojects.com/) - CLI framework
|
||||
- [PyYAML](https://pyyaml.org/) - Configuration parsing
|
||||
- [pytest](https://pytest.org/) - Testing framework
|
||||
- [hypothesis](https://hypothesis.readthedocs.io/) - Property-based testing
|
||||
|
||||
## Support
|
||||
|
||||
- Report issues: [GitHub Issues](https://github.com/yourusername/video-library-manager/issues)
|
||||
- Documentation: See CLAUDE.md for architecture details
|
||||
- Logs: Check `~/.vlm/vlm.log` for detailed information
|
||||
|
||||
+65
-3
@@ -19,6 +19,7 @@ class Config:
|
||||
series_filename_template: Filename template for series (e.g., "S{season:02d}E{episode:02d}{ext}")
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
quarantine_dir: Quarantine directory name relative to category root (e.g., ".quarantine")
|
||||
categories: Mapping of category names to directory name lists for file categorization
|
||||
"""
|
||||
library_root: Path
|
||||
video_extensions: list[str] = field(default_factory=lambda: [
|
||||
@@ -30,6 +31,11 @@ class Config:
|
||||
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
|
||||
log_level: str = "INFO"
|
||||
quarantine_dir: str = ".quarantine"
|
||||
categories: dict[str, list[str]] = field(default_factory=lambda: {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
@@ -80,6 +86,13 @@ def load_config(path: Path) -> Config:
|
||||
quarantine_dir = data.get('quarantine_dir', '.quarantine')
|
||||
log_level = data.get('log_level', 'INFO')
|
||||
|
||||
# Extract categories configuration
|
||||
categories = data.get('categories', {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
video_extensions=video_extensions,
|
||||
@@ -88,7 +101,8 @@ def load_config(path: Path) -> Config:
|
||||
movie_filename_template=movie_filename_template,
|
||||
series_filename_template=series_filename_template,
|
||||
log_level=log_level,
|
||||
quarantine_dir=quarantine_dir
|
||||
quarantine_dir=quarantine_dir,
|
||||
categories=categories
|
||||
)
|
||||
|
||||
|
||||
@@ -110,7 +124,12 @@ def create_default_config(path: Path) -> Config:
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine"
|
||||
quarantine_dir=".quarantine",
|
||||
categories={
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
|
||||
# Create YAML content
|
||||
@@ -124,7 +143,8 @@ def create_default_config(path: Path) -> Config:
|
||||
'series_filename': default_config.series_filename_template
|
||||
},
|
||||
'quarantine_dir': default_config.quarantine_dir,
|
||||
'log_level': default_config.log_level
|
||||
'log_level': default_config.log_level,
|
||||
'categories': default_config.categories
|
||||
}
|
||||
|
||||
# Ensure parent directory exists
|
||||
@@ -205,4 +225,46 @@ def validate_config(config: Config) -> list[str]:
|
||||
elif config.quarantine_dir.startswith('/') or config.quarantine_dir.startswith('\\'):
|
||||
errors.append("quarantine_dir must be relative to category root, not absolute")
|
||||
|
||||
# Validate categories
|
||||
if not config.categories:
|
||||
errors.append("categories cannot be empty")
|
||||
elif not isinstance(config.categories, dict):
|
||||
errors.append("categories must be a dictionary")
|
||||
else:
|
||||
# Check required category keys exist
|
||||
required_categories = {"movie", "series", "anime"}
|
||||
missing = required_categories - set(config.categories.keys())
|
||||
if missing:
|
||||
errors.append(f"categories must include keys: {sorted(missing)}")
|
||||
|
||||
# Validate each category's directory list and check for duplicates
|
||||
seen_dirs = {}
|
||||
for category, dir_list in config.categories.items():
|
||||
if not isinstance(dir_list, list):
|
||||
errors.append(f"categories['{category}'] must be a list")
|
||||
continue
|
||||
|
||||
if not dir_list:
|
||||
errors.append(f"categories['{category}'] cannot be empty")
|
||||
continue
|
||||
|
||||
for dir_name in dir_list:
|
||||
if not isinstance(dir_name, str):
|
||||
errors.append(f"categories['{category}'] must contain strings")
|
||||
break
|
||||
|
||||
if not dir_name.strip():
|
||||
errors.append(f"categories['{category}'] contains empty directory name")
|
||||
break
|
||||
|
||||
# Check for duplicates (case-insensitive)
|
||||
dir_lower = dir_name.lower()
|
||||
if dir_lower in seen_dirs:
|
||||
errors.append(
|
||||
f"Duplicate directory name '{dir_name}' in categories "
|
||||
f"'{category}' and '{seen_dirs[dir_lower]}'"
|
||||
)
|
||||
else:
|
||||
seen_dirs[dir_lower] = category
|
||||
|
||||
return errors
|
||||
|
||||
+123
-33
@@ -1,8 +1,8 @@
|
||||
"""Inventory scanner for discovering and cataloging video files.
|
||||
|
||||
This module implements the core scanning functionality for the Video Library Manager,
|
||||
including recursive directory traversal, file filtering, metadata extraction, and
|
||||
categorization based on directory structure.
|
||||
including file discovery via `find`, metadata extraction, and categorization based on
|
||||
directory structure.
|
||||
"""
|
||||
|
||||
import csv
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Callable, Iterator, Optional
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import VideoFile
|
||||
@@ -27,7 +27,11 @@ def _normalize_to_utc(timestamp: datetime) -> datetime:
|
||||
return timestamp.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def scan_library(root: Path, config: Config) -> list[VideoFile]:
|
||||
def scan_library(
|
||||
root: Path,
|
||||
config: Config,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None
|
||||
) -> list[VideoFile]:
|
||||
"""Recursively scan library for video files.
|
||||
|
||||
Discovers all video files matching configured extensions within the library root,
|
||||
@@ -57,11 +61,24 @@ def scan_library(root: Path, config: Config) -> list[VideoFile]:
|
||||
|
||||
video_files = []
|
||||
file_count = 0
|
||||
# Recursively scan directory tree
|
||||
for video_file in _scan_directory_recursive(root, config, root):
|
||||
discovered_paths = _discover_video_paths(root, config.video_extensions)
|
||||
total_paths = len(discovered_paths)
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(0, total_paths)
|
||||
|
||||
for index, file_path in enumerate(discovered_paths, start=1):
|
||||
video_file = _create_video_file(file_path, root, config.categories)
|
||||
if video_file is None:
|
||||
if progress_callback is not None:
|
||||
progress_callback(index, total_paths)
|
||||
continue
|
||||
video_files.append(video_file)
|
||||
file_count += 1
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(index, total_paths)
|
||||
|
||||
if file_count % 100 == 0:
|
||||
logger.debug(f"Scanned {file_count} files so far...")
|
||||
|
||||
@@ -69,20 +86,87 @@ def scan_library(root: Path, config: Config) -> list[VideoFile]:
|
||||
return video_files
|
||||
|
||||
|
||||
def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Discover matching video files under root.
|
||||
|
||||
Uses the system `find` command for traversal speed and falls back to Python
|
||||
recursion if `find` is unavailable.
|
||||
"""
|
||||
try:
|
||||
return _discover_video_paths_with_find(root, video_extensions)
|
||||
except FileNotFoundError:
|
||||
logger.warning("`find` command not available - falling back to Python recursion")
|
||||
return _discover_video_paths_recursive(root, video_extensions)
|
||||
|
||||
|
||||
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Discover matching video files using the system `find` command."""
|
||||
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
|
||||
if not normalized_extensions:
|
||||
return []
|
||||
|
||||
command: list[str] = ["find", str(root), "-type", "f", "("]
|
||||
for index, extension in enumerate(normalized_extensions):
|
||||
if index > 0:
|
||||
command.append("-o")
|
||||
command.extend(["-iname", f"*{extension}"])
|
||||
command.extend([")", "-print0"])
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="replace").strip()
|
||||
if stderr_text:
|
||||
logger.warning(f"find reported issues while scanning: {stderr_text}")
|
||||
|
||||
discovered_paths: list[Path] = []
|
||||
for path_bytes in stdout.split(b"\0"):
|
||||
if not path_bytes:
|
||||
continue
|
||||
file_path = Path(os.fsdecode(path_bytes))
|
||||
if _is_hidden_path(file_path, root):
|
||||
continue
|
||||
discovered_paths.append(file_path)
|
||||
|
||||
return discovered_paths
|
||||
|
||||
|
||||
def _discover_video_paths_recursive(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Fallback discovery using Python directory traversal."""
|
||||
discovered_paths: list[Path] = []
|
||||
for file_path in _scan_directory_recursive(root, video_extensions):
|
||||
if _is_hidden_path(file_path, root):
|
||||
continue
|
||||
discovered_paths.append(file_path)
|
||||
return discovered_paths
|
||||
|
||||
|
||||
def _is_hidden_path(file_path: Path, library_root: Path) -> bool:
|
||||
"""Return True when any path component under root starts with a dot."""
|
||||
try:
|
||||
relative_path = file_path.relative_to(library_root)
|
||||
except ValueError:
|
||||
return file_path.name.startswith(".")
|
||||
return any(part.startswith(".") for part in relative_path.parts)
|
||||
|
||||
|
||||
def _scan_directory_recursive(
|
||||
directory: Path,
|
||||
config: Config,
|
||||
library_root: Path
|
||||
) -> list[VideoFile]:
|
||||
video_extensions: list[str]
|
||||
) -> Iterator[Path]:
|
||||
"""Recursively scan a directory for video files.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
config: Configuration object
|
||||
library_root: Root of the library (for categorization)
|
||||
video_extensions: List of configured video extensions
|
||||
|
||||
Yields:
|
||||
VideoFile objects for each discovered video file
|
||||
File paths for each discovered video file
|
||||
"""
|
||||
try:
|
||||
# Use os.scandir for efficient directory traversal
|
||||
@@ -96,15 +180,13 @@ def _scan_directory_recursive(
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
# Check if file has video extension
|
||||
file_path = Path(entry.path)
|
||||
if _is_video_file(file_path, config.video_extensions):
|
||||
video_file = _create_video_file(file_path, library_root)
|
||||
if video_file:
|
||||
yield video_file
|
||||
if _is_video_file(file_path, video_extensions):
|
||||
yield file_path
|
||||
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
# Recursively scan subdirectory
|
||||
subdir_path = Path(entry.path)
|
||||
yield from _scan_directory_recursive(subdir_path, config, library_root)
|
||||
yield from _scan_directory_recursive(subdir_path, video_extensions)
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
# Handle inaccessible files/directories gracefully
|
||||
@@ -130,7 +212,11 @@ def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool:
|
||||
return file_extension in [ext.lower() for ext in video_extensions]
|
||||
|
||||
|
||||
def _create_video_file(file_path: Path, library_root: Path) -> Optional[VideoFile]:
|
||||
def _create_video_file(
|
||||
file_path: Path,
|
||||
library_root: Path,
|
||||
categories_config: dict[str, list[str]]
|
||||
) -> Optional[VideoFile]:
|
||||
"""Create VideoFile object from file path.
|
||||
|
||||
Extracts file metadata and categorizes based on directory structure.
|
||||
@@ -139,6 +225,7 @@ def _create_video_file(file_path: Path, library_root: Path) -> Optional[VideoFil
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
library_root: Root of the library (for categorization)
|
||||
categories_config: Mapping of category names to directory name lists
|
||||
|
||||
Returns:
|
||||
VideoFile object or None if file cannot be accessed
|
||||
@@ -150,7 +237,7 @@ def _create_video_file(file_path: Path, library_root: Path) -> Optional[VideoFil
|
||||
modified_timestamp = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||
|
||||
# Categorize based on directory structure
|
||||
category = categorize_file(file_path, library_root)
|
||||
category = categorize_file(file_path, library_root, categories_config)
|
||||
|
||||
# Extract video metadata using ffprobe (optional, non-blocking)
|
||||
video_metadata = extract_metadata(file_path)
|
||||
@@ -173,18 +260,21 @@ def _create_video_file(file_path: Path, library_root: Path) -> Optional[VideoFil
|
||||
return None
|
||||
|
||||
|
||||
def categorize_file(file_path: Path, library_root: Path) -> str:
|
||||
def categorize_file(
|
||||
file_path: Path,
|
||||
library_root: Path,
|
||||
categories_config: dict[str, list[str]]
|
||||
) -> str:
|
||||
"""Determine category based on directory structure.
|
||||
|
||||
Categories are determined by the top-level directory within the library root:
|
||||
- movie/ -> "movie"
|
||||
- series/ -> "series"
|
||||
- anime/ -> "anime"
|
||||
- other/ or anything else -> "other"
|
||||
Categories are determined by the top-level directory within the library root,
|
||||
matched against configured category mappings.
|
||||
|
||||
Args:
|
||||
file_path: Path to video file
|
||||
library_root: Root of the library
|
||||
categories_config: Mapping of category names to directory name lists
|
||||
Example: {"movie": ["movie", "movies"], "series": ["series", "tv"]}
|
||||
|
||||
Returns:
|
||||
Category string: "movie", "series", "anime", or "other"
|
||||
@@ -198,14 +288,14 @@ def categorize_file(file_path: Path, library_root: Path) -> str:
|
||||
if len(parts) > 0:
|
||||
top_level_dir = parts[0].lower()
|
||||
|
||||
if top_level_dir == "movie":
|
||||
return "movie"
|
||||
elif top_level_dir == "series":
|
||||
return "series"
|
||||
elif top_level_dir == "anime":
|
||||
return "anime"
|
||||
else:
|
||||
return "other"
|
||||
# Check against configured category mappings
|
||||
for category, dir_names in categories_config.items():
|
||||
# Case-insensitive matching
|
||||
if top_level_dir in [name.lower() for name in dir_names]:
|
||||
return category
|
||||
|
||||
# No match found
|
||||
return "other"
|
||||
else:
|
||||
# File is directly in library root
|
||||
return "other"
|
||||
|
||||
@@ -35,6 +35,30 @@ class TestConfig:
|
||||
assert config.movie_template == "movies/{title}-{year}/"
|
||||
assert config.log_level == "DEBUG"
|
||||
|
||||
def test_config_default_categories(self):
|
||||
"""Test Config has default categories."""
|
||||
config = Config(library_root=Path("/test"))
|
||||
|
||||
assert config.categories == {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
def test_config_custom_categories(self):
|
||||
"""Test Config with custom category mappings."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies", "films"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
|
||||
assert "movies" in config.categories["movie"]
|
||||
assert "tv" in config.categories["series"]
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Test load_config function."""
|
||||
@@ -139,6 +163,22 @@ class TestLoadConfig:
|
||||
assert config.movie_template == "movie/{title} ({year})/"
|
||||
assert config.log_level == "INFO"
|
||||
|
||||
def test_load_config_with_custom_categories(self, tmp_path):
|
||||
"""Test loading config with custom categories."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
library_root: /test/library
|
||||
categories:
|
||||
movie: [movie, movies, films]
|
||||
series: [series, tv, shows]
|
||||
anime: [anime]
|
||||
""")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.categories["movie"] == ["movie", "movies", "films"]
|
||||
assert config.categories["series"] == ["series", "tv", "shows"]
|
||||
|
||||
|
||||
class TestCreateDefaultConfig:
|
||||
"""Test create_default_config function."""
|
||||
@@ -314,6 +354,121 @@ class TestValidateConfig:
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
|
||||
def test_validate_empty_categories(self):
|
||||
"""Test validating config with empty categories."""
|
||||
config = Config(library_root=Path("/test"), categories={})
|
||||
errors = validate_config(config)
|
||||
assert any("categories" in e and "empty" in e for e in errors)
|
||||
|
||||
def test_validate_missing_required_category(self):
|
||||
"""Test validating config with missing required categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]} # Missing series, anime
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("series" in e or "anime" in e for e in errors)
|
||||
|
||||
def test_validate_duplicate_directory_names(self):
|
||||
"""Test validating config with duplicate directory names."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"], # Duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e and "videos" in e for e in errors)
|
||||
|
||||
def test_validate_case_insensitive_duplicates(self):
|
||||
"""Test validating config with case-insensitive duplicates."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"], # Case-insensitive duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e for e in errors)
|
||||
|
||||
def test_validate_valid_custom_categories(self):
|
||||
"""Test validating config with valid custom categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
|
||||
def test_validate_categories_not_dict(self):
|
||||
"""Test validating config with categories not a dict."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"] # Wrong type
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a dictionary" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_not_list(self):
|
||||
"""Test validating config with category value not a list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie", # Should be a list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_validate_empty_category_list(self):
|
||||
"""Test validating config with empty category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [], # Empty list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("cannot be empty" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_with_non_string(self):
|
||||
"""Test validating config with non-string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123], # Non-string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must contain strings" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_with_empty_string(self):
|
||||
"""Test validating config with empty string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""], # Empty string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("empty directory name" in e for e in errors)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
"""Integration tests for configuration workflow."""
|
||||
|
||||
+203
-8
@@ -107,6 +107,46 @@ class TestScanLibrary:
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"video.mp4", "video.mkv"}
|
||||
|
||||
def test_scan_uses_find_output_and_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from find output."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
hidden_dir.mkdir()
|
||||
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
hidden_file = hidden_dir / "hidden.mp4"
|
||||
visible_file.touch()
|
||||
hidden_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"")
|
||||
process.returncode = 0
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_falls_back_when_find_is_unavailable(self, tmp_path):
|
||||
"""Test scan_library falls back to recursive scanning if find is unavailable."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "fallback.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.Popen', side_effect=FileNotFoundError):
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == video_file
|
||||
|
||||
def test_scan_records_metadata(self, tmp_path):
|
||||
"""Test scanning records file metadata correctly."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
@@ -200,6 +240,49 @@ class TestScanLibrary:
|
||||
# Should find both files (no permission errors in test environment)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_scan_reports_progress_callback(self, tmp_path):
|
||||
"""Test scanning reports progress updates for discovered files."""
|
||||
config = Config(library_root=tmp_path)
|
||||
fake_paths = [tmp_path / "a.mp4", tmp_path / "b.mp4"]
|
||||
|
||||
fake_video = VideoFile(
|
||||
path=fake_paths[0],
|
||||
filename="a.mp4",
|
||||
size_bytes=1,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
|
||||
progress_events: list[tuple[int, int]] = []
|
||||
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=[fake_video, None]
|
||||
):
|
||||
result = scan_library(
|
||||
tmp_path,
|
||||
config,
|
||||
progress_callback=lambda current, total: progress_events.append((current, total))
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert progress_events == [(0, 2), (1, 2), (2, 2)]
|
||||
|
||||
def test_scan_reports_progress_for_empty_discovery(self, tmp_path):
|
||||
"""Test scanning reports zero progress when no files are discovered."""
|
||||
config = Config(library_root=tmp_path)
|
||||
progress_events: list[tuple[int, int]] = []
|
||||
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=[]):
|
||||
result = scan_library(
|
||||
tmp_path,
|
||||
config,
|
||||
progress_callback=lambda current, total: progress_events.append((current, total))
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert progress_events == [(0, 0)]
|
||||
|
||||
|
||||
class TestCategorizeFile:
|
||||
"""Tests for the categorize_file function."""
|
||||
@@ -211,7 +294,13 @@ class TestCategorizeFile:
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_series(self, tmp_path):
|
||||
@@ -221,7 +310,13 @@ class TestCategorizeFile:
|
||||
video_file = series_dir / "test.mkv"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "series"
|
||||
|
||||
def test_categorize_anime(self, tmp_path):
|
||||
@@ -231,7 +326,13 @@ class TestCategorizeFile:
|
||||
video_file = anime_dir / "test.avi"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "anime"
|
||||
|
||||
def test_categorize_other(self, tmp_path):
|
||||
@@ -241,7 +342,13 @@ class TestCategorizeFile:
|
||||
video_file = other_dir / "test.mov"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_nested_file(self, tmp_path):
|
||||
@@ -251,7 +358,13 @@ class TestCategorizeFile:
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_case_insensitive(self, tmp_path):
|
||||
@@ -261,7 +374,13 @@ class TestCategorizeFile:
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_file_in_root(self, tmp_path):
|
||||
@@ -269,7 +388,13 @@ class TestCategorizeFile:
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_unknown_directory(self, tmp_path):
|
||||
@@ -279,7 +404,77 @@ class TestCategorizeFile:
|
||||
video_file = unknown_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_plural_movies(self, tmp_path):
|
||||
"""Test recognizing plural 'movies' directory."""
|
||||
movies_dir = tmp_path / "movies"
|
||||
movies_dir.mkdir()
|
||||
video_file = movies_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_tv_directory(self, tmp_path):
|
||||
"""Test recognizing 'tv' as series category."""
|
||||
tv_dir = tmp_path / "tv"
|
||||
tv_dir.mkdir()
|
||||
video_file = tv_dir / "show.mkv"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "series"
|
||||
|
||||
def test_categorize_custom_case_insensitive(self, tmp_path):
|
||||
"""Test case-insensitive matching with custom mappings."""
|
||||
movies_dir = tmp_path / "MOVIES"
|
||||
movies_dir.mkdir()
|
||||
video_file = movies_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_unmapped_returns_other(self, tmp_path):
|
||||
"""Test unmapped directory returns 'other'."""
|
||||
downloads_dir = tmp_path / "downloads"
|
||||
downloads_dir.mkdir()
|
||||
video_file = downloads_dir / "file.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user