Compare commits
6
Commits
c7a55190d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9814b2b917 | ||
|
|
8832f5da3f | ||
|
|
fe03a31dd4 | ||
|
|
8a60aaf9a9 | ||
|
|
0f0636bf19 | ||
|
|
dfa18ed405 |
@@ -1,46 +1,88 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Core package lives in `src/vlm/`.
|
||||
- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Shared CLI helpers live in `cli_helpers.py`. Command logic is in `commands/` (scan, parse, enrich, analyze, plan, execute, review_plan, report, quarantine_cmd, state_cmd, config_cmd).
|
||||
- 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`).
|
||||
- Project metadata and tool config are in `pyproject.toml`.
|
||||
## Project Overview
|
||||
Video Library Manager (VLM) - Python CLI for managing personal video collections. Safety-first, human-in-the-loop approach. All operations reversible.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `uv pip install -e .` installs the package in editable mode.
|
||||
- `uv pip install -e ".[dev]"` installs dev dependencies (`pytest`, `hypothesis`, `pytest-cov`, `ruff`).
|
||||
- `uv run pytest -q` runs the full test suite.
|
||||
- `uv run pytest tests/test_logging.py` runs a targeted test file during iteration.
|
||||
- `uv run ruff check src tests` runs the linter (also in CI).
|
||||
- `vlm --help` verifies CLI startup and available commands.
|
||||
## Quick Start
|
||||
```bash
|
||||
uv pip install -e ".[dev]" # Install with dev deps
|
||||
uv run pytest -q # Run tests
|
||||
uv run vlm --help # Verify CLI
|
||||
```
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Use Python 3.10+ idioms, 4-space indentation, and PEP 8 naming.
|
||||
- Modules/functions/variables: `snake_case`; classes: `PascalCase`; constants: `UPPER_SNAKE_CASE`.
|
||||
- Keep modules focused on a single responsibility; prefer small pure helpers in domain modules.
|
||||
- Add type hints for public functions and non-trivial internal APIs.
|
||||
- Use absolute imports in `src/vlm/`: `from vlm.module import ...` (avoid new relative imports).
|
||||
- Ruff (`E`, `F`, `I`) is configured in `pyproject.toml`; CI runs `ruff check src tests`.
|
||||
## Core Workflow
|
||||
1. `vlm scan` → discover files → `artifacts/inventory.csv`
|
||||
2. `vlm parse` → extract identities → `artifacts/identities.json`
|
||||
3. `vlm enrich` → (optional) add TMDB metadata
|
||||
4. `vlm analyze` → detect gaps/duplicates → `artifacts/analysis.json`
|
||||
5. `vlm plan` → generate execution plan → `artifacts/plan.json`
|
||||
6. `vlm review-plan` → preview high-risk operations
|
||||
7. `vlm execute` → dry-run by default, `--confirm` to execute
|
||||
8. `vlm rollback` → undo executed operations
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: `pytest`; property-based tests use `hypothesis`.
|
||||
- Naming (enforced in config): files `test_*.py`, functions `test_*`, classes `Test*`.
|
||||
- Add/extend tests with each behavior change, including CLI error paths and edge cases.
|
||||
- Prefer narrow unit tests for module logic plus targeted CLI integration tests via `CliRunner`.
|
||||
## Project Structure
|
||||
- `src/vlm/cli.py` - CLI entrypoint
|
||||
- `src/vlm/commands/` - Command implementations (scan, parse, enrich, analyze, plan, execute, review_plan, report, quarantine_cmd, state_cmd, config_cmd)
|
||||
- `src/vlm/scanner.py` - File discovery + ffprobe metadata
|
||||
- `src/vlm/parser.py` - Filename parsing (movies: title+year, series: SxxExx)
|
||||
- `src/vlm/enrichment.py` - TMDB enrichment pipeline
|
||||
- `src/vlm/planner.py` - Execution plan generation
|
||||
- `src/vlm/executor.py` - File operations with rollback
|
||||
- `src/vlm/models.py` - Data structures (VideoFile, MovieIdentity, SeriesIdentity, etc.)
|
||||
- `tests/` - Test suite mirroring source modules
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Current history is minimal; use clear, imperative commit subjects (example: `fix logging fallback for unwritable log dir`).
|
||||
- Keep commits focused; avoid mixing refactors and behavior changes unless tightly coupled.
|
||||
- PRs should include: summary, rationale, test evidence (`pytest` output), and any CLI-visible output changes.
|
||||
- Link related issues/tasks when applicable and call out config or migration impacts.
|
||||
## Key Concepts
|
||||
|
||||
## Security & Configuration Tips
|
||||
- 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.
|
||||
### Safety Protocol
|
||||
- NEVER delete files permanently - use quarantine
|
||||
- All operations create rollback logs with `--confirm`
|
||||
- Default mode is dry-run
|
||||
|
||||
### File Categorization
|
||||
Based on top-level directory matching `categories` config (case-insensitive). Default: `movie`, `series`, `anime`.
|
||||
|
||||
## Documentation baseline
|
||||
- Updated to reflect release 0.2.0 baseline as of 2026-06-01.
|
||||
- Canonical release notes are tracked in `CHANGELOG.md`.
|
||||
- Default workflow artifacts: `artifacts/` (do not commit generated CSV/JSON).
|
||||
### Schema Versions
|
||||
- **v1** (default): Lightweight, no embedded metadata
|
||||
- **v2** (with `--inventory`): Includes video metadata for quality-aware duplicate resolution
|
||||
|
||||
### Parsing Patterns (Hardcoded)
|
||||
- Movies: `{title} ({year})` or `{title}.{year}`
|
||||
- Series: `S{season:02d}E{episode:02d}` or `{season}x{episode}`
|
||||
|
||||
## Development Commands
|
||||
```bash
|
||||
uv run pytest # All tests
|
||||
uv run pytest tests/test_scanner.py # Specific file
|
||||
uv run ruff check src tests # Lint
|
||||
uv run vlm scan # Discover files
|
||||
uv run vlm parse --inventory artifacts/inventory.csv # Parse with metadata
|
||||
uv run vlm plan --analysis artifacts/analysis.json # Plan with duplicates
|
||||
uv run vlm review-plan --tui # Interactive review (requires [tui])
|
||||
```
|
||||
|
||||
## Code Style
|
||||
- Python 3.10+, 4-space indent, PEP 8
|
||||
- `snake_case` functions/vars, `PascalCase` classes, `UPPER_SNAKE_CASE` constants
|
||||
- Type hints for public APIs
|
||||
- Absolute imports: `from vlm.module import ...`
|
||||
- Ruff (`E`, `F`, `I`) enforced in CI
|
||||
|
||||
## Testing
|
||||
- pytest + hypothesis for property-based tests
|
||||
- Naming: `test_*.py`, `test_*()`, `Test*`
|
||||
- Add tests with behavior changes
|
||||
- Prefer unit tests + targeted CLI integration via `CliRunner`
|
||||
|
||||
## Commit Guidelines
|
||||
- Imperative subjects: `fix logging fallback for unwritable log dir`
|
||||
- Keep commits focused (no mixed refactors + behavior changes)
|
||||
- Include: `Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`
|
||||
- PRs: summary, rationale, test evidence, CLI output changes
|
||||
|
||||
## Important Notes
|
||||
- Anime: discovered but not parsed in v1
|
||||
- State tracking: optional, persisted to `~/.vlm/state.json`
|
||||
- Quarantine: only movie/series (not anime/other)
|
||||
- Timestamps: UTC ISO 8601 format
|
||||
- Logging: `~/.vlm/vlm.log`, falls back to console if unwritable
|
||||
- Config: `~/.vlm/config.yaml`
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
|
||||
## 2026-05-21
|
||||
|
||||
### CLI line-count reduction (phase 2)
|
||||
|
||||
- Shrunk `cli.py` to a thin registrar (~70 lines); Click definitions live beside command implementations in `commands/`.
|
||||
- Deduplicated report commands via `reports.duplicate_groups_from_analysis` / `completeness_from_analysis` and shared `cli_helpers.run_command` / `emit_report`.
|
||||
- Archived the completed simplification plan under `docs/archive/2026-pre-baseline/`.
|
||||
|
||||
### Functional code simplification
|
||||
|
||||
- Removed unused `src/vlm/exceptions.py` and consolidated plan summary helpers (`preferred_plan_summary` everywhere).
|
||||
@@ -22,6 +28,11 @@
|
||||
- Fixed series parser empty title when the only title token is a quality tag (e.g. `UHD S01E01.mp4`).
|
||||
- Verification: `pytest -q` → **517 passed**.
|
||||
|
||||
### Documentation sync
|
||||
|
||||
- Resynchronized `README.md`, `CLAUDE.md`, `AGENTS.md`, and `skills/vlm-library-workflow/` docs to the CLI-modularization baseline; refreshed validation baseline (`pytest -q` → **529 passed**).
|
||||
- Fixed the archive index pointer to the completed simplification plan (now inside `docs/archive/2026-pre-baseline/`).
|
||||
|
||||
## 2026-04-07
|
||||
|
||||
### Review-plan Safety & Validation Hardening
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Documentation Status
|
||||
- Synchronized with release 0.2.0 baseline on 2026-06-01 (see `CHANGELOG.md`).
|
||||
- Default workflow outputs: `artifacts/` — do not commit generated CSV/JSON at repo root.
|
||||
|
||||
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
|
||||
|
||||
**Important:** This project uses `uv` for Python package management. All commands should be run with `uv run` to ensure they execute in the correct project environment with proper dependencies.
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Install package in editable mode
|
||||
uv pip install -e .
|
||||
|
||||
# Install with dev dependencies (pytest, hypothesis)
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# Optional Textual UI for `vlm review-plan --tui`
|
||||
uv pip install -e ".[tui]"
|
||||
```
|
||||
|
||||
The default CLI path does not require Textual; it is imported only when `uv run vlm review-plan --tui` is used.
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests (use uv run to execute in the project environment)
|
||||
uv run pytest
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/test_scanner.py
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest -v
|
||||
|
||||
# Run with quiet output (summary only)
|
||||
uv run pytest -q
|
||||
|
||||
# Count total test cases
|
||||
uv run pytest --collect-only -q
|
||||
```
|
||||
|
||||
### Running the CLI
|
||||
```bash
|
||||
# Verify CLI works (use uv run for proper environment)
|
||||
uv run vlm --help
|
||||
|
||||
# Initialize config
|
||||
uv run vlm config init
|
||||
|
||||
# Common workflow
|
||||
uv run vlm scan # Discover files
|
||||
uv run vlm parse # Extract identities (v1 schema)
|
||||
uv run vlm parse --inventory artifacts/inventory.csv # Embed metadata for quality-aware duplicate handling
|
||||
uv run vlm enrich # (Optional) Enrich titles/reputation via TMDB
|
||||
uv run vlm enrich --refresh-all # Force full refresh (ignore cache)
|
||||
uv run vlm analyze # Detect gaps/duplicates
|
||||
uv run vlm plan # Generate execution plan
|
||||
uv run vlm plan --analysis artifacts/analysis.json # Generate plan with duplicate resolution
|
||||
uv run vlm review-plan # Export CSV + terminal review preview
|
||||
uv run vlm review-plan --tui # Optional full-screen review UI
|
||||
uv run vlm apply-review # Sync edited CSV decisions back into the plan
|
||||
uv run vlm execute # Dry-run (default)
|
||||
uv run vlm execute --confirm # Actually execute
|
||||
uv run vlm rollback # Undo executed operations
|
||||
|
||||
# Reporting
|
||||
uv run vlm report summary # Overview statistics
|
||||
uv run vlm report inventory # File inventory
|
||||
uv run vlm report completeness # Series with missing episodes
|
||||
uv run vlm report duplicates # Duplicate files with quality comparison
|
||||
|
||||
# Quarantine management
|
||||
uv run vlm quarantine list # List quarantined files
|
||||
uv run vlm quarantine add <file> --reason "duplicate"
|
||||
uv run vlm quarantine restore <file>
|
||||
|
||||
# State management
|
||||
uv run vlm state show <file>
|
||||
uv run vlm state set <file> --status reviewed
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Workflow
|
||||
VLM follows a read-first, multi-stage pipeline:
|
||||
1. **Scan** → discovers video files, extracts metadata via ffprobe (optional), saves to `artifacts/inventory.csv`
|
||||
2. **Parse** → extracts titles/years/seasons/episodes from filenames, saves to `artifacts/identities.json`
|
||||
- Use `--inventory artifacts/inventory.csv` to embed video metadata (v2 schema) for accurate duplicate resolution by quality
|
||||
- Without `--inventory`, produces v1 schema (lightweight, no embedded metadata)
|
||||
3. **Enrich** (optional) → adds bilingual titles and reputation (TMDB); updates `artifacts/identities.json` in place; uses SQLite cache for incremental runs
|
||||
4. **Analyze** → detects episode gaps and duplicates, saves to `artifacts/analysis.json`
|
||||
5. **Plan** → generates reviewable execution plan (`artifacts/plan.json`) with file operations
|
||||
6. **Review / Apply Review** → previews high-risk operations in terminal or optional TUI, then syncs edited CSV decisions back into the plan when needed
|
||||
7. **Execute** → performs file operations (dry-run by default, `--confirm` to execute)
|
||||
8. **Rollback** → reverses executed operations (best-effort)
|
||||
|
||||
### Module Organization
|
||||
- `cli.py` - Click-based CLI interface, global options, command registration
|
||||
- `context.py` - CLIContext (config, paths) and pass_context for commands
|
||||
- `commands/` - Command implementations (scan, parse, enrich, analyze, plan, execute/rollback)
|
||||
- `scanner.py` - File discovery using system `find` command, metadata extraction via ffprobe
|
||||
- `parser.py` - Filename parsing using regex patterns (movies: title + year, series: SxxExx)
|
||||
- `enrichment.py` - Enrichment pipeline; `cache.py` - SQLite cache; `providers/` - TMDB etc.
|
||||
- `io.py` - Unified JSON/CSV I/O helpers, including validated typed plan loading/saving
|
||||
- `utils.py` - UTC time, format_size, shared helpers
|
||||
- `analysis.py` - Completeness checking (episode gaps) and duplicate detection
|
||||
- `duplicate_resolve.py` - Duplicate group resolution with explicit failures for unresolved strategies/data
|
||||
- `planner.py` - Execution plan generation with conflict detection and manual-review duplicate fallback
|
||||
- `executor.py` - File operations (move/rename/quarantine) with rollback logging and library-root safety checks
|
||||
- `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
|
||||
- `enrichment` (or `enrich`) - TMDB/api_keys, cache_db, translation, reputation; see README for full schema
|
||||
- `plan.duplicate_keep` - when using `vlm plan --analysis`: `by_quality`, `by_reputation` (default), `by_reputation_quality_time`, `first_seen`, or `manual`
|
||||
|
||||
### 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.
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
**identities.json Schema Versions:**
|
||||
|
||||
- **v1** (default without `--inventory`): Lightweight schema without embedded metadata
|
||||
- Records contain: path, filename, category, title, year/season/episodes, confidence, needs_review
|
||||
- VideoFile objects reconstructed with defaults: size_bytes=0, resolution=None, codec=None
|
||||
- Suitable for basic organization workflows
|
||||
|
||||
- **v2** (with `--inventory`): Enhanced schema with embedded video metadata
|
||||
- All v1 fields plus `video_metadata` object containing:
|
||||
- size_bytes, modified_timestamp, resolution, codec, duration_seconds, bitrate_kbps
|
||||
- Enables accurate duplicate resolution by quality (compare resolution, codec, file size)
|
||||
- Required for `by_quality` duplicate resolution strategy
|
||||
- Backward compatible: v1 files load without errors
|
||||
|
||||
**Implementation Details:**
|
||||
- `_video_file_from_record()` in io.py extracts embedded metadata if present
|
||||
- Parse command with `--inventory` flag loads inventory.csv and embeds metadata in output
|
||||
- Schema version stored in `vlm_schema_version` field at root level of identities.json
|
||||
|
||||
### 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>`
|
||||
@@ -814,23 +814,6 @@ Always keep backups of important files!
|
||||
- **Local files only**: Designed for local or mounted network storage
|
||||
- **Best-effort rollback**: Rollback may not succeed if files have been modified
|
||||
|
||||
## Agent skills
|
||||
|
||||
This project includes workflow guidance under `skills/vlm-library-workflow/` (scan → parse → enrich → analyze → plan → review-plan → apply-review → execute).
|
||||
|
||||
### Activation (Gemini CLI)
|
||||
|
||||
```bash
|
||||
activate_skill vlm-library-workflow
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Context-Aware Guidance**: The Agent understands the VLM safety-first workflow and configuration.
|
||||
- **Risk Warnings**: Automatic alerts if a plan contains high-risk operations (>20% quarantine).
|
||||
- **Command Recipes**: Instant access to complex command sequences and troubleshooting steps.
|
||||
- **Developer Verification**: Built-in test execution recipes for verifying core logic changes.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
@@ -860,7 +843,8 @@ uv run pytest --cov=vlm tests/
|
||||
|
||||
```
|
||||
src/vlm/
|
||||
├── cli.py # Click-based CLI interface, global options
|
||||
├── cli.py # Thin Click registrar; global options and command registration
|
||||
├── cli_helpers.py # Shared CLI helpers (context init, command runner, report emission)
|
||||
├── context.py # CLIContext and pass_context for commands
|
||||
├── cli_helpers.py # Shared CLI helpers
|
||||
├── commands/ # Command implementations
|
||||
@@ -894,6 +878,7 @@ src/vlm/
|
||||
├── review_tui.py # Optional Textual review UI
|
||||
├── transaction.py # Execution transaction log
|
||||
├── executor.py # File operations and rollback
|
||||
├── transaction.py # Transaction/rollback logging for executed operations
|
||||
├── quarantine.py # Quarantine management
|
||||
├── state.py # File state tracking
|
||||
├── reports.py # Report generation
|
||||
|
||||
@@ -8,6 +8,6 @@ They are **not** maintained as current project documentation.
|
||||
- `/README.md` — user guide and workflow
|
||||
- `/CHANGELOG.md` — release and refactor history
|
||||
- `/CLAUDE.md` / `/AGENTS.md` — agent/developer guidance
|
||||
- `/plans/2026-05-21-functional-code-simplification-plan-v1.md` — code simplification plan
|
||||
- `2026-05-21-functional-code-simplification-plan-v1.md` (in this archive) — completed code simplification plan
|
||||
|
||||
Archived on 2026-05-21 as part of the functional code trim (Phase 2).
|
||||
|
||||
@@ -11,6 +11,7 @@ authors = [
|
||||
dependencies = [
|
||||
"click>=8.1.0",
|
||||
"pyyaml>=6.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,96 +1,69 @@
|
||||
# Documentation Status
|
||||
- Synced with artifacts/ baseline on 2026-06-01.
|
||||
|
||||
---
|
||||
name: vlm-library-workflow
|
||||
description: Operate and extend the Video Library Manager (`vlm`) with a safety-first, human-in-the-loop workflow across scan, parse, enrich, analyze, plan, review-plan, apply-review, execute, rollback, and developer verification. Use when requests involve organizing a video library, producing or reviewing `artifacts/inventory.csv`, `artifacts/identities.json`, `artifacts/analysis.json`, `artifacts/plan.json`, tuning VLM config templates, resolving duplicates or episode gaps, running dry-run/confirm execution, recovering changes via rollback, explaining VLM CLI usage, or developing/modifying VLM features (parser, providers, planner, executor, commands, tests).
|
||||
description: Operate and extend the Video Library Manager (vlm) CLI. Use for organizing video libraries (scan/parse/enrich/analyze/plan/execute), reviewing plans, resolving duplicates, or developing VLM features.
|
||||
---
|
||||
|
||||
# VLM Library Workflow
|
||||
|
||||
## Overview
|
||||
## Safety Contract
|
||||
|
||||
Run the repository's built-in media organization pipeline with consistent safety checks and explicit output verification. Prefer incremental, reviewable steps and never skip dry-run and plan inspection before destructive operations.
|
||||
1. Before `execute --confirm`, always run `review-plan` and report `High-risk operations` count.
|
||||
2. If high-risk > 0: **stop**, wait for explicit user decision.
|
||||
3. `--confirm` requires `--require-review` to enforce review gate; default does not force.
|
||||
4. Never run `execute --confirm` without user confirmation.
|
||||
|
||||
**Do not commit** generated CSV/JSON under `artifacts/` or at the repository root.
|
||||
## Execution Threshold Semantics
|
||||
|
||||
## Workflow Order
|
||||
- `high_risk_operations`: count of non-no-op operations with risk flags (printed by `review-plan`).
|
||||
- `review_export_rows`: internal count (not printed); may be larger. Do not use `wc -l` on CSV.
|
||||
- If export row count is needed, parse CSV data rows or add the count to CLI output.
|
||||
|
||||
Run commands in this default sequence unless the user asks for a specific stage:
|
||||
## High-Risk Closed Loop
|
||||
|
||||
1. `vlm config init` then set `library_root` in `~/.vlm/config.yaml`
|
||||
2. `vlm scan` → `artifacts/inventory.csv`
|
||||
3. `vlm parse --inventory artifacts/inventory.csv` → `artifacts/identities.json` (v2 schema)
|
||||
4. `vlm enrich` when bilingual titles and reputation signals are needed
|
||||
5. `vlm analyze --inventory artifacts/inventory.csv` → `artifacts/analysis.json`
|
||||
6. `vlm plan --analysis artifacts/analysis.json` → `artifacts/plan.json`
|
||||
7. `vlm review-plan` → `artifacts/plan_manual_review.csv` (use `--tui` only when Textual is installed)
|
||||
8. Edit CSV if needed, then `vlm apply-review` to sync decisions into `plan.json`
|
||||
9. `vlm execute` (dry-run) and inspect summary
|
||||
10. `vlm execute --confirm` only after explicit user confirmation
|
||||
11. `vlm rollback` if the user requests revert
|
||||
When high-risk > 0, use the same plan and CSV path throughout:
|
||||
|
||||
If `vlm` is not on PATH, prefix commands with `uv run`.
|
||||
1. `vlm review-plan --input <plan> --output <csv>`
|
||||
2. Human reviews CSV (modify decision or keep)
|
||||
3. `vlm apply-review --plan <plan> --csv <csv>` — **required even if no changes** (writes `review_applied_at`)
|
||||
4. `vlm execute --plan <plan>` (dry-run with updated plan)
|
||||
5. Get explicit user confirmation
|
||||
6. `vlm execute --plan <plan> --confirm --require-review --review-csv <csv>`
|
||||
|
||||
## Preflight Checks
|
||||
Three rejection paths: missing CSV, missing `review_applied_at`, CSV path mismatch.
|
||||
|
||||
Run these checks before executing workflow commands:
|
||||
## Workflow Phases
|
||||
|
||||
1. Confirm current working directory is repository root.
|
||||
2. Probe command availability: `vlm --help` or `uv run vlm --help`.
|
||||
3. Run the target subcommand `--help` when options are uncertain.
|
||||
4. Confirm config validity with `vlm config validate` after config edits.
|
||||
5. Verify required input artifacts exist under `artifacts/` (or paths passed via flags).
|
||||
6. Treat `execute --confirm` as destructive and require explicit user confirmation.
|
||||
| Phase | Purpose | Artifact |
|
||||
|-------|---------|----------|
|
||||
| config | Set `library_root` | `~/.vlm/config.yaml` |
|
||||
| scan | Discover files | `inventory.csv` |
|
||||
| parse | Extract identities | `identities.json` |
|
||||
| enrich | Add TMDB metadata | `identities.json` (updated) |
|
||||
| analyze | Detect gaps/duplicates | `analysis.json` |
|
||||
| plan | Generate operations | `plan.json` |
|
||||
| review-plan | Export high-risk + preview | `plan_manual_review.csv` |
|
||||
| apply-review | Sync manual decisions | `plan.json` (updated) |
|
||||
| execute | Dry-run, then confirm | rollback log |
|
||||
| rollback | Revert if needed | restored files |
|
||||
|
||||
## Execution Rules
|
||||
## Decision Rules
|
||||
|
||||
Follow these rules while executing tasks:
|
||||
**duplicate_keep strategies** (5): `by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`.
|
||||
|
||||
1. Prefer read-only stages first: scan, parse, enrich, analyze, plan.
|
||||
2. Treat `artifacts/plan.json` as the reviewable contract; summarize counts and conflicts before execution.
|
||||
3. Run `review-plan` and `apply-review` before `execute --confirm` when manual decisions are required.
|
||||
4. Run dry-run (`vlm execute`) before `vlm execute --confirm`.
|
||||
5. If execution is interrupted or results are incorrect, locate rollback logs and run `vlm rollback`.
|
||||
6. Keep outputs explicit in responses: file path, record counts, and next command.
|
||||
7. If the user asks for a partial workflow, run only required stages and state skipped dependencies.
|
||||
8. Before `execute --confirm`, run `vlm review-plan` and report high-risk counts; pause if the user has not reviewed high-risk operations.
|
||||
**Parser boundary risks**: filenames with resolution-like `1920x1080`/`1440x1080` and `Sample` clips are high-risk; require review-plan output before confirmation.
|
||||
|
||||
## Decision Points
|
||||
**Metadata quality**: prefer `vlm parse --inventory` when duplicate quality ranking matters.
|
||||
|
||||
Use these decision policies:
|
||||
|
||||
1. Duplicate handling: choose `plan.duplicate_keep` (`by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`) per user preference.
|
||||
2. Enrichment: skip `vlm enrich` only when translations/reputation are not needed or API keys are unavailable.
|
||||
3. Metadata quality: prefer `vlm parse --inventory artifacts/inventory.csv` when duplicate quality ranking matters.
|
||||
4. Analysis-assisted planning: prefer `vlm plan --analysis artifacts/analysis.json` for automatic duplicate quarantine decisions.
|
||||
5. Parser boundary risk: treat resolution-like tokens (`1920x1080`) and `Sample` clips as high-risk; require review-plan output before confirmation.
|
||||
**Analysis-assisted planning**: prefer `vlm plan --analysis` for automatic duplicate quarantine.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return concise, operational summaries:
|
||||
|
||||
1. Commands executed.
|
||||
2. Artifacts generated or updated (under `artifacts/` by default).
|
||||
3. Key counts (files scanned, identities parsed, duplicate groups, plan operations).
|
||||
4. Risk counts from `review-plan` (manual_review / sample_source / high_season / high_episode / conflicts).
|
||||
5. Blocking errors and exact remediation command.
|
||||
6. Safe next step.
|
||||
|
||||
## Developer Verification
|
||||
|
||||
When modifying VLM core logic:
|
||||
|
||||
1. **Full suite**: `uv run pytest -q`
|
||||
2. **Core components**: `uv run pytest tests/test_scanner.py tests/test_planner.py tests/test_executor.py`
|
||||
3. **Property tests**: `uv run pytest tests/test_analysis_properties.py`
|
||||
4. **Lint** (with dev extras): `uv run ruff check src tests`
|
||||
1. Commands executed and artifacts generated.
|
||||
2. Key counts (files, identities, duplicates, operations).
|
||||
3. Risk counts from review-plan; blocking errors with exact remediation command.
|
||||
|
||||
## References
|
||||
|
||||
Load these references on demand:
|
||||
|
||||
1. `references/command-recipes.md` — command syntax, artifact expectations, failure triage.
|
||||
2. `references/workflow.md` — end-to-end operator workflow.
|
||||
3. `references/cli-reference.md` — command and config quick reference.
|
||||
4. `references/dev-guide.md` — architecture and developer modification patterns.
|
||||
- `references/triage.md`: failure triage mapping + preflight checks.
|
||||
- `references/dev-map.md`: module → test → verification mapping.
|
||||
- For CLI options: run `vlm <command> --help`. Do not trust memory.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Documentation Status
|
||||
- Updated to current CLI options on 2026-06-01. Default artifact paths are under `artifacts/`.
|
||||
|
||||
# VLM CLI Reference
|
||||
|
||||
## Configuration
|
||||
- `vlm config init`: Create default config.
|
||||
- `vlm config show`: Display current settings.
|
||||
- `vlm config validate`: Check config for errors.
|
||||
|
||||
## Core Commands
|
||||
- `vlm scan [--output PATH]`: Discover video files.
|
||||
- `vlm parse [--input CSV] [--output JSON] [--inventory CSV]`: Parse filenames.
|
||||
- `vlm enrich [--input JSON] [--output JSON] [--refresh-all]`: Fetch TMDB metadata.
|
||||
- `vlm analyze [--input JSON] [--output JSON]`: Find gaps and duplicates.
|
||||
- `vlm plan [--input JSON] [--analysis JSON] [--output JSON]`: Generate operations.
|
||||
- `vlm review-plan [--input JSON] [--output CSV] [--tui]`: Export manual review CSV (optional Textual UI).
|
||||
- `vlm apply-review [--plan JSON] [--csv PATH]`: Sync edited review CSV into plan.
|
||||
- `vlm execute [--plan JSON] [--confirm] [--yes] [--verbose-ops] [--safe-mode] [--preserve-directories]`: Move/Rename/Quarantine operations with safety guards.
|
||||
- `vlm rollback [--log PATH]`: Undo operations.
|
||||
|
||||
## Management & Reporting
|
||||
- `vlm quarantine list|add|restore`: Manage the `.quarantine` directory.
|
||||
- `vlm report inventory|completeness|duplicates|summary`: Generate human-readable reports.
|
||||
- `vlm state show|set|query|clear`: Track manual review status for files.
|
||||
|
||||
## Important Config Options (`~/.vlm/config.yaml`)
|
||||
- `library_root`: Path to the video collection.
|
||||
- `templates`: Naming patterns for movies and series.
|
||||
- `plan.duplicate_keep`: Strategy for duplicates (`by_quality`, `by_reputation`, `by_reputation_quality_time`, `first_seen`, `manual`).
|
||||
- `enrichment.api_keys`: TMDB and OpenAI keys.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Documentation Status
|
||||
- Synced with artifacts/ baseline on 2026-06-01.
|
||||
|
||||
# VLM Command Recipes
|
||||
|
||||
## Baseline
|
||||
|
||||
Run from repository root unless the user specifies otherwise.
|
||||
|
||||
```bash
|
||||
vlm --help
|
||||
vlm config show
|
||||
vlm config validate
|
||||
```
|
||||
|
||||
If `vlm` is not on PATH:
|
||||
|
||||
```bash
|
||||
uv run vlm --help
|
||||
uv run vlm config show
|
||||
uv run vlm config validate
|
||||
```
|
||||
|
||||
## End-to-End Pipeline
|
||||
|
||||
Default artifact paths match CLI defaults under `artifacts/`:
|
||||
|
||||
```bash
|
||||
vlm scan
|
||||
vlm parse --inventory artifacts/inventory.csv
|
||||
vlm enrich
|
||||
vlm analyze --inventory artifacts/inventory.csv
|
||||
vlm plan --analysis artifacts/analysis.json
|
||||
vlm review-plan --input artifacts/plan.json --output artifacts/plan_manual_review.csv
|
||||
vlm apply-review
|
||||
vlm execute
|
||||
vlm execute --confirm
|
||||
```
|
||||
|
||||
## Artifact Expectations
|
||||
|
||||
1. `artifacts/inventory.csv` — discovered files with filesystem and optional ffprobe metadata.
|
||||
2. `artifacts/identities.json` — parsed identities (v2 when parse used `--inventory`).
|
||||
3. `artifacts/analysis.json` — completeness gaps and duplicate groups.
|
||||
4. `artifacts/plan.json` — planned operations (`move`, `rename`, `quarantine`, `no-op`).
|
||||
5. `artifacts/plan_manual_review.csv` — high-risk rows for human review before `--confirm`.
|
||||
|
||||
Do not commit these files to Git.
|
||||
|
||||
## Focused Workflows
|
||||
|
||||
```bash
|
||||
vlm parse --inventory artifacts/inventory.csv
|
||||
vlm analyze --input artifacts/identities.json --output artifacts/analysis.json --inventory artifacts/inventory.csv
|
||||
vlm plan --input artifacts/identities.json --analysis artifacts/analysis.json --output artifacts/plan.json
|
||||
vlm rollback
|
||||
```
|
||||
|
||||
## Frequent Failure Triage
|
||||
|
||||
1. Missing config: `vlm config init`, set `library_root`.
|
||||
2. Invalid config: `vlm config validate`.
|
||||
3. Missing input artifact: run prerequisite stage (scan → parse → analyze → plan).
|
||||
4. Unexpected duplicate decisions: check `plan.duplicate_keep`, rerun `vlm plan --analysis artifacts/analysis.json`.
|
||||
5. Unsafe execution: `vlm rollback`, re-run `review-plan` / `apply-review`.
|
||||
6. `vlm` not found: use `uv run vlm ...`.
|
||||
7. TMDB rate limits: lower `enrichment.max_concurrency` in config and retry later.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Documentation Status
|
||||
- Updated for the modular command architecture on 2026-02-16.
|
||||
|
||||
# VLM Developer Guide
|
||||
|
||||
## Project Structure
|
||||
- `src/vlm/cli.py`: Entry point and thin command wrappers.
|
||||
- `src/vlm/parser.py`: Regex-based filename parsing logic.
|
||||
- `src/vlm/enrichment.py`: Pipeline for external metadata fetching.
|
||||
- `src/vlm/providers/`: API implementations (e.g., TMDB).
|
||||
- `src/vlm/planner.py`: Logic for mapping identities to filesystem operations.
|
||||
- `src/vlm/executor.py`: Safe file manipulation and rollback logging.
|
||||
|
||||
## Adding a New Command
|
||||
1. Create a new module in `src/vlm/commands/`.
|
||||
2. Define the command using `@click.command()`.
|
||||
3. Register it in `src/vlm/cli.py` with a Click-decorated function that delegates to the module implementation.
|
||||
|
||||
## Modifying the Parser
|
||||
- The parser uses a sequence of regex patterns in `src/vlm/parser.py`.
|
||||
- Add new patterns to the `PATTERNS` list or improve existing ones.
|
||||
- Always run `pytest tests/test_parser.py` after changes.
|
||||
|
||||
## Data Models
|
||||
See `src/vlm/models.py` for core data structures:
|
||||
- `VideoFile`: Basic file metadata.
|
||||
- `MovieIdentity` / `SeriesIdentity`: Parsed/enriched identity records.
|
||||
- `FileOperation`: Definition of a move/rename/quarantine/no-op/preserve-directory operation.
|
||||
|
||||
## Testing
|
||||
- **Unit Tests**: `pytest`
|
||||
- **Property-based Tests**: `pytest tests/test_analysis_properties.py` (uses Hypothesis).
|
||||
- **Integration Tests**: `pytest tests/test_reports_integration.py`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Developer Map
|
||||
|
||||
## Module → Test → Verification
|
||||
|
||||
| Module | Tests | When to run |
|
||||
|--------|-------|-------------|
|
||||
| `scanner.py` | `tests/test_scanner.py` | File discovery, metadata extraction |
|
||||
| `parser.py` | `tests/test_parser.py` | Filename parsing changes |
|
||||
| `enrichment.py`, `providers/` | `tests/test_enrichment.py` | TMDB/API changes |
|
||||
| `planner.py` | `tests/test_planner.py` | Operation generation, duplicate resolution |
|
||||
| `executor.py` | `tests/test_executor.py` | File operations, rollback |
|
||||
| `plan_review.py` | `tests/test_plan_review.py` | Review gate, CSV handling |
|
||||
| `commands/*.py` | `tests/test_cli_*.py` | CLI flag/behavior changes |
|
||||
| `analysis.py` | `tests/test_analysis_properties.py` | Property-based tests (Hypothesis) |
|
||||
| Full integration | `pytest` | Before any commit |
|
||||
|
||||
## Adding a New Command
|
||||
|
||||
1. Create module in `src/vlm/commands/`.
|
||||
2. Define with `@click.command()`.
|
||||
3. Import and register in `src/vlm/cli.py` via `main.add_command(...)`.
|
||||
|
||||
## Key Models
|
||||
|
||||
See `src/vlm/models.py`: `VideoFile`, `MovieIdentity`, `SeriesIdentity`, `FileOperation`, `ExecutionPlan`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Triage & Preflight
|
||||
|
||||
## Preflight Checks
|
||||
|
||||
Before executing workflow commands:
|
||||
|
||||
1. Confirm working directory is repository root.
|
||||
2. Run `vlm --help`; if unavailable, switch to `uv run vlm`.
|
||||
3. Run `<subcommand> --help` when options are uncertain.
|
||||
4. Run `vlm config validate` after config edits.
|
||||
5. Verify required input files exist before downstream stages.
|
||||
6. Treat `execute --confirm` as destructive; require explicit user confirmation.
|
||||
|
||||
## Failure Triage
|
||||
|
||||
| Problem | Command |
|
||||
|---------|---------|
|
||||
| Missing config | `vlm config init`, then set `library_root` |
|
||||
| Invalid config values | `vlm config validate` and fix reported keys |
|
||||
| Missing input artifact | Run prerequisite stage (scan → parse → analyze → plan) |
|
||||
| Unexpected duplicate decisions | Check `plan.duplicate_keep` in config, rerun `vlm plan --analysis` |
|
||||
| Unsafe execution results | `vlm rollback`, inspect plan, re-run `execute --confirm` |
|
||||
| `vlm` not found | Use `uv run vlm ...` fallback |
|
||||
@@ -1,74 +0,0 @@
|
||||
# Documentation Status
|
||||
- Synced with artifacts/ baseline on 2026-06-01.
|
||||
|
||||
# VLM Workflow Guide
|
||||
|
||||
This guide details the standard end-to-end process for organizing a video library using VLM.
|
||||
|
||||
## 1. Setup and Discovery
|
||||
|
||||
### Initialize Configuration
|
||||
```bash
|
||||
vlm config init
|
||||
```
|
||||
Edit `~/.vlm/config.yaml` to set `library_root` and optionally `workspace_dir: artifacts`.
|
||||
|
||||
### Library Scan
|
||||
```bash
|
||||
vlm scan
|
||||
```
|
||||
- **Goal**: Create `artifacts/inventory.csv`.
|
||||
- **Note**: Ensure `ffprobe` is installed for resolution and codec metadata.
|
||||
|
||||
## 2. Identification
|
||||
|
||||
### Filename Parsing
|
||||
```bash
|
||||
vlm parse --inventory artifacts/inventory.csv
|
||||
```
|
||||
- **Goal**: Create `artifacts/identities.json`.
|
||||
- **Why --inventory?**: Embeds video metadata (v2 schema) required for quality-based duplicate resolution.
|
||||
|
||||
### Metadata Enrichment
|
||||
```bash
|
||||
vlm enrich
|
||||
```
|
||||
- **Goal**: Update `artifacts/identities.json` with TMDB data.
|
||||
- **Troubleshooting**: Check `enrichment.api_keys` in config; never commit `~/.vlm/config.yaml`.
|
||||
|
||||
## 3. Analysis and Planning
|
||||
|
||||
### Detect Issues
|
||||
```bash
|
||||
vlm analyze
|
||||
```
|
||||
- **Goal**: Create `artifacts/analysis.json`.
|
||||
- **Outputs**: Episode gaps and duplicate groups.
|
||||
|
||||
### Create Execution Plan
|
||||
```bash
|
||||
vlm plan --analysis artifacts/analysis.json
|
||||
```
|
||||
- **Goal**: Create `artifacts/plan.json`.
|
||||
- **Strategy**: Uses `plan.duplicate_keep` (default: `by_reputation`) for duplicate decisions.
|
||||
|
||||
## 4. Review and Execution
|
||||
|
||||
### Human review
|
||||
```bash
|
||||
vlm review-plan
|
||||
# Optional: vlm review-plan --tui (requires textual extra)
|
||||
vlm apply-review # after editing artifacts/plan_manual_review.csv
|
||||
```
|
||||
|
||||
### Execute Changes
|
||||
```bash
|
||||
vlm execute # Dry-run
|
||||
vlm execute --confirm # Actual operations (explicit confirmation)
|
||||
```
|
||||
|
||||
### Reverting Changes
|
||||
```bash
|
||||
vlm rollback
|
||||
```
|
||||
Restores files using the latest log in `~/.vlm/rollback/`.
|
||||
+26
-1011
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -123,3 +125,63 @@ def initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext
|
||||
|
||||
logger = setup_logging(log_level=cfg.log_level)
|
||||
return CLIContext(config=cfg, logger=logger)
|
||||
|
||||
|
||||
def emit_report(content: str, output: Optional[Path]) -> None:
|
||||
"""Write report content to a file or stdout."""
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(content, encoding="utf-8")
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(content)
|
||||
|
||||
|
||||
def optional_plan_summary(plan: Optional[Path]):
|
||||
"""Load optional plan JSON and return a display summary string."""
|
||||
if plan is None:
|
||||
return None
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
raise SystemExit(1)
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
|
||||
return preferred_plan_summary(load_plan(plan))
|
||||
|
||||
|
||||
def run_command(
|
||||
ctx: CLIContext,
|
||||
fn: Callable[[], None],
|
||||
*,
|
||||
stage: str,
|
||||
json_errors: bool = False,
|
||||
) -> None:
|
||||
"""Run a command body with consistent CLI error handling."""
|
||||
try:
|
||||
fn()
|
||||
except FileNotFoundError as e:
|
||||
command_error(ctx, f"Error: Input file not found: {e}", f"{stage} file not found: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
if json_errors:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"{stage} JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except ValueError as e:
|
||||
command_error(ctx, f"Error: {e}", f"{stage} validation failed: {e}")
|
||||
except OSError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error during {stage}: {e}",
|
||||
f"{stage} I/O failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"Error during {stage}: {e}", err=True)
|
||||
ctx.logger.error(f"{stage} failed: {e}", exc_info=True)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import (
|
||||
identities_to_analysis_input,
|
||||
load_identities_json,
|
||||
@@ -120,3 +121,23 @@ def analyze_cmd(
|
||||
f"Analysis completed: {len(completeness_results)} incomplete series, "
|
||||
f"{len(duplicate_groups)} duplicate groups, saved to {output}"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
|
||||
"""Analyze completeness and duplicates."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: analyze_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(input, "input", "identities.json", "--input"),
|
||||
output,
|
||||
inventory,
|
||||
),
|
||||
stage="analyze",
|
||||
json_errors=True,
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.cli_helpers import command_error, default_config_path
|
||||
from vlm.config import create_default_config, validate_config
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
|
||||
|
||||
def config_init_cmd(ctx: CLIContext, path: Path) -> None:
|
||||
@@ -60,3 +60,31 @@ def config_validate_cmd(ctx: CLIContext) -> None:
|
||||
for error in errors:
|
||||
click.echo(f" - {error}", err=True)
|
||||
command_error(ctx, "Configuration validation failed.", "Configuration validation failed")
|
||||
|
||||
|
||||
@click.group(name="config")
|
||||
@pass_context
|
||||
def config_group(ctx: CLIContext):
|
||||
"""Manage VLM configuration."""
|
||||
|
||||
|
||||
@config_group.command("init")
|
||||
@click.option("--path", type=click.Path(path_type=Path), default=default_config_path)
|
||||
@pass_context
|
||||
def config_init(ctx: CLIContext, path: Path):
|
||||
"""Create a default config file."""
|
||||
config_init_cmd(ctx, path)
|
||||
|
||||
|
||||
@config_group.command("show")
|
||||
@pass_context
|
||||
def config_show(ctx: CLIContext):
|
||||
"""Show current configuration."""
|
||||
config_show_cmd(ctx)
|
||||
|
||||
|
||||
@config_group.command("validate")
|
||||
@pass_context
|
||||
def config_validate(ctx: CLIContext):
|
||||
"""Validate configuration."""
|
||||
config_validate_cmd(ctx)
|
||||
|
||||
@@ -8,7 +8,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.enrichment import enrich_identities_data
|
||||
from vlm.io import load_json_file, save_json_file
|
||||
|
||||
@@ -144,3 +145,37 @@ def enrich_cmd(
|
||||
stats["skipped"],
|
||||
refresh_mode,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--refresh-changed-only", is_flag=True, default=False)
|
||||
@click.option("--refresh-all", is_flag=True, default=False)
|
||||
@click.option("--timeout", type=int, default=6, show_default=True)
|
||||
@click.option("--retries", type=int, default=2, show_default=True)
|
||||
@pass_context
|
||||
def enrich(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
refresh_changed_only: bool,
|
||||
refresh_all: bool,
|
||||
timeout: int,
|
||||
retries: int,
|
||||
):
|
||||
"""Enrich identities with translation and reputation metadata."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: enrich_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(input, "input", "identities.json", "--input"),
|
||||
output,
|
||||
refresh_changed_only,
|
||||
refresh_all,
|
||||
timeout,
|
||||
retries,
|
||||
),
|
||||
stage="enrich",
|
||||
json_errors=True,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.executor import ExecutionEngine
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
@@ -258,3 +259,50 @@ def rollback_cmd(ctx: CLIContext, log: Optional[Path]) -> None:
|
||||
rollback_summary["successful"],
|
||||
rollback_summary["failed"],
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--confirm", is_flag=True, default=False)
|
||||
@click.option("--yes", is_flag=True, default=False)
|
||||
@click.option("--verbose-ops", is_flag=True, default=False)
|
||||
@click.option("--preserve-directories", is_flag=True, default=False)
|
||||
@click.option("--safe-mode", is_flag=True, default=False)
|
||||
@click.option("--require-review", is_flag=True, default=False)
|
||||
@click.option("--review-csv", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def execute(
|
||||
ctx: CLIContext,
|
||||
plan: Path,
|
||||
confirm: bool,
|
||||
yes: bool,
|
||||
verbose_ops: bool,
|
||||
preserve_directories: bool,
|
||||
safe_mode: bool,
|
||||
require_review: bool,
|
||||
review_csv: Optional[Path],
|
||||
):
|
||||
"""Execute plan (dry-run by default; use --confirm to apply)."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: execute_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan"),
|
||||
confirm,
|
||||
yes,
|
||||
verbose_ops,
|
||||
preserve_directories,
|
||||
safe_mode,
|
||||
require_review=require_review,
|
||||
review_csv=review_csv,
|
||||
),
|
||||
stage="execute",
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--log", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def rollback(ctx: CLIContext, log: Optional[Path]):
|
||||
"""Rollback previous execution (best-effort)."""
|
||||
run_command(ctx, lambda: rollback_cmd(ctx, log), stage="rollback")
|
||||
|
||||
+40
-14
@@ -7,7 +7,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_inventory_csv, save_identities_json
|
||||
from vlm.models import (
|
||||
IdentityRecord,
|
||||
@@ -16,7 +17,7 @@ from vlm.models import (
|
||||
SeriesIdentityRecord,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.parser import parse_movie, parse_series
|
||||
from vlm.parser import parse_anime, parse_movie, parse_series
|
||||
from vlm.utils import utc_now
|
||||
|
||||
|
||||
@@ -51,7 +52,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
|
||||
movie_identities: list[MovieIdentityRecord] = []
|
||||
series_identities: list[SeriesIdentityRecord] = []
|
||||
anime_files: list[IdentityRecord] = []
|
||||
anime_identities: list[SeriesIdentityRecord] = []
|
||||
other_files: list[IdentityRecord] = []
|
||||
|
||||
def get_video_metadata(file_path: str) -> dict:
|
||||
@@ -106,14 +107,20 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
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",
|
||||
}
|
||||
)
|
||||
identity = parse_anime(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
|
||||
anime_identities.append(record)
|
||||
else:
|
||||
other_files.append(
|
||||
{
|
||||
@@ -139,7 +146,12 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
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" Anime: {len(anime_identities)}")
|
||||
|
||||
anime_need_review = sum(1 for a in anime_identities if a["needs_review"])
|
||||
if anime_need_review > 0:
|
||||
click.echo(f" - Need review: {anime_need_review}")
|
||||
|
||||
click.echo(f" Other: {len(other_files)} (not parsed)")
|
||||
|
||||
click.echo()
|
||||
@@ -157,7 +169,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
},
|
||||
"movies": movie_identities,
|
||||
"series": series_identities,
|
||||
"anime": anime_files,
|
||||
"anime": anime_identities,
|
||||
"other": other_files,
|
||||
}
|
||||
|
||||
@@ -165,8 +177,22 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
|
||||
|
||||
click.echo("Parsed identities saved successfully!")
|
||||
logger.info(
|
||||
"Parse completed: %s movies, %s series, saved to %s",
|
||||
"Parse completed: %s movies, %s series, %s anime, saved to %s",
|
||||
len(movie_identities),
|
||||
len(series_identities),
|
||||
len(anime_identities),
|
||||
output,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def parse(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
|
||||
"""Parse identities from filenames."""
|
||||
def _run():
|
||||
input_resolved = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
parse_cmd(ctx, input_resolved, output, inventory)
|
||||
run_command(ctx, _run, stage="parse", json_errors=True)
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import identities_to_plan_input, load_analysis_json, load_identities_json
|
||||
from vlm.planner import generate_plan, save_plan
|
||||
|
||||
@@ -110,3 +111,16 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
|
||||
f"Plan generated: {execution_plan.summary['total']} operations, "
|
||||
f"{conflicts} conflicts, saved to {output}"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def plan(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path]):
|
||||
"""Generate execution plan."""
|
||||
def _run():
|
||||
input_resolved = resolve_legacy_default_input_path(input, "input", "identities.json", "--input")
|
||||
plan_cmd(ctx, input_resolved, output, analysis)
|
||||
run_command(ctx, _run, stage="plan", json_errors=True)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.quarantine import QuarantineManager
|
||||
from vlm.utils import format_size
|
||||
|
||||
@@ -147,3 +147,34 @@ def quarantine_restore_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
f"Failed to restore file: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_context
|
||||
def quarantine(ctx: CLIContext):
|
||||
"""Manage quarantined files (movie and series in v1)."""
|
||||
|
||||
|
||||
@quarantine.command("list")
|
||||
@click.option("--category", type=click.STRING, default=None, help="Category filter (validated against configured categories)")
|
||||
@pass_context
|
||||
def quarantine_list(ctx: CLIContext, category: Optional[str]):
|
||||
"""List quarantined files."""
|
||||
quarantine_list_cmd(ctx, category)
|
||||
|
||||
|
||||
@quarantine.command("add")
|
||||
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("--reason", type=str, default=None)
|
||||
@pass_context
|
||||
def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]):
|
||||
"""Quarantine a file."""
|
||||
quarantine_add_cmd(ctx, file, reason)
|
||||
|
||||
|
||||
@quarantine.command("restore")
|
||||
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
||||
@pass_context
|
||||
def quarantine_restore(ctx: CLIContext, file: Path):
|
||||
"""Restore a file from quarantine."""
|
||||
quarantine_restore_cmd(ctx, file)
|
||||
|
||||
+124
-275
@@ -1,22 +1,24 @@
|
||||
"""Report CLI command implementations."""
|
||||
"""Report CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error, resolve_legacy_default_input_path
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import (
|
||||
default_artifact_path,
|
||||
emit_report,
|
||||
optional_plan_summary,
|
||||
resolve_legacy_default_input_path,
|
||||
run_command,
|
||||
)
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_analysis_json, load_inventory_csv
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
from vlm.reports import (
|
||||
completeness_from_analysis,
|
||||
duplicate_groups_from_analysis,
|
||||
generate_completeness_report,
|
||||
generate_duplicate_report,
|
||||
generate_inventory_report,
|
||||
@@ -24,290 +26,137 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def report_inventory_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
def _run_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]) -> None:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
video_files = load_inventory_csv(input)
|
||||
click.echo(f"Loaded {len(video_files)} files\n")
|
||||
report_format = "csv" if format == "text" else format
|
||||
content = generate_inventory_report(video_files, report_format, ctx.config.library_root)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("inventory report: %s files, format=%s", len(video_files), format)
|
||||
|
||||
|
||||
def _run_completeness(
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
"""Generate inventory report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = optional_plan_summary(plan)
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
analysis_data = load_analysis_json(input)
|
||||
seasons = completeness_from_analysis(analysis_data)
|
||||
click.echo(f"Loaded {len(seasons)} series with gaps\n")
|
||||
content = generate_completeness_report(
|
||||
seasons, format, ctx.config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("completeness report: %s series", len(seasons))
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
def _run_duplicates(
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = optional_plan_summary(plan)
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
analysis_data = load_analysis_json(input)
|
||||
groups = duplicate_groups_from_analysis(analysis_data)
|
||||
click.echo(f"Loaded {len(groups)} duplicate groups\n")
|
||||
content = generate_duplicate_report(
|
||||
groups, format, ctx.config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("duplicate report: %s groups", len(groups))
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating inventory report in {format} format...")
|
||||
def _run_summary(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
video_files = load_inventory_csv(input)
|
||||
click.echo(f"Loaded {len(video_files)} files\n")
|
||||
content = generate_summary_report(video_files, ctx.config.library_root)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("summary report: %s files", len(video_files))
|
||||
|
||||
report_format = "csv" if format == "text" else format
|
||||
report_content = generate_inventory_report(video_files, report_format, config.library_root)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
@click.group()
|
||||
@pass_context
|
||||
def report(ctx: CLIContext):
|
||||
"""Generate inventory, completeness, duplicate, and summary reports."""
|
||||
|
||||
logger.info("Generated inventory report in %s format with %s files", format, len(video_files))
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
@report.command("inventory")
|
||||
@click.option("--format", type=click.Choice(["csv", "json", "text"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
|
||||
"""List discovered files with metadata."""
|
||||
run_command(ctx, lambda: _run_inventory(ctx, format, input, output), stage="inventory report")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating inventory report: {e}",
|
||||
f"Inventory report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@report.command("completeness")
|
||||
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
|
||||
"""Show series with episode gaps."""
|
||||
run_command(
|
||||
ctx, lambda: _run_completeness(ctx, format, input, output, plan),
|
||||
stage="completeness report", json_errors=True,
|
||||
)
|
||||
|
||||
|
||||
@report.command("duplicates")
|
||||
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
|
||||
"""Show duplicate groups with quality comparison."""
|
||||
run_command(
|
||||
ctx, lambda: _run_duplicates(ctx, format, input, output, plan),
|
||||
stage="duplicate report", json_errors=True,
|
||||
)
|
||||
|
||||
|
||||
@report.command("summary")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
||||
"""Show library statistics."""
|
||||
run_command(ctx, lambda: _run_summary(ctx, input, output), stage="summary report")
|
||||
|
||||
|
||||
def report_inventory_cmd(ctx: CLIContext, format: str, input: Path, output: Optional[Path]) -> None:
|
||||
"""Implementation for inventory report command."""
|
||||
run_command(ctx, lambda: _run_inventory(ctx, format, input, output), stage="inventory report")
|
||||
|
||||
|
||||
def report_completeness_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
"""Generate completeness report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
completeness_list = analysis_data.get("completeness", [])
|
||||
|
||||
season_completeness = []
|
||||
for c in completeness_list:
|
||||
season_completeness.append(
|
||||
SeasonCompleteness(
|
||||
series_title=c["series_title"],
|
||||
season=c["season"],
|
||||
episodes_found=c["episodes_found"],
|
||||
episodes_missing=c["episodes_missing"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(season_completeness)} series with gaps")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating completeness report in {format} format...")
|
||||
report_content = generate_completeness_report(
|
||||
season_completeness, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated completeness report in %s format with %s series",
|
||||
format,
|
||||
len(season_completeness),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating completeness report: {e}",
|
||||
f"Completeness report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
"""Implementation for completeness report command."""
|
||||
run_command(
|
||||
ctx, lambda: _run_completeness(ctx, format, input, output, plan),
|
||||
stage="completeness report", json_errors=True,
|
||||
)
|
||||
|
||||
|
||||
def report_duplicates_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
"""Generate duplicate report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
duplicates_list = analysis_data.get("duplicates", [])
|
||||
|
||||
duplicate_groups = []
|
||||
for d in duplicates_list:
|
||||
identity_data = d["identity"]
|
||||
|
||||
if identity_data["type"] == "movie":
|
||||
identity = MovieIdentity(
|
||||
title=identity_data["title"],
|
||||
year=identity_data.get("year"),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
else:
|
||||
identity = SeriesIdentity(
|
||||
title=identity_data["title"],
|
||||
season=identity_data.get("season"),
|
||||
episodes=identity_data.get("episodes", []),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in d.get("quality_comparison", [])
|
||||
}
|
||||
|
||||
files = []
|
||||
for file_path in d["files"]:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(
|
||||
VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_groups.append(
|
||||
DuplicateGroup(
|
||||
identity=identity,
|
||||
files=files,
|
||||
quality_comparison=d["quality_comparison"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating duplicate report in {format} format...")
|
||||
report_content = generate_duplicate_report(
|
||||
duplicate_groups, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated duplicate report in %s format with %s groups",
|
||||
format,
|
||||
len(duplicate_groups),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating duplicate report: {e}",
|
||||
f"Duplicate report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
"""Implementation for duplicates report command."""
|
||||
run_command(
|
||||
ctx, lambda: _run_duplicates(ctx, format, input, output, plan),
|
||||
stage="duplicate report", json_errors=True,
|
||||
)
|
||||
|
||||
|
||||
def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
|
||||
"""Generate summary report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo("Generating summary report...")
|
||||
report_content = generate_summary_report(video_files, config.library_root)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info("Generated summary report with %s files", len(video_files))
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating summary report: {e}",
|
||||
f"Summary report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
"""Implementation for summary report command."""
|
||||
run_command(ctx, lambda: _run_summary(ctx, input, output), stage="summary report")
|
||||
|
||||
@@ -15,7 +15,7 @@ from vlm.cli_helpers import (
|
||||
resolve_legacy_default_input_path,
|
||||
review_plan_tui_streams_ok,
|
||||
)
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_analysis_json, load_identities_json
|
||||
from vlm.plan_render import (
|
||||
duplicate_groups_from_plan,
|
||||
@@ -242,3 +242,60 @@ def apply_review_cmd(
|
||||
f"Apply review failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="review-plan")
|
||||
@click.option("--input", type=click.Path(exists=True, path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan_manual_review.csv"))
|
||||
@click.option("--season-threshold", type=int, default=20, show_default=True)
|
||||
@click.option("--episode-threshold", type=int, default=40, show_default=True)
|
||||
@click.option("--preview-limit", type=int, default=10, show_default=True)
|
||||
@click.option("--show-all", is_flag=True, default=False)
|
||||
@click.option("--tui", is_flag=True, default=False)
|
||||
@click.option("--identities", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--analysis", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--group-by", type=click.Choice(["none", "reason", "title", "duplicate"], case_sensitive=False), default="none", show_default=True)
|
||||
@click.option("--sample-safe", type=int, default=0, show_default=True)
|
||||
@click.option("--structure-preview", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def review_plan(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Path,
|
||||
season_threshold: int,
|
||||
episode_threshold: int,
|
||||
preview_limit: int,
|
||||
show_all: bool,
|
||||
tui: bool,
|
||||
identities: Optional[Path],
|
||||
analysis: Optional[Path],
|
||||
group_by: str,
|
||||
sample_safe: int,
|
||||
structure_preview: Optional[Path],
|
||||
):
|
||||
"""Review a plan and export high-risk operations for manual confirmation."""
|
||||
review_plan_cmd(
|
||||
ctx,
|
||||
input,
|
||||
output,
|
||||
season_threshold,
|
||||
episode_threshold,
|
||||
preview_limit,
|
||||
show_all,
|
||||
tui,
|
||||
identities,
|
||||
analysis,
|
||||
group_by,
|
||||
sample_safe,
|
||||
structure_preview,
|
||||
)
|
||||
|
||||
|
||||
@click.command(name="apply-review")
|
||||
@click.option("--plan", type=click.Path(exists=True, path_type=Path), default=Path("plan.json"))
|
||||
@click.option("--csv", type=click.Path(exists=True, path_type=Path), default=Path("plan_manual_review.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def apply_review(ctx: CLIContext, plan: Path, csv: Path, output: Optional[Path]):
|
||||
"""Apply modifications from a manual review CSV back to the plan JSON."""
|
||||
apply_review_cmd(ctx, plan, csv, output)
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.scanner import load_inventory_csv, save_inventory_csv, scan_library
|
||||
from vlm.utils import format_size
|
||||
|
||||
@@ -94,3 +95,24 @@ def scan_cmd(
|
||||
save_inventory_csv(video_files, output, config.library_root)
|
||||
click.echo("Inventory saved successfully!")
|
||||
logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--metadata/--no-metadata", default=True)
|
||||
@click.option("--reuse-from", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@click.option("--force-refresh-metadata", is_flag=True, default=False)
|
||||
@pass_context
|
||||
def scan(
|
||||
ctx: CLIContext,
|
||||
output: Path,
|
||||
metadata: bool,
|
||||
reuse_from: Optional[Path],
|
||||
force_refresh_metadata: bool,
|
||||
):
|
||||
"""Scan library and write inventory.csv."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata),
|
||||
stage="scan",
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.state import StateManager
|
||||
|
||||
|
||||
@@ -137,3 +137,43 @@ def state_clear_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
f"Failed to clear file state: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_context
|
||||
def state(ctx: CLIContext):
|
||||
"""Track per-file workflow status."""
|
||||
|
||||
|
||||
@state.command("show")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@pass_context
|
||||
def state_show(ctx: CLIContext, file: Path):
|
||||
"""Show state for a file."""
|
||||
state_show_cmd(ctx, file)
|
||||
|
||||
|
||||
@state.command("set")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True)
|
||||
@click.option("--reason", type=str, default=None)
|
||||
@pass_context
|
||||
def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]):
|
||||
"""Set state for a file."""
|
||||
state_set_cmd(ctx, file, status, reason)
|
||||
|
||||
|
||||
@state.command("query")
|
||||
@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True)
|
||||
@pass_context
|
||||
def state_query(ctx: CLIContext, status: str):
|
||||
"""List files with a given status."""
|
||||
state_query_cmd(ctx, status)
|
||||
|
||||
|
||||
@state.command("clear")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@pass_context
|
||||
def state_clear(ctx: CLIContext, file: Path):
|
||||
"""Clear state for a file."""
|
||||
state_clear_cmd(ctx, file)
|
||||
|
||||
+275
-240
@@ -1,41 +1,55 @@
|
||||
"""Configuration management for Video Library Manager."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
||||
|
||||
DEFAULT_VIDEO_EXTENSIONS = [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
]
|
||||
|
||||
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
_VALID_DUPLICATE_KEEP = {
|
||||
"by_reputation",
|
||||
"by_reputation_quality_time",
|
||||
"first_seen",
|
||||
"manual",
|
||||
"by_quality",
|
||||
}
|
||||
_VALID_PROVIDERS = {"tmdb"}
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Configuration for Video Library Manager."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
library_root: Path
|
||||
video_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS)
|
||||
movie_template: str = "movie/{title} ({year})/"
|
||||
series_template: str = "series/{title}/Season {season:02d}/"
|
||||
anime_template: str = "anime/{title}/Season {season:02d}/"
|
||||
movie_filename_template: str = "{title} ({year}){ext}"
|
||||
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
|
||||
anime_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
|
||||
log_level: str = "INFO"
|
||||
quarantine_dir: str = ".quarantine"
|
||||
workspace_dir: Path = Path("artifacts")
|
||||
categories: dict[str, list[str]] = field(default_factory=lambda: {
|
||||
categories: dict[str, list[str]] = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
"anime": ["anime"],
|
||||
}
|
||||
|
||||
# Enrichment settings
|
||||
enrichment_enabled: bool = True
|
||||
enrichment_incremental: bool = True
|
||||
enrichment_refresh_mode: str = "manual"
|
||||
enrichment_providers: list[str] = field(default_factory=lambda: ["tmdb"])
|
||||
enrichment_cache_db: Path = field(default_factory=lambda: Path.home() / ".vlm" / "enrichment_cache.db")
|
||||
enrichment_providers: list[str] = ["tmdb"]
|
||||
enrichment_cache_db: Path = Path.home() / ".vlm" / "enrichment_cache.db"
|
||||
enrichment_max_concurrency: int = 6
|
||||
enrichment_min_match_score: float = 0.75
|
||||
translation_mode: str = "bidirectional"
|
||||
@@ -51,12 +65,247 @@ class Config:
|
||||
reputation_policy: str = "flag_for_review"
|
||||
naming_title_format: str = "{title_zh} {title_en}"
|
||||
|
||||
# Plan settings (e.g. duplicate handling when consuming analysis)
|
||||
duplicate_keep: str = "by_reputation"
|
||||
plan_max_season: int = 15
|
||||
plan_max_episode: int = 100
|
||||
plan_include_sample_files: bool = False
|
||||
|
||||
@field_validator("library_root", mode="before")
|
||||
@classmethod
|
||||
def _coerce_library_root(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
v = Path(v).expanduser()
|
||||
if isinstance(v, Path) and (not str(v) or str(v) == "."):
|
||||
raise ValueError("library_root cannot be empty")
|
||||
return v
|
||||
|
||||
@field_validator("video_extensions")
|
||||
@classmethod
|
||||
def _validate_video_extensions(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("video_extensions cannot be empty")
|
||||
for ext in v:
|
||||
if not ext.startswith("."):
|
||||
raise ValueError(f"video extension must start with '.': {ext}")
|
||||
return v
|
||||
|
||||
@field_validator("movie_template", "series_template", "anime_template", "movie_filename_template", "series_filename_template", "anime_filename_template")
|
||||
@classmethod
|
||||
def _nonempty_template(cls, v: str, info: Any) -> str:
|
||||
if not v:
|
||||
raise ValueError(f"{info.field_name} cannot be empty")
|
||||
return v
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def _validate_log_level(cls, v: str) -> str:
|
||||
if v.upper() not in _VALID_LOG_LEVELS:
|
||||
raise ValueError(f"log_level must be one of {sorted(_VALID_LOG_LEVELS)}, got: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("quarantine_dir")
|
||||
@classmethod
|
||||
def _validate_quarantine_dir(cls, v: str) -> str:
|
||||
if not v:
|
||||
raise ValueError("quarantine_dir cannot be empty")
|
||||
if v.startswith("/") or v.startswith("\\"):
|
||||
raise ValueError("quarantine_dir must be relative to category root, not absolute")
|
||||
return v
|
||||
|
||||
@field_validator("workspace_dir", mode="before")
|
||||
@classmethod
|
||||
def _coerce_workspace_dir(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
if not v.strip():
|
||||
raise ValueError("workspace_dir cannot be empty")
|
||||
return Path(v).expanduser()
|
||||
if isinstance(v, Path):
|
||||
if not str(v).strip():
|
||||
raise ValueError("workspace_dir cannot be empty")
|
||||
return v
|
||||
raise ValueError("workspace_dir must be a Path object")
|
||||
|
||||
@field_validator("enrichment_max_concurrency")
|
||||
@classmethod
|
||||
def _validate_concurrency(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("enrichment_max_concurrency must be >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_min_match_score")
|
||||
@classmethod
|
||||
def _validate_match_score(cls, v: float) -> float:
|
||||
if not 0.0 <= v <= 1.0:
|
||||
raise ValueError("enrichment_min_match_score must be between 0.0 and 1.0")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_providers")
|
||||
@classmethod
|
||||
def _validate_providers(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("enrichment_providers must be a non-empty list")
|
||||
invalid = [p for p in v if p.lower() not in _VALID_PROVIDERS]
|
||||
if invalid:
|
||||
raise ValueError(
|
||||
f"enrichment_providers contains unsupported providers: {invalid}; "
|
||||
f"supported providers: ['tmdb']"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_refresh_mode")
|
||||
@classmethod
|
||||
def _validate_refresh_mode(cls, v: str) -> str:
|
||||
if v not in {"manual", "incremental", "full"}:
|
||||
raise ValueError("enrichment_refresh_mode must be 'manual', 'incremental', or 'full'")
|
||||
return v
|
||||
|
||||
@field_validator("reputation_min_votes")
|
||||
@classmethod
|
||||
def _validate_min_votes(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("reputation_min_votes must be >= 0")
|
||||
return v
|
||||
|
||||
@field_validator("reputation_low_score_threshold")
|
||||
@classmethod
|
||||
def _validate_low_score(cls, v: float) -> float:
|
||||
if not 0.0 <= v <= 10.0:
|
||||
raise ValueError("reputation_low_score_threshold must be between 0.0 and 10.0")
|
||||
return v
|
||||
|
||||
@field_validator("tmdb_language")
|
||||
@classmethod
|
||||
def _validate_tmdb_language(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("tmdb_language must be a non-empty string")
|
||||
return v
|
||||
|
||||
@field_validator("duplicate_keep")
|
||||
@classmethod
|
||||
def _validate_duplicate_keep(cls, v: str) -> str:
|
||||
if v not in _VALID_DUPLICATE_KEEP:
|
||||
raise ValueError(
|
||||
f"duplicate_keep must be one of {sorted(_VALID_DUPLICATE_KEEP)}, got: {v!r}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("plan_max_season", "plan_max_episode")
|
||||
@classmethod
|
||||
def _validate_plan_thresholds(cls, v: int, info: Any) -> int:
|
||||
if v < 1:
|
||||
raise ValueError(f"{info.field_name} must be an integer >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("enrichment_cache_db", mode="before")
|
||||
@classmethod
|
||||
def _coerce_cache_db(cls, v: Any) -> Path:
|
||||
if isinstance(v, str):
|
||||
return Path(v).expanduser()
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_categories(self) -> Config:
|
||||
categories = self.categories
|
||||
if not isinstance(categories, dict):
|
||||
raise ValueError("categories must be a dictionary")
|
||||
if not categories:
|
||||
raise ValueError("categories cannot be empty")
|
||||
|
||||
required = {"movie", "series", "anime"}
|
||||
missing = required - set(categories.keys())
|
||||
if missing:
|
||||
raise ValueError(f"categories must include keys: {sorted(missing)}")
|
||||
|
||||
seen_dirs: dict[str, str] = {}
|
||||
for category, dir_list in categories.items():
|
||||
if not isinstance(dir_list, list):
|
||||
raise ValueError(f"categories['{category}'] must be a list")
|
||||
if not dir_list:
|
||||
raise ValueError(f"categories['{category}'] cannot be empty")
|
||||
for dir_name in dir_list:
|
||||
if not isinstance(dir_name, str):
|
||||
raise ValueError(f"categories['{category}'] must contain strings")
|
||||
if not dir_name.strip():
|
||||
raise ValueError(f"categories['{category}'] contains empty directory name")
|
||||
dir_lower = dir_name.lower()
|
||||
if dir_lower in seen_dirs:
|
||||
raise ValueError(
|
||||
f"Duplicate directory name '{dir_name}' in categories "
|
||||
f"'{category}' and '{seen_dirs[dir_lower]}'"
|
||||
)
|
||||
seen_dirs[dir_lower] = category
|
||||
|
||||
return self
|
||||
|
||||
|
||||
def _flatten_yaml(data: dict) -> dict[str, Any]:
|
||||
"""Flatten nested YAML structure into flat Config fields."""
|
||||
if not data:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
library_root = data.get("library_root")
|
||||
if not library_root:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
flat: dict[str, Any] = {"library_root": library_root}
|
||||
|
||||
if "video_extensions" in data:
|
||||
flat["video_extensions"] = data["video_extensions"]
|
||||
|
||||
templates = data.get("templates", {})
|
||||
if templates:
|
||||
flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/")
|
||||
flat["series_template"] = templates.get("series_dir", "series/{title}/Season {season:02d}/")
|
||||
flat["anime_template"] = templates.get("anime_dir", "anime/{title}/Season {season:02d}/")
|
||||
flat["movie_filename_template"] = templates.get("movie_filename", "{title} ({year}){ext}")
|
||||
flat["series_filename_template"] = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
|
||||
flat["anime_filename_template"] = templates.get("anime_filename", "S{season:02d}E{episode:02d}{ext}")
|
||||
|
||||
for key in ("quarantine_dir", "log_level", "workspace_dir", "categories"):
|
||||
if key in data:
|
||||
flat[key] = data[key]
|
||||
|
||||
plan = data.get("plan", {})
|
||||
if plan:
|
||||
flat["duplicate_keep"] = plan.get("duplicate_keep", "by_reputation")
|
||||
flat["plan_max_season"] = int(plan.get("max_season", 15))
|
||||
flat["plan_max_episode"] = int(plan.get("max_episode", 100))
|
||||
flat["plan_include_sample_files"] = bool(plan.get("include_sample_files", False))
|
||||
|
||||
enrichment = data.get("enrichment")
|
||||
if enrichment is None:
|
||||
enrichment = data.get("enrich", {})
|
||||
if enrichment:
|
||||
translation = enrichment.get("translation", {})
|
||||
api_keys = enrichment.get("api_keys", {})
|
||||
reputation = enrichment.get("reputation", {})
|
||||
naming = enrichment.get("naming", {})
|
||||
tmdb = enrichment.get("tmdb", {})
|
||||
|
||||
flat["enrichment_enabled"] = enrichment.get("enabled", True)
|
||||
flat["enrichment_incremental"] = enrichment.get("incremental", True)
|
||||
flat["enrichment_refresh_mode"] = enrichment.get("refresh_mode", "manual")
|
||||
flat["enrichment_providers"] = enrichment.get("providers", ["tmdb"])
|
||||
flat["enrichment_cache_db"] = enrichment.get(
|
||||
"cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db")
|
||||
)
|
||||
flat["enrichment_max_concurrency"] = enrichment.get("max_concurrency", 6)
|
||||
flat["enrichment_min_match_score"] = enrichment.get("min_match_score", 0.75)
|
||||
flat["translation_mode"] = translation.get("mode", "bidirectional")
|
||||
flat["translation_fallback_machine"] = translation.get("fallback_machine", True)
|
||||
flat["tmdb_api_key"] = api_keys.get("tmdb")
|
||||
flat["tmdb_bearer_token"] = api_keys.get("tmdb_bearer")
|
||||
flat["openai_api_key"] = api_keys.get("openai")
|
||||
flat["tmdb_language"] = tmdb.get("language", "zh-CN")
|
||||
flat["tmdb_region"] = tmdb.get("region")
|
||||
flat["tmdb_include_adult"] = tmdb.get("include_adult", False)
|
||||
flat["reputation_min_votes"] = reputation.get("min_votes", 50)
|
||||
flat["reputation_low_score_threshold"] = reputation.get("low_score_threshold", 6.0)
|
||||
flat["reputation_policy"] = reputation.get("policy", "flag_for_review")
|
||||
flat["naming_title_format"] = naming.get("title_format", "{title_zh} {title_en}")
|
||||
|
||||
return flat
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
"""Load configuration from YAML file."""
|
||||
@@ -69,84 +318,8 @@ def load_config(path: Path) -> Config:
|
||||
except yaml.YAMLError as e:
|
||||
raise yaml.YAMLError(f"Invalid YAML syntax in configuration file: {e}")
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
library_root_str = data.get("library_root")
|
||||
if not library_root_str:
|
||||
raise ValueError("Configuration must specify 'library_root'")
|
||||
|
||||
library_root = Path(library_root_str).expanduser()
|
||||
|
||||
video_extensions = data.get("video_extensions", list(DEFAULT_VIDEO_EXTENSIONS))
|
||||
|
||||
templates = data.get("templates", {})
|
||||
movie_template = templates.get("movie_dir", "movie/{title} ({year})/")
|
||||
series_template = templates.get("series_dir", "series/{title}/Season {season:02d}/")
|
||||
movie_filename_template = templates.get("movie_filename", "{title} ({year}){ext}")
|
||||
series_filename_template = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}")
|
||||
|
||||
quarantine_dir = data.get("quarantine_dir", ".quarantine")
|
||||
workspace_dir = Path(data.get("workspace_dir", "artifacts")).expanduser()
|
||||
log_level = data.get("log_level", "INFO")
|
||||
categories = data.get("categories", {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
})
|
||||
|
||||
plan = data.get("plan", {})
|
||||
duplicate_keep = plan.get("duplicate_keep", "by_reputation")
|
||||
plan_max_season = int(plan.get("max_season", 15))
|
||||
plan_max_episode = int(plan.get("max_episode", 100))
|
||||
plan_include_sample_files = bool(plan.get("include_sample_files", False))
|
||||
|
||||
enrichment = data.get("enrichment")
|
||||
if enrichment is None:
|
||||
enrichment = data.get("enrich", {})
|
||||
translation = enrichment.get("translation", {})
|
||||
api_keys = enrichment.get("api_keys", {})
|
||||
reputation = enrichment.get("reputation", {})
|
||||
naming = enrichment.get("naming", {})
|
||||
tmdb = enrichment.get("tmdb", {})
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
video_extensions=video_extensions,
|
||||
movie_template=movie_template,
|
||||
series_template=series_template,
|
||||
movie_filename_template=movie_filename_template,
|
||||
series_filename_template=series_filename_template,
|
||||
log_level=log_level,
|
||||
quarantine_dir=quarantine_dir,
|
||||
workspace_dir=workspace_dir,
|
||||
categories=categories,
|
||||
enrichment_enabled=enrichment.get("enabled", True),
|
||||
enrichment_incremental=enrichment.get("incremental", True),
|
||||
enrichment_refresh_mode=enrichment.get("refresh_mode", "manual"),
|
||||
enrichment_providers=enrichment.get("providers", ["tmdb"]),
|
||||
enrichment_cache_db=Path(
|
||||
enrichment.get("cache_db", str(Path.home() / ".vlm" / "enrichment_cache.db"))
|
||||
).expanduser(),
|
||||
enrichment_max_concurrency=enrichment.get("max_concurrency", 6),
|
||||
enrichment_min_match_score=enrichment.get("min_match_score", 0.75),
|
||||
translation_mode=translation.get("mode", "bidirectional"),
|
||||
translation_fallback_machine=translation.get("fallback_machine", True),
|
||||
tmdb_api_key=api_keys.get("tmdb"),
|
||||
tmdb_bearer_token=api_keys.get("tmdb_bearer"),
|
||||
tmdb_language=tmdb.get("language", "zh-CN"),
|
||||
tmdb_region=tmdb.get("region"),
|
||||
tmdb_include_adult=tmdb.get("include_adult", False),
|
||||
openai_api_key=api_keys.get("openai"),
|
||||
reputation_min_votes=reputation.get("min_votes", 50),
|
||||
reputation_low_score_threshold=reputation.get("low_score_threshold", 6.0),
|
||||
reputation_policy=reputation.get("policy", "flag_for_review"),
|
||||
naming_title_format=naming.get("title_format", "{title_zh} {title_en}"),
|
||||
duplicate_keep=duplicate_keep,
|
||||
plan_max_season=plan_max_season,
|
||||
plan_max_episode=plan_max_episode,
|
||||
plan_include_sample_files=plan_include_sample_files,
|
||||
)
|
||||
flat = _flatten_yaml(data or {})
|
||||
return Config(**flat)
|
||||
|
||||
|
||||
def create_default_config(path: Path) -> Config:
|
||||
@@ -202,15 +375,16 @@ def create_default_config(path: Path) -> Config:
|
||||
"templates": {
|
||||
"movie_dir": default_config.movie_template,
|
||||
"series_dir": default_config.series_template,
|
||||
"anime_dir": default_config.anime_template,
|
||||
"movie_filename": default_config.movie_filename_template,
|
||||
"series_filename": default_config.series_filename_template,
|
||||
"anime_filename": default_config.anime_filename_template,
|
||||
},
|
||||
"quarantine_dir": default_config.quarantine_dir,
|
||||
"workspace_dir": str(default_config.workspace_dir),
|
||||
"log_level": default_config.log_level,
|
||||
"categories": default_config.categories,
|
||||
"enrichment": enrichment_content,
|
||||
# Backward-compatible alias for users who prefer `enrich`.
|
||||
"enrich": enrichment_content,
|
||||
}
|
||||
|
||||
@@ -223,153 +397,14 @@ def create_default_config(path: Path) -> Config:
|
||||
|
||||
|
||||
def validate_config(config: Config) -> list[str]:
|
||||
"""Validate configuration and return list of error messages."""
|
||||
errors = []
|
||||
"""Validate configuration and return list of error messages.
|
||||
|
||||
if not isinstance(config.library_root, Path):
|
||||
errors.append("library_root must be a Path object")
|
||||
elif not str(config.library_root) or str(config.library_root) == ".":
|
||||
errors.append("library_root cannot be empty")
|
||||
|
||||
if not config.video_extensions:
|
||||
errors.append("video_extensions cannot be empty")
|
||||
elif not isinstance(config.video_extensions, list):
|
||||
errors.append("video_extensions must be a list")
|
||||
else:
|
||||
for ext in config.video_extensions:
|
||||
if not isinstance(ext, str):
|
||||
errors.append(f"video_extensions must contain strings, found: {type(ext)}")
|
||||
break
|
||||
if not ext.startswith("."):
|
||||
errors.append(f"video extension must start with '.': {ext}")
|
||||
|
||||
if not config.movie_template:
|
||||
errors.append("movie_template cannot be empty")
|
||||
elif not isinstance(config.movie_template, str):
|
||||
errors.append("movie_template must be a string")
|
||||
|
||||
if not config.series_template:
|
||||
errors.append("series_template cannot be empty")
|
||||
elif not isinstance(config.series_template, str):
|
||||
errors.append("series_template must be a string")
|
||||
|
||||
if not config.movie_filename_template:
|
||||
errors.append("movie_filename_template cannot be empty")
|
||||
elif not isinstance(config.movie_filename_template, str):
|
||||
errors.append("movie_filename_template must be a string")
|
||||
|
||||
if not config.series_filename_template:
|
||||
errors.append("series_filename_template cannot be empty")
|
||||
elif not isinstance(config.series_filename_template, str):
|
||||
errors.append("series_filename_template must be a string")
|
||||
|
||||
if not isinstance(config.enrichment_max_concurrency, int):
|
||||
errors.append("enrichment_max_concurrency must be an integer")
|
||||
elif config.enrichment_max_concurrency < 1:
|
||||
errors.append("enrichment_max_concurrency must be >= 1")
|
||||
|
||||
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if not config.log_level:
|
||||
errors.append("log_level cannot be empty")
|
||||
elif not isinstance(config.log_level, str):
|
||||
errors.append("log_level must be a string")
|
||||
elif config.log_level.upper() not in valid_log_levels:
|
||||
errors.append(f"log_level must be one of {valid_log_levels}, got: {config.log_level}")
|
||||
|
||||
if not config.quarantine_dir:
|
||||
errors.append("quarantine_dir cannot be empty")
|
||||
elif not isinstance(config.quarantine_dir, str):
|
||||
errors.append("quarantine_dir must be a string")
|
||||
elif config.quarantine_dir.startswith("/") or config.quarantine_dir.startswith("\\"):
|
||||
errors.append("quarantine_dir must be relative to category root, not absolute")
|
||||
|
||||
if not isinstance(config.workspace_dir, Path):
|
||||
errors.append("workspace_dir must be a Path object")
|
||||
elif not str(config.workspace_dir).strip():
|
||||
errors.append("workspace_dir cannot be empty")
|
||||
|
||||
if not config.categories:
|
||||
errors.append("categories cannot be empty")
|
||||
elif not isinstance(config.categories, dict):
|
||||
errors.append("categories must be a dictionary")
|
||||
else:
|
||||
required_categories = {"movie", "series", "anime"}
|
||||
missing = required_categories - set(config.categories.keys())
|
||||
if missing:
|
||||
errors.append(f"categories must include keys: {sorted(missing)}")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if not isinstance(config.enrichment_cache_db, Path):
|
||||
errors.append("enrichment_cache_db must be a Path object")
|
||||
if not isinstance(config.enrichment_providers, list) or not config.enrichment_providers:
|
||||
errors.append("enrichment_providers must be a non-empty list")
|
||||
else:
|
||||
allowed_providers = {"tmdb"}
|
||||
invalid = [provider for provider in config.enrichment_providers if provider.lower() not in allowed_providers]
|
||||
if invalid:
|
||||
errors.append(
|
||||
f"enrichment_providers contains unsupported providers: {invalid}; supported providers: ['tmdb']"
|
||||
)
|
||||
if config.enrichment_max_concurrency < 1:
|
||||
errors.append("enrichment_max_concurrency must be >= 1")
|
||||
if not (0.0 <= config.enrichment_min_match_score <= 1.0):
|
||||
errors.append("enrichment_min_match_score must be between 0.0 and 1.0")
|
||||
if config.enrichment_refresh_mode not in {"manual"}:
|
||||
errors.append("enrichment_refresh_mode must be 'manual'")
|
||||
if config.reputation_min_votes < 0:
|
||||
errors.append("reputation_min_votes must be >= 0")
|
||||
if not (0.0 <= config.reputation_low_score_threshold <= 10.0):
|
||||
errors.append("reputation_low_score_threshold must be between 0.0 and 10.0")
|
||||
if not isinstance(config.tmdb_language, str) or not config.tmdb_language.strip():
|
||||
errors.append("tmdb_language must be a non-empty string")
|
||||
if config.tmdb_region is not None and not isinstance(config.tmdb_region, str):
|
||||
errors.append("tmdb_region must be a string when set")
|
||||
if not isinstance(config.tmdb_include_adult, bool):
|
||||
errors.append("tmdb_include_adult must be a boolean")
|
||||
if config.duplicate_keep not in (
|
||||
"by_reputation",
|
||||
"by_reputation_quality_time",
|
||||
"first_seen",
|
||||
"manual",
|
||||
"by_quality",
|
||||
):
|
||||
errors.append(
|
||||
"duplicate_keep must be one of "
|
||||
"'by_reputation', 'by_reputation_quality_time', 'first_seen', 'manual', 'by_quality', "
|
||||
f"got: {config.duplicate_keep!r}"
|
||||
)
|
||||
if not isinstance(config.plan_max_season, int) or config.plan_max_season < 1:
|
||||
errors.append("plan_max_season must be an integer >= 1")
|
||||
if not isinstance(config.plan_max_episode, int) or config.plan_max_episode < 1:
|
||||
errors.append("plan_max_episode must be an integer >= 1")
|
||||
if not isinstance(config.plan_include_sample_files, bool):
|
||||
errors.append("plan_include_sample_files must be a boolean")
|
||||
|
||||
return errors
|
||||
With Pydantic, most validation happens at construction time. This function
|
||||
re-validates by reconstructing the model, catching any errors that may have
|
||||
been bypassed (e.g. via model_construct). Returns empty list for valid configs.
|
||||
"""
|
||||
try:
|
||||
Config.model_validate(config.model_dump())
|
||||
return []
|
||||
except ValidationError as e:
|
||||
return [f"{err['loc'][0]}: {err['msg']}" for err in e.errors()]
|
||||
|
||||
@@ -15,6 +15,7 @@ from vlm.cache import EnrichmentCache
|
||||
from vlm.config import Config
|
||||
from vlm.parser import normalize_title
|
||||
from vlm.providers import ProviderResult, TMDBAuthError, TMDBProvider, TMDBProviderError
|
||||
from vlm.providers.base import RequestRateLimiter
|
||||
from vlm.utils import sanitize_path_component
|
||||
|
||||
RefreshMode = str
|
||||
@@ -62,6 +63,7 @@ def enrich_identities_data(
|
||||
refresh_all = refresh_mode == "refresh_all"
|
||||
|
||||
max_workers = max(1, int(config.enrichment_max_concurrency))
|
||||
rate_limiter = RequestRateLimiter(min_interval=0.25)
|
||||
|
||||
for section, media_type in (("movies", "movie"), ("series", "series"), ("anime", "anime")):
|
||||
records = identities_data.get(section, [])
|
||||
@@ -108,6 +110,7 @@ def enrich_identities_data(
|
||||
config,
|
||||
request_timeout=request_timeout,
|
||||
retries=retries,
|
||||
rate_limiter=rate_limiter,
|
||||
)
|
||||
_apply_payload(record, payload)
|
||||
cache.put_identity(identity_key, fingerprint, payload)
|
||||
@@ -125,6 +128,7 @@ def enrich_identities_data(
|
||||
config,
|
||||
request_timeout,
|
||||
retries,
|
||||
rate_limiter,
|
||||
)
|
||||
future_map[future] = (record, identity_key, fingerprint)
|
||||
|
||||
@@ -197,7 +201,7 @@ def _update_stats_after_enrich(
|
||||
_emit_progress(stats, progress_callback)
|
||||
|
||||
|
||||
def _build_providers(config: Config, *, request_timeout: int, retries: int) -> list:
|
||||
def _build_providers(config: Config, *, request_timeout: int, retries: int, rate_limiter: RequestRateLimiter) -> list:
|
||||
providers = []
|
||||
unsupported: list[str] = []
|
||||
for name in config.enrichment_providers:
|
||||
@@ -213,6 +217,7 @@ def _build_providers(config: Config, *, request_timeout: int, retries: int) -> l
|
||||
timeout_seconds=request_timeout,
|
||||
retries=retries,
|
||||
min_interval_seconds=0.25,
|
||||
rate_limiter=rate_limiter,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -232,11 +237,13 @@ def _enrich_record_with_fresh_providers(
|
||||
config: Config,
|
||||
request_timeout: int,
|
||||
retries: int,
|
||||
rate_limiter: RequestRateLimiter,
|
||||
) -> tuple[dict, int, list[dict[str, str]], str]:
|
||||
providers = _build_providers(
|
||||
config,
|
||||
request_timeout=request_timeout,
|
||||
retries=retries,
|
||||
rate_limiter=rate_limiter,
|
||||
)
|
||||
return _enrich_record(
|
||||
record,
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.logging_config import get_logger, log_operation
|
||||
from vlm.logging_config import log_operation
|
||||
from vlm.models import ExecutionPlan, FileOperation, OperationResult, RollbackLog
|
||||
from vlm.quarantine import QuarantineManager
|
||||
from vlm.state import StateManager
|
||||
@@ -41,7 +41,7 @@ class ExecutionEngine:
|
||||
verbose_operations: Emit per-operation dry-run logs at INFO when True
|
||||
state_manager: Optional state manager for updating file statuses
|
||||
"""
|
||||
self.logger = logger or get_logger()
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.config = config
|
||||
self.verbose_operations = verbose_operations
|
||||
self.state_manager = state_manager
|
||||
|
||||
+4
-1
@@ -479,7 +479,10 @@ def identities_to_plan_input(
|
||||
for s in series_data:
|
||||
result.append((_video_file_from_record(s), _series_identity_from_record(s)))
|
||||
for a in anime_data:
|
||||
result.append((_video_file_from_record(a), None))
|
||||
if "title" in a and "needs_review" in a:
|
||||
result.append((_video_file_from_record(a), _series_identity_from_record(a)))
|
||||
else:
|
||||
result.append((_video_file_from_record(a), None))
|
||||
for o in other_data:
|
||||
result.append((_video_file_from_record(o), None))
|
||||
|
||||
|
||||
@@ -106,21 +106,6 @@ def setup_logging(
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
"""Get the configured VLM logger instance.
|
||||
|
||||
Returns:
|
||||
Logger instance (creates default configuration if not already set up)
|
||||
"""
|
||||
logger = logging.getLogger("vlm")
|
||||
|
||||
# If logger has no handlers, set up default configuration
|
||||
if not logger.handlers:
|
||||
setup_logging()
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def log_operation(
|
||||
logger: logging.Logger,
|
||||
level: int,
|
||||
|
||||
+40
-39
@@ -4,14 +4,14 @@ This module defines the core data structures used throughout the application
|
||||
for representing video files and their parsed identities.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@dataclass
|
||||
class VideoFile:
|
||||
|
||||
class VideoFile(BaseModel):
|
||||
"""Represents a video file discovered during inventory scanning.
|
||||
|
||||
Attributes:
|
||||
@@ -25,26 +25,27 @@ class VideoFile:
|
||||
duration_seconds: Optional video duration in seconds
|
||||
bitrate_kbps: Optional video bitrate in kilobits per second
|
||||
"""
|
||||
|
||||
path: Path
|
||||
filename: str
|
||||
size_bytes: int
|
||||
modified_timestamp: datetime
|
||||
category: str
|
||||
|
||||
# Optional metadata (if ffprobe available)
|
||||
resolution: Optional[str] = None
|
||||
codec: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
bitrate_kbps: Optional[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Canonicalize path on creation."""
|
||||
@field_validator("path", mode="before")
|
||||
@classmethod
|
||||
def canonicalize_path(cls, v):
|
||||
from vlm.utils import canonical_path
|
||||
object.__setattr__(self, 'path', canonical_path(self.path))
|
||||
|
||||
return canonical_path(v)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MovieIdentity:
|
||||
class MovieIdentity(BaseModel):
|
||||
"""Represents the parsed identity of a movie file.
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
@@ -59,6 +60,7 @@ class MovieIdentity:
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
|
||||
title: str
|
||||
year: Optional[int]
|
||||
confidence: float
|
||||
@@ -73,11 +75,10 @@ class MovieIdentity:
|
||||
reputation_source: Optional[str] = None
|
||||
review_status: str = "pending"
|
||||
enrichment_confidence: Optional[float] = None
|
||||
provider_metadata: dict[str, str] = field(default_factory=dict)
|
||||
provider_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesIdentity:
|
||||
class SeriesIdentity(BaseModel):
|
||||
"""Represents the parsed identity of a TV series episode file.
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
@@ -92,6 +93,7 @@ class SeriesIdentity:
|
||||
needs_review: Flag indicating if manual review is needed
|
||||
original_filename: Original filename before parsing
|
||||
"""
|
||||
|
||||
title: str
|
||||
season: Optional[int]
|
||||
episodes: list[int]
|
||||
@@ -107,11 +109,10 @@ class SeriesIdentity:
|
||||
reputation_source: Optional[str] = None
|
||||
review_status: str = "pending"
|
||||
enrichment_confidence: Optional[float] = None
|
||||
provider_metadata: dict[str, str] = field(default_factory=dict)
|
||||
provider_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileOperation:
|
||||
class FileOperation(BaseModel):
|
||||
"""Represents a single file operation in an execution plan.
|
||||
|
||||
Attributes:
|
||||
@@ -122,17 +123,17 @@ class FileOperation:
|
||||
has_conflict: Flag indicating if destination already exists
|
||||
conflict_reason: Description of the conflict (None if no conflict)
|
||||
"""
|
||||
|
||||
operation_type: str
|
||||
source_path: Path
|
||||
destination_path: Optional[Path]
|
||||
reason: str
|
||||
has_conflict: bool
|
||||
conflict_reason: Optional[str] = None
|
||||
review_context: dict = field(default_factory=dict)
|
||||
review_context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionPlan:
|
||||
class ExecutionPlan(BaseModel):
|
||||
"""Represents a complete execution plan with all file operations.
|
||||
|
||||
Attributes:
|
||||
@@ -145,17 +146,17 @@ class ExecutionPlan:
|
||||
metadata: Optional dict (e.g. analysis_source, duplicate_groups_considered,
|
||||
completeness_seasons_with_gaps) when plan was built from analysis
|
||||
"""
|
||||
|
||||
plan_id: str
|
||||
created_at: datetime
|
||||
operations: list[FileOperation]
|
||||
summary: dict
|
||||
summary_by_reason: dict = field(default_factory=dict)
|
||||
summary_by_reason: dict = Field(default_factory=dict)
|
||||
human_summary: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationResult:
|
||||
class OperationResult(BaseModel):
|
||||
"""Represents the result of executing a single file operation.
|
||||
|
||||
Attributes:
|
||||
@@ -164,14 +165,14 @@ class OperationResult:
|
||||
error_message: Error message if operation failed (None if successful)
|
||||
executed_at: Timestamp when the operation was executed
|
||||
"""
|
||||
|
||||
operation: FileOperation
|
||||
success: bool
|
||||
error_message: Optional[str]
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RollbackLog:
|
||||
class RollbackLog(BaseModel):
|
||||
"""Represents a log of executed operations for rollback purposes.
|
||||
|
||||
Attributes:
|
||||
@@ -180,14 +181,14 @@ class RollbackLog:
|
||||
executed_at: Timestamp when the operations were executed
|
||||
operations: List of operation results that were executed
|
||||
"""
|
||||
|
||||
log_id: str
|
||||
execution_plan_id: str
|
||||
executed_at: datetime
|
||||
operations: list[OperationResult]
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineEntry:
|
||||
class QuarantineEntry(BaseModel):
|
||||
"""Represents a single file in quarantine.
|
||||
|
||||
Attributes:
|
||||
@@ -199,27 +200,27 @@ class QuarantineEntry:
|
||||
category: Category of the video ("movie" or "series")
|
||||
status: Operation status ("pending" | "committed") for two-phase commit
|
||||
"""
|
||||
|
||||
original_path: Path
|
||||
quarantine_path: Path
|
||||
quarantined_at: datetime
|
||||
reason: Optional[str]
|
||||
size_bytes: int
|
||||
category: str
|
||||
status: str = "committed" # Default for backward compatibility
|
||||
status: str = "committed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantineManifest:
|
||||
class QuarantineManifest(BaseModel):
|
||||
"""Represents a manifest of all quarantined files in a category.
|
||||
|
||||
Attributes:
|
||||
entries: List of quarantine entries
|
||||
"""
|
||||
|
||||
entries: list[QuarantineEntry]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileState:
|
||||
class FileState(BaseModel):
|
||||
"""Represents the state of a file in the workflow.
|
||||
|
||||
Attributes:
|
||||
@@ -228,14 +229,14 @@ class FileState:
|
||||
reason: Optional reason for the status
|
||||
updated_at: Timestamp when the state was last updated
|
||||
"""
|
||||
|
||||
file_path: Path
|
||||
status: str
|
||||
reason: Optional[str]
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateStore:
|
||||
class StateStore(BaseModel):
|
||||
"""Represents the persistent state store for all files.
|
||||
|
||||
Attributes:
|
||||
@@ -243,13 +244,13 @@ class StateStore:
|
||||
version: Version of the state store format
|
||||
last_updated: Timestamp when the state store was last updated
|
||||
"""
|
||||
|
||||
states: dict[str, FileState]
|
||||
version: str
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeasonCompleteness:
|
||||
class SeasonCompleteness(BaseModel):
|
||||
"""Represents completeness analysis for a single season of a series.
|
||||
|
||||
Attributes:
|
||||
@@ -258,14 +259,14 @@ class SeasonCompleteness:
|
||||
episodes_found: List of episode numbers that were found
|
||||
episodes_missing: List of episode numbers missing in the range [min, max]
|
||||
"""
|
||||
|
||||
series_title: str
|
||||
season: int
|
||||
episodes_found: list[int]
|
||||
episodes_missing: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuplicateGroup:
|
||||
class DuplicateGroup(BaseModel):
|
||||
"""Represents a group of duplicate video files.
|
||||
|
||||
Attributes:
|
||||
@@ -273,6 +274,7 @@ class DuplicateGroup:
|
||||
files: List of VideoFile objects that are duplicates
|
||||
quality_comparison: List of dictionaries with quality metrics for each file
|
||||
"""
|
||||
|
||||
identity: MovieIdentity | SeriesIdentity
|
||||
files: list[VideoFile]
|
||||
quality_comparison: list[dict]
|
||||
@@ -333,7 +335,7 @@ class ParsedIdentitiesJSON(TypedDict, total=False):
|
||||
metadata: dict[str, object]
|
||||
movies: list[MovieIdentityRecord]
|
||||
series: list[SeriesIdentityRecord]
|
||||
anime: list[IdentityRecord]
|
||||
anime: list[SeriesIdentityRecord]
|
||||
other: list[IdentityRecord]
|
||||
|
||||
|
||||
@@ -383,4 +385,3 @@ class PlanJSON(TypedDict, total=False):
|
||||
summary_by_reason: dict[str, int]
|
||||
human_summary: str
|
||||
metadata: dict[str, object]
|
||||
|
||||
|
||||
@@ -282,6 +282,80 @@ def parse_series(
|
||||
)
|
||||
|
||||
|
||||
def parse_anime(
|
||||
filename: str,
|
||||
extensions: Optional[list[str]] = None,
|
||||
) -> SeriesIdentity:
|
||||
"""Parse an anime filename into a plan-consumable series identity.
|
||||
|
||||
Conservative: explicit ``SxxEyy`` / ``Season N - EP`` are organized; an
|
||||
absolute episode (``Title - NN``) with no season info sets ``needs_review``
|
||||
so the planner treats it as a manual-review no-op (never silently moved).
|
||||
"""
|
||||
if extensions is None:
|
||||
extensions = DEFAULT_VIDEO_EXTENSIONS
|
||||
name = filename
|
||||
for ext in extensions:
|
||||
if name.lower().endswith(ext.lower()):
|
||||
name = name[: -len(ext)]
|
||||
break
|
||||
|
||||
# Strip CRC32 hash brackets first so they are not mistaken for a release group
|
||||
name = re.sub(r'\[[0-9A-Fa-f]{8}\]', '', name)
|
||||
name = remove_release_groups(name)
|
||||
name = remove_quality_tags(name)
|
||||
|
||||
season = None
|
||||
episodes: list[int] = []
|
||||
confidence = 0.0
|
||||
title_part = name
|
||||
|
||||
m = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.9
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'[Ss]eason\s*(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.85
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'\bS(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.85
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'\s*[-_]\s*(\d{1,3})(?!\d)\s*$', name)
|
||||
if m:
|
||||
episodes = [int(m.group(1))]
|
||||
confidence = 0.5
|
||||
title_part = name[: m.start()]
|
||||
# season remains None -> needs_review
|
||||
|
||||
if title_part and title_part.strip():
|
||||
title = humanize_parsed_title(remove_quality_tags(remove_release_groups(title_part)))
|
||||
else:
|
||||
title = humanize_parsed_title(name)
|
||||
if not title.strip():
|
||||
title = humanize_parsed_title(filename)
|
||||
|
||||
needs_review = season is None or not episodes
|
||||
|
||||
return SeriesIdentity(
|
||||
title=title,
|
||||
season=season,
|
||||
episodes=episodes,
|
||||
confidence=confidence,
|
||||
needs_review=needs_review,
|
||||
original_filename=filename,
|
||||
)
|
||||
|
||||
def group_episodes(episodes: list[SeriesIdentity]) -> dict[tuple[str, int], list[SeriesIdentity]]:
|
||||
|
||||
"""Group parsed episodes by normalized series title and season number.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Duplicate handling for planner operations.
|
||||
|
||||
Resolves duplicate file groups: decides which to keep, which to quarantine,
|
||||
and generates appropriate reason strings.
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
from vlm.models import FileOperation, MovieIdentity, SeriesIdentity
|
||||
from vlm.plan_review import normalized_path_key
|
||||
|
||||
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
|
||||
|
||||
|
||||
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
|
||||
"""Index duplicate quality entries by canonicalized path."""
|
||||
lookup: dict[str, dict] = {}
|
||||
for quality in quality_comparison:
|
||||
quality_path = quality.get("path")
|
||||
if isinstance(quality_path, str) and quality_path.strip():
|
||||
lookup[normalized_path_key(quality_path)] = quality
|
||||
return lookup
|
||||
|
||||
|
||||
def _mark_duplicate_group_manual_review(
|
||||
operations: list[FileOperation],
|
||||
indices: list[int],
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
|
||||
for index in indices:
|
||||
current = operations[index]
|
||||
operations[index] = FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=current.source_path,
|
||||
destination_path=None,
|
||||
reason=f"Duplicate group needs manual review: {message}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
|
||||
|
||||
def _select_duplicate_quarantine_reason(
|
||||
strategy: str,
|
||||
identities: list[Union[MovieIdentity, SeriesIdentity]],
|
||||
) -> str:
|
||||
"""Choose a user-facing reason string for duplicate quarantine."""
|
||||
if strategy == "by_quality":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_QUALITY
|
||||
if strategy == "by_reputation_quality_time":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME
|
||||
if strategy == "by_reputation":
|
||||
rep_values = [i.reputation_score for i in identities if i.reputation_score is not None]
|
||||
if len(rep_values) <= 1:
|
||||
return QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Path rendering for planner operations.
|
||||
|
||||
Computes destination paths from config templates and identity data
|
||||
for movie, series, and anime files.
|
||||
"""
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import FileOperation, MovieIdentity, SeriesIdentity, VideoFile
|
||||
from vlm.utils import is_within_root, sanitize_path_component
|
||||
|
||||
NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds configured threshold)"
|
||||
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
|
||||
|
||||
|
||||
def _create_movie_operation(
|
||||
video_file: VideoFile,
|
||||
identity: MovieIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a movie file.
|
||||
|
||||
Args:
|
||||
video_file: The movie file
|
||||
identity: Parsed movie identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the movie
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If movie needs review (no year or low-confidence enrichment), generate no-op
|
||||
if identity.needs_review or identity.year is None:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie needs manual review (no year found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply movie directory template
|
||||
target_dir = config.movie_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply movie filename template
|
||||
target_filename = config.movie_filename_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year,
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize movie: {identity.title} ({identity.year})",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _create_series_operation(
|
||||
video_file: VideoFile,
|
||||
identity: SeriesIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a series file.
|
||||
|
||||
Args:
|
||||
video_file: The series file
|
||||
identity: Parsed series identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the series episode
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If series needs review (no season or no episodes), generate no-op
|
||||
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if identity.season > config.plan_max_season:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_SEASON_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if any(ep > config.plan_max_episode for ep in identity.episodes):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_EPISODE_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply series directory template
|
||||
target_dir = config.series_template.format(
|
||||
title=safe_title,
|
||||
season=identity.season
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply series filename template
|
||||
# For multi-episode files, use the first episode number
|
||||
target_filename = config.series_filename_template.format(
|
||||
season=identity.season,
|
||||
episode=identity.episodes[0],
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize series: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _create_anime_operation(
|
||||
video_file: VideoFile,
|
||||
identity: SeriesIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for an anime file.
|
||||
|
||||
Uses anime-specific templates. Like series, anime with needs_review
|
||||
or missing season/episode info generates a no-op for manual review.
|
||||
"""
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if identity.season > config.plan_max_season:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime needs manual review (season exceeds configured threshold)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if any(ep > config.plan_max_episode for ep in identity.episodes):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime needs manual review (episode exceeds configured threshold)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
target_dir = config.anime_template.format(
|
||||
title=safe_title,
|
||||
season=identity.season
|
||||
)
|
||||
|
||||
ext = video_file.path.suffix
|
||||
|
||||
target_filename = config.anime_filename_template.format(
|
||||
season=identity.season,
|
||||
episode=identity.episodes[0],
|
||||
ext=ext
|
||||
)
|
||||
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize anime: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
@@ -35,6 +35,7 @@ REVIEW_CSV_ENRICHED_FIELDS = [
|
||||
"rel_dest",
|
||||
"quality_hint",
|
||||
"duplicate_group_id",
|
||||
"sidecars",
|
||||
]
|
||||
|
||||
REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS
|
||||
@@ -246,6 +247,11 @@ def enrich_review_row(
|
||||
enriched["episode"] = str(ctx["episode"])
|
||||
if ctx.get("duplicate_group_id"):
|
||||
enriched["duplicate_group_id"] = str(ctx["duplicate_group_id"])
|
||||
sidecars = ctx.get("sidecars")
|
||||
if isinstance(sidecars, list) and sidecars:
|
||||
names = [str(item.get("name", "")) for item in sidecars if isinstance(item, dict)]
|
||||
if names:
|
||||
enriched["sidecars"] = ", ".join(names)
|
||||
|
||||
if identity_lookup and source_path:
|
||||
id_ctx = identity_lookup.get(source_path) or identity_lookup.get(
|
||||
|
||||
+30
-276
@@ -5,7 +5,6 @@ should be organized based on their parsed identities and configuration templates
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
@@ -19,6 +18,16 @@ from vlm.models import (
|
||||
SeriesIdentity,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.plan_duplicates import (
|
||||
_build_duplicate_quality_lookup,
|
||||
_mark_duplicate_group_manual_review,
|
||||
_select_duplicate_quarantine_reason,
|
||||
)
|
||||
from vlm.plan_paths import (
|
||||
_create_anime_operation,
|
||||
_create_movie_operation,
|
||||
_create_series_operation,
|
||||
)
|
||||
from vlm.plan_review import (
|
||||
REVIEW_APPLIED_AT_KEY,
|
||||
REVIEW_CSV_PATH_KEY,
|
||||
@@ -26,48 +35,15 @@ from vlm.plan_review import (
|
||||
build_review_context,
|
||||
normalized_path_key,
|
||||
)
|
||||
from vlm.scanner import find_sidecar_companions
|
||||
from vlm.utils import (
|
||||
is_sample_path,
|
||||
is_within_root,
|
||||
sanitize_path_component,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
|
||||
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
|
||||
__all__ = ["generate_plan", "save_plan", "load_plan", "apply_review_to_plan"]
|
||||
|
||||
NO_OP_REASON_SAMPLE_EXCLUDED = "Sample file excluded by plan include_sample_files=false"
|
||||
NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds configured threshold)"
|
||||
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
|
||||
|
||||
|
||||
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
|
||||
"""Index duplicate quality entries by canonicalized path."""
|
||||
lookup: dict[str, dict] = {}
|
||||
for quality in quality_comparison:
|
||||
quality_path = quality.get("path")
|
||||
if isinstance(quality_path, str) and quality_path.strip():
|
||||
lookup[normalized_path_key(quality_path)] = quality
|
||||
return lookup
|
||||
|
||||
|
||||
def _mark_duplicate_group_manual_review(
|
||||
operations: list[FileOperation],
|
||||
indices: list[int],
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
|
||||
for index in indices:
|
||||
current = operations[index]
|
||||
operations[index] = FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=current.source_path,
|
||||
destination_path=None,
|
||||
reason=f"Duplicate group needs manual review: {message}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
|
||||
|
||||
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
|
||||
@@ -289,13 +265,15 @@ def _create_operation(
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Handle anime category - generate no-op (v1 constraint)
|
||||
# Handle anime category - route to anime-specific operation
|
||||
if video_file.category == "anime":
|
||||
if isinstance(identity, SeriesIdentity):
|
||||
return _create_anime_operation(video_file, identity, config)
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Anime files not organized in v1",
|
||||
reason="Anime needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
@@ -341,225 +319,6 @@ def _create_operation(
|
||||
)
|
||||
|
||||
|
||||
def _create_movie_operation(
|
||||
video_file: VideoFile,
|
||||
identity: MovieIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a movie file.
|
||||
|
||||
Args:
|
||||
video_file: The movie file
|
||||
identity: Parsed movie identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the movie
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If movie needs review (no year or low-confidence enrichment), generate no-op
|
||||
if identity.needs_review or identity.year is None:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Movie needs manual review (no year found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply movie directory template
|
||||
target_dir = config.movie_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply movie filename template
|
||||
target_filename = config.movie_filename_template.format(
|
||||
title=safe_title,
|
||||
year=identity.year,
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize movie: {identity.title} ({identity.year})",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _create_series_operation(
|
||||
video_file: VideoFile,
|
||||
identity: SeriesIdentity,
|
||||
config: Config
|
||||
) -> FileOperation:
|
||||
"""Create operation for a series file.
|
||||
|
||||
Args:
|
||||
video_file: The series file
|
||||
identity: Parsed series identity
|
||||
config: Configuration with templates
|
||||
|
||||
Returns:
|
||||
FileOperation for organizing the series episode
|
||||
"""
|
||||
# Explicitly blocked by manual review workflow.
|
||||
if identity.review_status == "rejected":
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series rejected during manual review",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# If series needs review (no season or no episodes), generate no-op
|
||||
if identity.needs_review or identity.season is None or len(identity.episodes) == 0:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="Series needs manual review (no season/episode found)",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if identity.season > config.plan_max_season:
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_SEASON_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
if any(ep > config.plan_max_episode for ep in identity.episodes):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=NO_OP_REASON_EPISODE_OUT_OF_RANGE,
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
safe_title = sanitize_path_component(identity.title, fallback="untitled")
|
||||
|
||||
# Apply series directory template
|
||||
target_dir = config.series_template.format(
|
||||
title=safe_title,
|
||||
season=identity.season
|
||||
)
|
||||
|
||||
# Get file extension
|
||||
ext = video_file.path.suffix
|
||||
|
||||
# Apply series filename template
|
||||
# For multi-episode files, use the first episode number
|
||||
target_filename = config.series_filename_template.format(
|
||||
season=identity.season,
|
||||
episode=identity.episodes[0],
|
||||
ext=ext
|
||||
)
|
||||
|
||||
# Construct full destination path
|
||||
destination = config.library_root / target_dir / target_filename
|
||||
if not is_within_root(destination, config.library_root):
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason=f"Unsafe destination outside library root: {destination}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Check if source and destination are the same
|
||||
if video_file.path.resolve() == destination.resolve():
|
||||
return FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=video_file.path,
|
||||
destination_path=None,
|
||||
reason="File already at target location",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Determine operation type (move or rename)
|
||||
if video_file.path.parent == destination.parent:
|
||||
operation_type = "rename"
|
||||
else:
|
||||
operation_type = "move"
|
||||
|
||||
# Check for conflicts - destination file already exists
|
||||
has_conflict = destination.exists()
|
||||
conflict_reason = None
|
||||
if has_conflict:
|
||||
conflict_reason = f"Destination file already exists: {destination}"
|
||||
|
||||
return FileOperation(
|
||||
operation_type=operation_type,
|
||||
source_path=video_file.path,
|
||||
destination_path=destination,
|
||||
reason=f"Organize series: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}",
|
||||
has_conflict=has_conflict,
|
||||
conflict_reason=conflict_reason
|
||||
)
|
||||
|
||||
|
||||
def _stamp_review_context_on_operations(
|
||||
operations: list[FileOperation],
|
||||
identities: list[tuple],
|
||||
@@ -584,7 +343,19 @@ def _stamp_review_context_on_operations(
|
||||
duplicate_group_id=gid,
|
||||
keep_candidate=keep_candidate,
|
||||
)
|
||||
stamped.append(replace(op, review_context=ctx))
|
||||
if op.operation_type != "no-op" and op.destination_path:
|
||||
sidecars = find_sidecar_companions(vf.path)
|
||||
if sidecars:
|
||||
dest_dir = op.destination_path.parent
|
||||
ctx["sidecars"] = [
|
||||
{
|
||||
"name": s.name,
|
||||
"path": str(s),
|
||||
"proposed_destination_path": str(dest_dir / s.name),
|
||||
}
|
||||
for s in sidecars
|
||||
]
|
||||
stamped.append(op.model_copy(update={"review_context": ctx}))
|
||||
continue
|
||||
stamped.append(op)
|
||||
return stamped
|
||||
@@ -675,23 +446,6 @@ def _generate_human_summary(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _select_duplicate_quarantine_reason(
|
||||
strategy: str,
|
||||
identities: list[Union[MovieIdentity, SeriesIdentity]],
|
||||
) -> str:
|
||||
"""Choose a user-facing reason string for duplicate quarantine."""
|
||||
if strategy == "by_quality":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_QUALITY
|
||||
if strategy == "by_reputation_quality_time":
|
||||
return QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME
|
||||
if strategy == "by_reputation":
|
||||
rep_values = [i.reputation_score for i in identities if i.reputation_score is not None]
|
||||
if len(rep_values) <= 1:
|
||||
return QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
return QUARANTINE_REASON_DUPLICATE
|
||||
|
||||
|
||||
def _build_quarantine_recommendation_lines(operations: list[FileOperation], limit: int = 20) -> list[str]:
|
||||
"""Build human-readable quarantine recommendations with reasons."""
|
||||
quarantines = [op for op in operations if op.operation_type == "quarantine"]
|
||||
|
||||
@@ -2,12 +2,37 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
|
||||
class RequestRateLimiter:
|
||||
"""Thread-safe rate limiter for API requests.
|
||||
|
||||
Coordinates request timing across concurrent workers to respect
|
||||
provider rate limits.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float):
|
||||
"""Initialize with minimum seconds between requests."""
|
||||
self._min_interval = min_interval
|
||||
self._last_request_time = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def wait(self) -> None:
|
||||
"""Block until enough time has passed since the last request."""
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_request_time
|
||||
if elapsed < self._min_interval:
|
||||
time.sleep(self._min_interval - elapsed)
|
||||
self._last_request_time = time.monotonic()
|
||||
|
||||
|
||||
class ProviderResult(BaseModel):
|
||||
"""Normalized provider output used by enrichment pipeline."""
|
||||
|
||||
provider: str
|
||||
@@ -19,7 +44,7 @@ class ProviderResult:
|
||||
reputation_votes: Optional[int] = None
|
||||
reputation_source: Optional[str] = None
|
||||
match_score: Optional[float] = None
|
||||
raw_metadata: dict[str, str] = field(default_factory=dict)
|
||||
raw_metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EnrichmentProvider(Protocol):
|
||||
|
||||
@@ -11,7 +11,7 @@ from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from vlm.providers.base import ProviderResult
|
||||
from vlm.providers.base import ProviderResult, RequestRateLimiter
|
||||
|
||||
|
||||
class TMDBAuthError(RuntimeError):
|
||||
@@ -40,6 +40,7 @@ class TMDBProvider:
|
||||
min_interval_seconds: float = 0.25,
|
||||
backoff_base_seconds: float = 0.5,
|
||||
backoff_max_seconds: float = 4.0,
|
||||
rate_limiter: Optional[RequestRateLimiter] = None,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.bearer_token = bearer_token
|
||||
@@ -52,6 +53,7 @@ class TMDBProvider:
|
||||
self.min_interval_seconds = min_interval_seconds
|
||||
self.backoff_base_seconds = backoff_base_seconds
|
||||
self.backoff_max_seconds = backoff_max_seconds
|
||||
self.rate_limiter = rate_limiter
|
||||
self._last_request_at = 0.0
|
||||
self.last_request_count = 0
|
||||
|
||||
@@ -181,6 +183,9 @@ class TMDBProvider:
|
||||
return None
|
||||
|
||||
def _wait_for_rate_limit(self) -> None:
|
||||
if self.rate_limiter is not None:
|
||||
self.rate_limiter.wait()
|
||||
return
|
||||
if self.min_interval_seconds <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
|
||||
+38
-15
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .config import Config
|
||||
from .logging_config import get_logger, log_operation
|
||||
from .logging_config import log_operation
|
||||
from .models import FileOperation, OperationResult, QuarantineEntry, QuarantineManifest
|
||||
from .scanner import categorize_file
|
||||
from .utils import utc_now
|
||||
@@ -33,7 +33,7 @@ class QuarantineManager:
|
||||
logger: Optional logger instance (uses default if not provided)
|
||||
"""
|
||||
self.config = config
|
||||
self.logger = logger or get_logger()
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def quarantine_file(
|
||||
self,
|
||||
@@ -113,11 +113,12 @@ class QuarantineManager:
|
||||
# Determine category from file path
|
||||
category = self._determine_category(file_path)
|
||||
|
||||
# Reject anime and other categories (v1 constraint)
|
||||
if category not in ("movie", "series"):
|
||||
# Reject categories outside the configured quarantine scope
|
||||
supported_categories = self._supported_categories()
|
||||
if category not in supported_categories:
|
||||
error_msg = (
|
||||
f"Quarantine not supported for category '{category}'. "
|
||||
f"Only 'movie' and 'series' categories are supported in v1."
|
||||
f"Supported categories: {', '.join(sorted(supported_categories))}."
|
||||
)
|
||||
log_operation(
|
||||
self.logger,
|
||||
@@ -359,6 +360,30 @@ class QuarantineManager:
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
def _supported_categories(self) -> set:
|
||||
"""Return configured category keys supported by the quarantine lifecycle."""
|
||||
categories_config = self.config.categories or {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
return set(categories_config.keys())
|
||||
|
||||
def _category_from_directory(self, dir_name: str) -> Optional[str]:
|
||||
"""Map a category directory name to its configured category key."""
|
||||
dir_name = dir_name.lower()
|
||||
if dir_name in self._supported_categories():
|
||||
return dir_name
|
||||
categories_config = self.config.categories or {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
for category, dir_names in categories_config.items():
|
||||
if any(dir_name == name.lower() for name in dir_names):
|
||||
return category
|
||||
return None
|
||||
|
||||
def _determine_category(self, file_path: Path) -> str:
|
||||
"""Determine the category of a file based on its path.
|
||||
|
||||
@@ -678,20 +703,21 @@ class QuarantineManager:
|
||||
entries = []
|
||||
|
||||
# Determine which categories to query
|
||||
supported = self._supported_categories()
|
||||
if category is not None:
|
||||
# Validate category
|
||||
if category not in ("movie", "series"):
|
||||
if category not in supported:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Invalid category '{category}' for listing. Only 'movie' and 'series' are supported.",
|
||||
f"Invalid category '{category}' for listing. Supported categories: {', '.join(sorted(supported))}.",
|
||||
operation_type="quarantine"
|
||||
)
|
||||
return []
|
||||
categories = [category]
|
||||
else:
|
||||
# List from all supported categories
|
||||
categories = ["movie", "series"]
|
||||
categories = sorted(supported)
|
||||
|
||||
# Load manifests from each category
|
||||
for cat in categories:
|
||||
@@ -720,7 +746,7 @@ class QuarantineManager:
|
||||
Returns:
|
||||
The path where the file was moved in quarantine, or None if not found
|
||||
"""
|
||||
for category in ("movie", "series"):
|
||||
for category in sorted(self._supported_categories()):
|
||||
manifest = self._load_manifest(category)
|
||||
for entry in manifest.entries:
|
||||
if entry.original_path == original_path:
|
||||
@@ -1014,14 +1040,11 @@ class QuarantineManager:
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
# First part should be category, second should be .quarantine
|
||||
category = parts[0].lower()
|
||||
# First part should be category directory, second should be .quarantine
|
||||
dir_name = parts[0].lower()
|
||||
quarantine_dir = parts[1]
|
||||
|
||||
if quarantine_dir != self.config.quarantine_dir:
|
||||
return None
|
||||
|
||||
if category in ("movie", "series"):
|
||||
return category
|
||||
|
||||
return None
|
||||
return self._category_from_directory(dir_name)
|
||||
|
||||
+68
-4
@@ -9,14 +9,11 @@ This module provides functionality to generate various reports about the video l
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, VideoFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
|
||||
|
||||
|
||||
def _normalize_to_utc(timestamp: datetime) -> datetime:
|
||||
@@ -565,3 +562,70 @@ def _format_duration(duration_seconds: float) -> str:
|
||||
parts.append(f"{seconds}s")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def completeness_from_analysis(analysis_data: dict) -> list[SeasonCompleteness]:
|
||||
"""Build SeasonCompleteness records from analysis JSON."""
|
||||
result: list[SeasonCompleteness] = []
|
||||
for row in analysis_data.get("completeness", []) or []:
|
||||
result.append(
|
||||
SeasonCompleteness(
|
||||
series_title=row["series_title"],
|
||||
season=row["season"],
|
||||
episodes_found=row["episodes_found"],
|
||||
episodes_missing=row["episodes_missing"],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def duplicate_groups_from_analysis(analysis_data: dict) -> list[DuplicateGroup]:
|
||||
"""Build DuplicateGroup records from analysis JSON."""
|
||||
groups: list[DuplicateGroup] = []
|
||||
for dup in analysis_data.get("duplicates", []) or []:
|
||||
identity_data = dup["identity"]
|
||||
if identity_data["type"] == "movie":
|
||||
identity = MovieIdentity(
|
||||
title=identity_data["title"],
|
||||
year=identity_data.get("year"),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
else:
|
||||
identity = SeriesIdentity(
|
||||
title=identity_data["title"],
|
||||
season=identity_data.get("season"),
|
||||
episodes=identity_data.get("episodes", []),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in dup.get("quality_comparison", [])
|
||||
}
|
||||
files: list[VideoFile] = []
|
||||
for file_path in dup.get("files", []) or []:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(
|
||||
VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
)
|
||||
)
|
||||
groups.append(
|
||||
DuplicateGroup(
|
||||
identity=identity,
|
||||
files=files,
|
||||
quality_comparison=dup.get("quality_comparison", []),
|
||||
)
|
||||
)
|
||||
return groups
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from vlm.plan_review import save_review_csv
|
||||
from vlm.review_display import (
|
||||
build_csv_rows,
|
||||
@@ -36,17 +37,18 @@ def _change_label(row: dict[str, str]) -> str:
|
||||
return src
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewTUIContext:
|
||||
class ReviewTUIContext(BaseModel):
|
||||
"""Inputs for the plan review TUI."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
rows: list[dict[str, str]]
|
||||
counters: dict[str, int]
|
||||
library_root: Path
|
||||
output_csv: Path
|
||||
plan_input: Path
|
||||
summary_text: str
|
||||
path_to_quality: dict[str, dict] = field(default_factory=dict)
|
||||
path_to_quality: dict[str, dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
+52
-74
@@ -1,7 +1,7 @@
|
||||
"""Inventory scanner for discovering and cataloging video files.
|
||||
|
||||
This module implements the core scanning functionality for the Video Library Manager,
|
||||
including file discovery via `find`, metadata extraction, and categorization based on
|
||||
including file discovery, metadata extraction, and categorization based on
|
||||
directory structure.
|
||||
"""
|
||||
|
||||
@@ -143,79 +143,7 @@ def scan_library(
|
||||
|
||||
|
||||
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 _log_find_nonzero_exit(returncode: int, stderr_text: str, discovered_count: int) -> None:
|
||||
"""Log the explicit contract for non-zero `find` exits.
|
||||
|
||||
Contract: if `find` emits partial stdout before failing, keep those paths and
|
||||
continue with a warning. If no paths were emitted, return an empty result and
|
||||
log that scan discovery was incomplete.
|
||||
"""
|
||||
stderr_suffix = f": {stderr_text}" if stderr_text else ""
|
||||
if discovered_count > 0:
|
||||
logger.warning(
|
||||
"find exited with code %s; using %s partial scan result(s)%s",
|
||||
returncode,
|
||||
discovered_count,
|
||||
stderr_suffix,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"find exited with code %s and produced no scan results%s",
|
||||
returncode,
|
||||
stderr_suffix,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="replace").strip()
|
||||
_log_find_nonzero_exit(process.returncode, stderr_text, len(discovered_paths))
|
||||
|
||||
return discovered_paths
|
||||
|
||||
|
||||
def _discover_video_paths_recursive(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Fallback discovery using Python directory traversal."""
|
||||
"""Discover matching video files under root using os.scandir recursion."""
|
||||
discovered_paths: list[Path] = []
|
||||
for file_path in _scan_directory_recursive(root, video_extensions):
|
||||
if _is_hidden_path(file_path, root):
|
||||
@@ -290,6 +218,56 @@ def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool:
|
||||
return file_extension in [ext.lower() for ext in video_extensions]
|
||||
|
||||
|
||||
DEFAULT_SIDECAR_EXTENSIONS = (".srt", ".ass", ".sub", ".idx", ".sup", ".nfo")
|
||||
|
||||
|
||||
def find_sidecar_companions(
|
||||
video_path: Path,
|
||||
sidecar_extensions: tuple = DEFAULT_SIDECAR_EXTENSIONS,
|
||||
) -> list[Path]:
|
||||
"""Find sidecar files in the same directory that belong to a video file.
|
||||
|
||||
Conservative matching: a companion must share the video's exact stem,
|
||||
optionally followed by dot-separated alphabetic suffix tokens (e.g.
|
||||
language or track tags such as ``zh`` or ``en.forced``). Numeric or
|
||||
otherwise non-alphabetic tokens are rejected so unrelated files are
|
||||
never associated.
|
||||
|
||||
Args:
|
||||
video_path: Path to the video file
|
||||
sidecar_extensions: Sidecar extensions to consider (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Sorted list of companion paths (empty when none are found).
|
||||
"""
|
||||
parent = video_path.parent
|
||||
try:
|
||||
with os.scandir(parent) as it:
|
||||
entries = [e for e in it if e.is_file(follow_symlinks=False)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
stem = video_path.stem
|
||||
exts = {ext.lower() for ext in sidecar_extensions}
|
||||
companions: list[Path] = []
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
suffix = Path(name).suffix.lower()
|
||||
if suffix not in exts:
|
||||
continue
|
||||
base = name[: -len(suffix)]
|
||||
if base == stem:
|
||||
companions.append(Path(entry.path))
|
||||
continue
|
||||
if not base.startswith(stem + "."):
|
||||
continue
|
||||
tokens = base[len(stem) + 1:].split(".")
|
||||
if tokens and all(token and token.isalpha() for token in tokens):
|
||||
companions.append(Path(entry.path))
|
||||
|
||||
return sorted(companions, key=lambda p: p.name)
|
||||
|
||||
|
||||
def _create_video_file(
|
||||
file_path: Path,
|
||||
library_root: Path,
|
||||
|
||||
+232
-190
@@ -10,16 +10,55 @@ from vlm.analysis import analyze_series_completeness, compare_quality, detect_du
|
||||
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesCompletenessAnalysis:
|
||||
"""Test series completeness analysis functionality."""
|
||||
|
||||
def test_detect_single_gap(self):
|
||||
"""Test detection of a single missing episode."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", episodes=[4],
|
||||
original_filename="Show.Name.S01E04.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -33,10 +72,14 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_detect_multiple_gaps(self):
|
||||
"""Test detection of multiple missing episodes."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [7], 0.9, False, "Show.Name.S01E07.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[7],
|
||||
original_filename="Show.Name.S01E07.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -48,9 +91,12 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_no_gaps_returns_empty(self):
|
||||
"""Test that complete seasons are not included in results."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -61,14 +107,20 @@ class TestSeriesCompletenessAnalysis:
|
||||
"""Test that gap detection for one season doesn't affect others."""
|
||||
episodes = [
|
||||
# Season 1 - has gap at episode 2
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", season=1, episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", season=1, episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
# Season 2 - complete
|
||||
SeriesIdentity("Show Name", 2, [1], 0.9, False, "Show.Name.S02E01.mkv"),
|
||||
SeriesIdentity("Show Name", 2, [2], 0.9, False, "Show.Name.S02E02.mkv"),
|
||||
_series("Show Name", season=2, episodes=[1],
|
||||
original_filename="Show.Name.S02E01.mkv"),
|
||||
_series("Show Name", season=2, episodes=[2],
|
||||
original_filename="Show.Name.S02E02.mkv"),
|
||||
# Season 3 - has gap at episode 5
|
||||
SeriesIdentity("Show Name", 3, [4], 0.9, False, "Show.Name.S03E04.mkv"),
|
||||
SeriesIdentity("Show Name", 3, [6], 0.9, False, "Show.Name.S03E06.mkv"),
|
||||
_series("Show Name", season=3, episodes=[4],
|
||||
original_filename="Show.Name.S03E04.mkv"),
|
||||
_series("Show Name", season=3, episodes=[6],
|
||||
original_filename="Show.Name.S03E06.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -89,8 +141,10 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_multi_episode_files(self):
|
||||
"""Test handling of multi-episode files."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1, 2], 0.9, False, "Show.Name.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
_series("Show Name", episodes=[1, 2],
|
||||
original_filename="Show.Name.S01E01-E02.mkv"),
|
||||
_series("Show Name", episodes=[4],
|
||||
original_filename="Show.Name.S01E04.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -103,11 +157,15 @@ class TestSeriesCompletenessAnalysis:
|
||||
"""Test that different series are analyzed separately."""
|
||||
episodes = [
|
||||
# Series A - has gap
|
||||
SeriesIdentity("Series A", 1, [1], 0.9, False, "Series.A.S01E01.mkv"),
|
||||
SeriesIdentity("Series A", 1, [3], 0.9, False, "Series.A.S01E03.mkv"),
|
||||
_series("Series A", episodes=[1],
|
||||
original_filename="Series.A.S01E01.mkv"),
|
||||
_series("Series A", episodes=[3],
|
||||
original_filename="Series.A.S01E03.mkv"),
|
||||
# Series B - complete
|
||||
SeriesIdentity("Series B", 1, [1], 0.9, False, "Series.B.S01E01.mkv"),
|
||||
SeriesIdentity("Series B", 1, [2], 0.9, False, "Series.B.S01E02.mkv"),
|
||||
_series("Series B", episodes=[1],
|
||||
original_filename="Series.B.S01E01.mkv"),
|
||||
_series("Series B", episodes=[2],
|
||||
original_filename="Series.B.S01E02.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -120,9 +178,13 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_skip_episodes_without_season(self):
|
||||
"""Test that episodes with season=None are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", None, [1], 0.3, True, "Show.Name.Episode.1.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -133,9 +195,13 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_skip_episodes_with_empty_episode_list(self):
|
||||
"""Test that episodes with empty episode list are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.S01.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -147,9 +213,12 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_non_sequential_start(self):
|
||||
"""Test gap detection when episodes don't start at 1."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [6], 0.9, False, "Show.Name.S01E06.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [8], 0.9, False, "Show.Name.S01E08.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[6],
|
||||
original_filename="Show.Name.S01E06.mkv"),
|
||||
_series("Show Name", episodes=[8],
|
||||
original_filename="Show.Name.S01E08.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -167,7 +236,8 @@ class TestSeriesCompletenessAnalysis:
|
||||
def test_single_episode_no_gap(self):
|
||||
"""Test that a single episode has no gaps."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
@@ -176,44 +246,29 @@ class TestSeriesCompletenessAnalysis:
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
"""Test duplicate detection functionality."""
|
||||
|
||||
def test_detect_movie_duplicates(self):
|
||||
"""Test detection of duplicate movies with identical title and year."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -228,36 +283,25 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_detect_series_duplicates(self):
|
||||
"""Test detection of duplicate series episodes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.1080p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.720p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.1080p.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.720p.mkv"),
|
||||
_series("Breaking Bad", episodes=[2],
|
||||
original_filename="Breaking.Bad.S01E02.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series",
|
||||
resolution="1920x1080"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series",
|
||||
resolution="1280x720"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E02.mkv"),
|
||||
"Breaking.Bad.S01E02.mkv",
|
||||
1200000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1920x1080"),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1280x720"),
|
||||
_video("Breaking.Bad.S01E02.mkv", 1_200_000_000,
|
||||
"series", modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -272,14 +316,17 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_no_duplicates(self):
|
||||
"""Test that unique files are not flagged as duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Movie A", 2020, 0.9, False, "Movie.A.2020.mkv"),
|
||||
MovieIdentity("Movie B", 2021, 0.9, False, "Movie.B.2021.mkv"),
|
||||
_movie("Movie A", 2020),
|
||||
_movie("Movie B", 2021),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Movie.A.2020.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Movie.B.2021.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -288,14 +335,21 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_skip_movies_without_year(self):
|
||||
"""Test that movies without year are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.mkv"),
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.2.mkv"),
|
||||
_movie("Unknown Movie", year=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Movie.mkv"),
|
||||
_movie("Unknown Movie", year=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Movie.2.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Unknown.Movie.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Unknown.Movie.2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -305,14 +359,21 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_skip_series_without_season(self):
|
||||
"""Test that series without season are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.E01.mkv"),
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.Episode.1.mkv"),
|
||||
_series("Unknown Show", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Show.E01.mkv"),
|
||||
_series("Unknown Show", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Show.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "series"),
|
||||
_video("Unknown.Show.E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Unknown.Show.Episode.1.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -321,14 +382,21 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_skip_series_with_empty_episodes(self):
|
||||
"""Test that series with empty episode list are excluded."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.Season.1.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.S01.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.Season.1.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "series"),
|
||||
_video("Show.Name.S01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.Name.Season.1.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -337,34 +405,23 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_quality_comparison_includes_all_metadata(self):
|
||||
"""Test that quality comparison includes all available metadata."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.1080p.mkv"),
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.720p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.1080p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.720p.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.1080p.mkv"),
|
||||
"Test.Movie.2020.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.720p.mkv"),
|
||||
"Test.Movie.2020.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
_video("Test.Movie.2020.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("Test.Movie.2020.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -388,16 +445,23 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_multi_episode_file_duplicates(self):
|
||||
"""Test duplicate detection for multi-episode files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1, 2], 0.9, False, "Show.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 1, [2], 0.9, False, "Show.S01E02.mkv"),
|
||||
_series("Show", episodes=[1, 2],
|
||||
original_filename="Show.S01E01-E02.mkv"),
|
||||
_series("Show", episodes=[1],
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", episodes=[2],
|
||||
original_filename="Show.S01E02.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.S01E01-E02.mkv", 2_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -407,14 +471,17 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_different_years_not_duplicates(self):
|
||||
"""Test that same title with different years are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Thing", 1982, 0.9, False, "The.Thing.1982.mkv"),
|
||||
MovieIdentity("The Thing", 2011, 0.9, False, "The.Thing.2011.mkv"),
|
||||
_movie("The Thing", 1982),
|
||||
_movie("The Thing", 2011),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("The.Thing.1982.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("The.Thing.2011.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -423,14 +490,19 @@ class TestDuplicateDetection:
|
||||
|
||||
def test_different_seasons_not_duplicates(self):
|
||||
"""Test that same series/episode in different seasons are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 2, [1], 0.9, False, "Show.S02E01.mkv"),
|
||||
_series("Show", season=1,
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", season=2,
|
||||
original_filename="Show.S02E01.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
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(timezone.utc), "series"),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S02E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
@@ -443,29 +515,16 @@ class TestQualityComparison:
|
||||
|
||||
def test_compare_quality_with_all_metadata(self):
|
||||
"""Test quality comparison with all metadata available."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h265",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h265", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
@@ -485,24 +544,12 @@ class TestQualityComparison:
|
||||
|
||||
def test_compare_quality_with_partial_metadata(self):
|
||||
"""Test quality comparison when some metadata is missing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080"
|
||||
# codec, duration, bitrate not available
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
# No optional metadata
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080"),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
@@ -527,16 +574,11 @@ class TestQualityComparison:
|
||||
|
||||
def test_compare_quality_single_file(self):
|
||||
"""Test quality comparison with single file."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file.mkv"),
|
||||
"file.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
_video("file.mkv", 1_500_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
@@ -18,6 +18,42 @@ from vlm.reports import (
|
||||
generate_summary_report,
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
# Custom strategies for generating test data
|
||||
|
||||
@st.composite
|
||||
@@ -41,10 +77,11 @@ def series_identity_strategy(draw, title=None, season=None):
|
||||
))
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv"
|
||||
|
||||
return SeriesIdentity(title, season, sorted(episodes), confidence, needs_review, original_filename)
|
||||
return _series(title, season, sorted(episodes),
|
||||
confidence=confidence,
|
||||
original_filename=original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
@@ -59,10 +96,10 @@ def movie_identity_strategy(draw, title=None, year=None):
|
||||
year = draw(st.integers(min_value=1900, max_value=2030))
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.{year}.mkv"
|
||||
|
||||
return MovieIdentity(title, year, confidence, needs_review, original_filename)
|
||||
return _movie(title, year, confidence=confidence,
|
||||
original_filename=original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
@@ -73,9 +110,8 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_'
|
||||
))) + ".mkv"
|
||||
|
||||
path = Path(f"/{category}/{filename}")
|
||||
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
|
||||
modified_timestamp = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Optional metadata
|
||||
has_metadata = draw(st.booleans())
|
||||
@@ -84,10 +120,17 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
codec = draw(st.sampled_from(["h264", "h265", "vp9", "av1"]))
|
||||
duration_seconds = draw(st.floats(min_value=300, max_value=10800))
|
||||
bitrate_kbps = draw(st.integers(min_value=500, max_value=20000))
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category,
|
||||
resolution, codec, duration_seconds, bitrate_kbps)
|
||||
return _video(
|
||||
filename, size_bytes, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
resolution=resolution, codec=codec,
|
||||
duration_seconds=duration_seconds, bitrate_kbps=bitrate_kbps,
|
||||
)
|
||||
else:
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category)
|
||||
return _video(
|
||||
filename, size_bytes, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
)
|
||||
|
||||
|
||||
# Property 10: Gap detection
|
||||
@@ -124,7 +167,8 @@ def test_property_10_gap_detection(title, season, episodes_data):
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = [
|
||||
SeriesIdentity(title, season, [ep], 0.9, False, f"{title}.S{season:02d}E{ep:02d}.mkv")
|
||||
_series(title, season, [ep],
|
||||
original_filename=f"{title}.S{season:02d}E{ep:02d}.mkv")
|
||||
for ep in episodes_with_gap
|
||||
]
|
||||
|
||||
@@ -179,11 +223,13 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
|
||||
episode_identities = []
|
||||
for ep in s1_with_gap:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 1, [ep], 0.9, False, f"{title}.S01E{ep:02d}.mkv")
|
||||
_series(title, 1, [ep],
|
||||
original_filename=f"{title}.S01E{ep:02d}.mkv")
|
||||
)
|
||||
for ep in s2_complete:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 2, [ep], 0.9, False, f"{title}.S02E{ep:02d}.mkv")
|
||||
_series(title, 2, [ep],
|
||||
original_filename=f"{title}.S02E{ep:02d}.mkv")
|
||||
)
|
||||
|
||||
# Analyze completeness
|
||||
@@ -219,16 +265,14 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
|
||||
# Create multiple movie identities with same title and year
|
||||
identities = []
|
||||
files = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
identities.append(_movie(title, year, original_filename=filename))
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
@@ -265,16 +309,16 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
|
||||
# Create multiple series identities with same title, season, and episode
|
||||
identities = []
|
||||
files = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
|
||||
identities.append(SeriesIdentity(title, season, [episode], 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/series/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
identities.append(
|
||||
_series(title, season, [episode], original_filename=filename)
|
||||
)
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000, "series",
|
||||
modified_timestamp=now, path=Path(f"/series/{filename}"),
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
@@ -311,31 +355,24 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
|
||||
# Create movie identities and files with varying metadata
|
||||
identities = []
|
||||
files = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
identities.append(_movie(title, year, original_filename=filename))
|
||||
|
||||
# Some files have full metadata, some don't
|
||||
if i % 2 == 0:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000,
|
||||
))
|
||||
else:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
@@ -377,15 +414,12 @@ def test_property_42_completeness_report(series_count, format):
|
||||
|
||||
for i in range(series_count):
|
||||
title = f"Series {i}"
|
||||
season = 1
|
||||
episodes_found = [1, 2, 4, 5] # Gap at episode 3
|
||||
episodes_missing = [3]
|
||||
|
||||
analysis_results.append(SeasonCompleteness(
|
||||
series_title=title,
|
||||
season=season,
|
||||
episodes_found=episodes_found,
|
||||
episodes_missing=episodes_missing
|
||||
season=1,
|
||||
episodes_found=[1, 2, 4, 5], # Gap at episode 3
|
||||
episodes_missing=[3]
|
||||
))
|
||||
|
||||
# Generate report
|
||||
@@ -415,6 +449,7 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
"""
|
||||
# Create duplicate groups
|
||||
duplicate_groups = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
title = f"Movie {i}"
|
||||
@@ -426,14 +461,11 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
|
||||
for j in range(2):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
|
||||
file = VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + j * 500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
file = _video(
|
||||
filename, 1_000_000_000 + j * 500_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080" if j == 0 else "1280x720",
|
||||
codec="h264"
|
||||
codec="h264",
|
||||
)
|
||||
files.append(file)
|
||||
quality_comparison.append({
|
||||
@@ -444,8 +476,11 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
'codec': file.codec
|
||||
})
|
||||
|
||||
identity = MovieIdentity(title, year, 0.9, False, files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(identity, files, quality_comparison))
|
||||
identity = _movie(title, year, original_filename=files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(
|
||||
identity=identity, files=files,
|
||||
quality_comparison=quality_comparison,
|
||||
))
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
@@ -483,18 +518,16 @@ def test_property_44_summary_report_accuracy(file_count, categories):
|
||||
files = []
|
||||
total_size = 0
|
||||
category_counts = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
category = categories[i % len(categories)]
|
||||
size = 1000000000 + i * 100000000
|
||||
size = 1_000_000_000 + i * 100_000_000
|
||||
filename = f"file_{i}.mkv"
|
||||
|
||||
files.append(VideoFile(
|
||||
Path(f"/{category}/{filename}"),
|
||||
filename,
|
||||
size,
|
||||
datetime.now(timezone.utc),
|
||||
category
|
||||
files.append(_video(
|
||||
filename, size, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
))
|
||||
|
||||
total_size += size
|
||||
|
||||
@@ -168,3 +168,79 @@ class TestParseCommand:
|
||||
assert movie2["video_metadata"]["size_bytes"] == 1500000000
|
||||
assert movie2["video_metadata"]["resolution"] is None
|
||||
assert movie2["video_metadata"]["codec"] is None
|
||||
|
||||
def test_parse_anime_with_season_episode(self, tmp_path):
|
||||
"""Test parse correctly parses anime files with SxxEyy format."""
|
||||
inventory_csv = tmp_path / "inventory.csv"
|
||||
inventory_csv.write_text(
|
||||
"# vlm inventory\n"
|
||||
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
|
||||
"/library/anime/Naruto Shippuden S01E05.mkv,Naruto Shippuden S01E05.mkv,500000,2024-01-01T00:00:00,anime,,,\n"
|
||||
)
|
||||
|
||||
output_json = tmp_path / "identities.json"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--config",
|
||||
str(tmp_path / "config.yaml"),
|
||||
"parse",
|
||||
"--input",
|
||||
str(inventory_csv),
|
||||
"--output",
|
||||
str(output_json),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Anime: 1" in result.output
|
||||
|
||||
with open(output_json) as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert len(data["anime"]) == 1
|
||||
anime = data["anime"][0]
|
||||
assert anime["title"] == "Naruto Shippuden"
|
||||
assert anime["season"] == 1
|
||||
assert anime["episodes"] == [5]
|
||||
assert anime["confidence"] == 0.9
|
||||
assert anime["needs_review"] is False
|
||||
|
||||
def test_parse_anime_absolute_numbering_needs_review(self, tmp_path):
|
||||
"""Test parse marks anime with absolute episode numbering as needs_review."""
|
||||
inventory_csv = tmp_path / "inventory.csv"
|
||||
inventory_csv.write_text(
|
||||
"# vlm inventory\n"
|
||||
"path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n"
|
||||
"/library/anime/Naruto - 042.mkv,Naruto - 042.mkv,500000,2024-01-01T00:00:00,anime,,,\n"
|
||||
)
|
||||
|
||||
output_json = tmp_path / "identities.json"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--config",
|
||||
str(tmp_path / "config.yaml"),
|
||||
"parse",
|
||||
"--input",
|
||||
str(inventory_csv),
|
||||
"--output",
|
||||
str(output_json),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
with open(output_json) as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert len(data["anime"]) == 1
|
||||
anime = data["anime"][0]
|
||||
assert anime["title"] == "Naruto"
|
||||
assert anime["season"] is None
|
||||
assert anime["episodes"] == [42]
|
||||
assert anime["needs_review"] is True
|
||||
|
||||
@@ -177,24 +177,24 @@ class TestQuarantineAddCommand:
|
||||
assert "Reason: duplicate file" in result.output
|
||||
assert "successfully quarantined" in result.output
|
||||
|
||||
def test_add_anime_file_rejected(self, config_file, temp_library):
|
||||
"""Test that anime files are rejected."""
|
||||
def test_add_anime_file_supported(self, config_file, temp_library):
|
||||
"""Test that anime files can be quarantined (configured category)."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test anime file
|
||||
anime_file = temp_library / "anime" / "Anime Show.mkv"
|
||||
anime_file.write_text("anime content")
|
||||
|
||||
# Try to quarantine (should fail)
|
||||
# Quarantine should succeed
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(anime_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "not supported" in result.output.lower()
|
||||
assert anime_file.exists() # File should still exist
|
||||
assert result.exit_code == 0
|
||||
assert "successfully quarantined" in result.output
|
||||
assert not anime_file.exists()
|
||||
|
||||
def test_add_nonexistent_file(self, config_file, temp_library):
|
||||
"""Test adding a file that doesn't exist."""
|
||||
|
||||
+174
-220
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vlm.config import Config, create_default_config, load_config, validate_config
|
||||
|
||||
@@ -334,277 +335,235 @@ class TestCreateDefaultConfig:
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Test validate_config function."""
|
||||
"""Test validation — with Pydantic, invalid values raise ValidationError at construction."""
|
||||
|
||||
def test_validate_valid_config(self):
|
||||
"""Test validating a valid configuration."""
|
||||
config = Config(library_root=Path("/mnt/nas/videos"))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_empty_library_root(self):
|
||||
"""Test validating config with empty library_root."""
|
||||
config = Config(library_root=Path(""))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("library_root" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="library_root"):
|
||||
Config(library_root=Path(""))
|
||||
|
||||
def test_validate_empty_video_extensions(self):
|
||||
"""Test validating config with empty video_extensions."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=[]
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("video_extensions" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="video_extensions"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), video_extensions=[])
|
||||
|
||||
def test_validate_invalid_video_extension_format(self):
|
||||
"""Test validating config with invalid video extension format."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=["mp4", ".mkv"] # Missing dot on first one
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="must start with"):
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=["mp4", ".mkv"],
|
||||
)
|
||||
|
||||
def test_validate_empty_templates(self):
|
||||
"""Test validating config with empty templates."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
movie_template="",
|
||||
series_template=""
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) >= 2
|
||||
assert any("movie_template" in err for err in errors)
|
||||
assert any("series_template" in err for err in errors)
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
movie_template="",
|
||||
series_template="",
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
fields = {e["loc"][0] for e in errors}
|
||||
assert "movie_template" in fields
|
||||
assert "series_template" in fields
|
||||
|
||||
def test_validate_invalid_log_level(self):
|
||||
"""Test validating config with invalid log level."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("log_level" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="log_level"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), log_level="INVALID")
|
||||
|
||||
def test_validate_valid_log_levels(self):
|
||||
"""Test validating config with all valid log levels."""
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
for level in valid_levels:
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level=level
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == [], f"Log level {level} should be valid"
|
||||
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
config = Config(library_root=Path("/mnt/nas/videos"), log_level=level)
|
||||
assert validate_config(config) == [], f"Log level {level} should be valid"
|
||||
|
||||
def test_validate_absolute_quarantine_dir(self):
|
||||
"""Test validating config with absolute quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must be relative" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="must be relative"):
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path",
|
||||
)
|
||||
|
||||
def test_validate_empty_quarantine_dir(self):
|
||||
"""Test validating config with empty quarantine_dir."""
|
||||
with pytest.raises(ValidationError, match="quarantine_dir"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
|
||||
|
||||
def test_workspace_dir_coerces_from_string(self):
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir=""
|
||||
workspace_dir="artifacts",
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("quarantine_dir" in err for err in errors)
|
||||
|
||||
def test_validate_workspace_dir_type(self):
|
||||
"""workspace_dir must be a Path object."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
workspace_dir="artifacts", # type: ignore[arg-type]
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("workspace_dir must be a Path object" in err for err in errors)
|
||||
assert config.workspace_dir == Path("artifacts")
|
||||
|
||||
def test_validate_multiple_errors(self):
|
||||
"""Test validating config with multiple errors."""
|
||||
config = Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID",
|
||||
)
|
||||
assert len(exc_info.value.errors()) >= 4
|
||||
|
||||
def test_validate_duplicate_keep_reputation_quality_time(self):
|
||||
"""Test validating config with by_reputation_quality_time strategy."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
duplicate_keep="by_reputation_quality_time"
|
||||
duplicate_keep="by_reputation_quality_time",
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(library_root=Path("/test"), categories={})
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]},
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="Duplicate.*videos"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="Duplicate"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
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"]
|
||||
}
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"],
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie",
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="cannot be empty"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_rejects_unsupported_enrichment_provider(self):
|
||||
"""Test validating config with unsupported enrichment provider."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("unsupported providers" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="unsupported providers"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
with pytest.raises(ValidationError, match="empty directory name"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_invalid_plan_thresholds(self):
|
||||
"""Plan season/episode thresholds must be positive integers."""
|
||||
config = Config(
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
)
|
||||
fields = {e["loc"][0] for e in exc_info.value.errors()}
|
||||
assert "plan_max_season" in fields
|
||||
assert "plan_max_episode" in fields
|
||||
|
||||
def test_validate_config_with_model_construct_bypass(self):
|
||||
"""validate_config catches errors bypassed via model_construct."""
|
||||
config = Config.model_construct(
|
||||
library_root=Path("/test"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
video_extensions=[],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
series_template="series/{title}/Season {season:02d}/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine",
|
||||
workspace_dir=Path("artifacts"),
|
||||
categories={"movie": ["movie"], "series": ["series"], "anime": ["anime"]},
|
||||
enrichment_enabled=True,
|
||||
enrichment_incremental=True,
|
||||
enrichment_refresh_mode="manual",
|
||||
enrichment_providers=["tmdb"],
|
||||
enrichment_cache_db=Path.home() / ".vlm" / "enrichment_cache.db",
|
||||
enrichment_max_concurrency=6,
|
||||
enrichment_min_match_score=0.75,
|
||||
translation_mode="bidirectional",
|
||||
translation_fallback_machine=True,
|
||||
tmdb_api_key=None,
|
||||
tmdb_bearer_token=None,
|
||||
tmdb_language="zh-CN",
|
||||
tmdb_region=None,
|
||||
tmdb_include_adult=False,
|
||||
openai_api_key=None,
|
||||
reputation_min_votes=50,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_policy="flag_for_review",
|
||||
naming_title_format="{title_zh} {title_en}",
|
||||
duplicate_keep="by_reputation",
|
||||
plan_max_season=15,
|
||||
plan_max_episode=100,
|
||||
plan_include_sample_files=False,
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("plan_max_season" in e for e in errors)
|
||||
assert any("plan_max_episode" in e for e in errors)
|
||||
assert any("video_extensions" in e for e in errors)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
@@ -654,10 +613,9 @@ class TestConfigIntegration:
|
||||
assert loaded_config.library_root == default_config.library_root
|
||||
|
||||
def test_validation_workflow(self, tmp_path):
|
||||
"""Test workflow: load config -> validate -> report errors."""
|
||||
"""Test workflow: load config with invalid values raises ValidationError."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
# Create config with some invalid values
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
||||
@@ -667,13 +625,9 @@ class TestConfigIntegration:
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Load config
|
||||
config = load_config(config_file)
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
load_config(config_file)
|
||||
|
||||
# Validate
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have errors
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
assert any("log_level" in err for err in errors)
|
||||
messages = [e["msg"] for e in exc_info.value.errors()]
|
||||
assert any("must start with" in m for m in messages)
|
||||
assert any("log_level" in m for m in messages)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.config import Config, validate_config
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vlm.config import Config
|
||||
|
||||
|
||||
def test_validate_rejects_non_positive_enrichment_concurrency():
|
||||
config = Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
errors = validate_config(config)
|
||||
assert any("enrichment_max_concurrency must be >= 1" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="enrichment_max_concurrency"):
|
||||
Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Verify vlm commands referenced in documentation exist in the CLI registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vlm.cli import main
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DOC_GLOBS = [
|
||||
"README.md",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
"skills/**/*.md",
|
||||
]
|
||||
|
||||
COMMAND_PATTERN = re.compile(
|
||||
r"^(?:uv run )?(?:\$ )?vlm\s+(.+)$", re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def _extract_doc_commands() -> list[str]:
|
||||
commands: list[str] = []
|
||||
for pattern in DOC_GLOBS:
|
||||
for path in REPO_ROOT.glob(pattern):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for match in COMMAND_PATTERN.finditer(text):
|
||||
raw = match.group(1).strip()
|
||||
raw = raw.rstrip("\\").strip()
|
||||
if raw.startswith("#") or not raw:
|
||||
continue
|
||||
commands.append(raw)
|
||||
return commands
|
||||
|
||||
|
||||
def _cli_command_names() -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
for name, cmd in main.commands.items():
|
||||
result[name] = []
|
||||
if hasattr(cmd, "commands"):
|
||||
result[name] = list(cmd.commands.keys())
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_cmd",
|
||||
sorted(set(_extract_doc_commands())),
|
||||
ids=lambda c: c[:60],
|
||||
)
|
||||
def test_documented_command_exists(raw_cmd: str):
|
||||
parts = raw_cmd.split()
|
||||
if not parts:
|
||||
pytest.skip("empty command")
|
||||
|
||||
first = parts[0]
|
||||
if first in ("--help", "-h"):
|
||||
return
|
||||
|
||||
registry = _cli_command_names()
|
||||
|
||||
if first not in registry:
|
||||
pytest.fail(f"Documented command 'vlm {first}' not in CLI registry")
|
||||
|
||||
if len(parts) > 1:
|
||||
sub = parts[1]
|
||||
if sub.startswith("-"):
|
||||
return
|
||||
subcommands = registry.get(first, [])
|
||||
if subcommands and sub not in subcommands:
|
||||
pytest.fail(
|
||||
f"Documented subcommand 'vlm {first} {sub}' "
|
||||
f"not in CLI registry (available: {subcommands})"
|
||||
)
|
||||
|
||||
|
||||
def test_fake_command_is_caught():
|
||||
registry = _cli_command_names()
|
||||
assert "nonexistent-command-xyz" not in registry
|
||||
+105
-15
@@ -7,7 +7,7 @@ import pytest
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.enrichment import _build_display_title, _build_providers, enrich_identities_data
|
||||
from vlm.providers.base import ProviderResult
|
||||
from vlm.providers.base import ProviderResult, RequestRateLimiter
|
||||
from vlm.providers.tmdb import TMDBAuthError
|
||||
|
||||
|
||||
@@ -38,14 +38,15 @@ def test_enrich_incremental_cache_hit(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [provider],
|
||||
lambda _config, request_timeout, retries, **_kwargs: [provider],
|
||||
)
|
||||
|
||||
identities = {
|
||||
@@ -104,16 +105,17 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_min_votes=50,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = LowScoreProvider()
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [provider],
|
||||
lambda _config, request_timeout, retries, **_kwargs: [provider],
|
||||
)
|
||||
|
||||
identities = {
|
||||
@@ -144,14 +146,15 @@ def test_enrich_refresh_all_bypasses_cache(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [provider],
|
||||
lambda _config, request_timeout, retries, **_kwargs: [provider],
|
||||
)
|
||||
|
||||
identities = {
|
||||
@@ -184,11 +187,12 @@ def test_build_providers_rejects_unknown_provider(tmp_path):
|
||||
"""Unknown providers should fail fast with a clear error."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_providers=["tmdb", "tmdb_typo"],
|
||||
enrichment_providers=["tmdb"],
|
||||
)
|
||||
config.enrichment_providers = ["tmdb", "tmdb_typo"]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported enrichment providers"):
|
||||
_build_providers(config, request_timeout=3, retries=1)
|
||||
_build_providers(config, request_timeout=3, retries=1, rate_limiter=RequestRateLimiter(0.25))
|
||||
|
||||
|
||||
def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
|
||||
@@ -220,14 +224,15 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = FlakyProvider()
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [provider],
|
||||
lambda _config, request_timeout, retries, **_kwargs: [provider],
|
||||
)
|
||||
|
||||
identities = {
|
||||
@@ -331,7 +336,7 @@ def test_enrich_auth_failure_stops_immediately(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [AuthFailProvider()],
|
||||
lambda _config, request_timeout, retries, **_kwargs: [AuthFailProvider()],
|
||||
)
|
||||
|
||||
identities = {
|
||||
@@ -361,10 +366,11 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
enrichment_max_concurrency=4,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
@@ -387,8 +393,14 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
|
||||
|
||||
thread_ids: set[int] = set()
|
||||
lock = threading.Lock()
|
||||
|
||||
def _fake_enrich(record, media_type, config_obj, request_timeout, retries):
|
||||
seen_limiters: list[object] = []
|
||||
def _fake_enrich(record, media_type, config_obj, request_timeout, retries, rate_limiter=None):
|
||||
with lock:
|
||||
if rate_limiter is not None:
|
||||
if not seen_limiters:
|
||||
seen_limiters.append(rate_limiter)
|
||||
else:
|
||||
assert rate_limiter is seen_limiters[0]
|
||||
time.sleep(0.01)
|
||||
with lock:
|
||||
thread_ids.add(threading.get_ident())
|
||||
@@ -420,3 +432,81 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
|
||||
assert stats["enriched"] == 8
|
||||
assert stats["cache_hits"] == 0
|
||||
assert len(thread_ids) > 1
|
||||
|
||||
|
||||
class _RateLimitedTMDB:
|
||||
name = "tmdb"
|
||||
|
||||
def __init__(self, api_key, *, rate_limiter=None, record_wait=None, wait_lock=None, **kwargs):
|
||||
self.rate_limiter = rate_limiter
|
||||
self.last_request_count = 0
|
||||
self._record_wait = record_wait
|
||||
self._wait_lock = wait_lock
|
||||
|
||||
def enrich(self, *, title, media_type, year=None):
|
||||
if self.rate_limiter is not None:
|
||||
self.rate_limiter.wait()
|
||||
if self._record_wait is not None and self._wait_lock is not None:
|
||||
with self._wait_lock:
|
||||
self._record_wait.append(time.monotonic())
|
||||
self.last_request_count = 1
|
||||
return ProviderResult(
|
||||
provider="tmdb",
|
||||
canonical_id=f"tmdb:{title}",
|
||||
title_zh="测试",
|
||||
title_en=title,
|
||||
translation_source="tmdb",
|
||||
)
|
||||
|
||||
|
||||
def test_enrich_concurrent_workers_share_provider_rate_limiter(tmp_path, monkeypatch):
|
||||
"""Concurrent TMDB providers must admit requests through one limiter."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["tmdb"],
|
||||
tmdb_api_key="fake",
|
||||
translation_fallback_machine=False,
|
||||
enrichment_max_concurrency=4,
|
||||
)
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
"movies": [
|
||||
{
|
||||
"path": f"/library/movie/Test.{i}.mkv",
|
||||
"filename": f"Test.{i}.mkv",
|
||||
"category": "movie",
|
||||
"title": f"Test {i}",
|
||||
"year": 2020,
|
||||
"confidence": 0.9,
|
||||
"needs_review": False,
|
||||
}
|
||||
for i in range(3)
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
shared_limiters: dict[str, object] = {}
|
||||
wait_times: list[float] = []
|
||||
wait_lock = threading.Lock()
|
||||
|
||||
def _factory(api_key, **kwargs):
|
||||
limiter = kwargs.pop("rate_limiter")
|
||||
if "limiter" not in shared_limiters:
|
||||
shared_limiters["limiter"] = limiter
|
||||
else:
|
||||
assert limiter is shared_limiters["limiter"]
|
||||
return _RateLimitedTMDB(api_key, rate_limiter=limiter, record_wait=wait_times, wait_lock=wait_lock)
|
||||
|
||||
monkeypatch.setattr("vlm.enrichment.TMDBProvider", _factory)
|
||||
|
||||
_, stats = enrich_identities_data(identities, config, refresh_mode="refresh_all")
|
||||
assert stats["enriched"] == 3
|
||||
assert len(wait_times) == 3
|
||||
# Shared limiter must serialize request starts at >= 0.25s apart
|
||||
# (allow small tolerance for thread scheduling).
|
||||
for earlier, later in zip(wait_times, wait_times[1:]):
|
||||
assert later - earlier >= 0.2
|
||||
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
from vlm.logging_config import (
|
||||
MAX_LOG_SIZE,
|
||||
default_log_dir,
|
||||
get_logger,
|
||||
log_operation,
|
||||
setup_logging,
|
||||
)
|
||||
@@ -239,28 +238,6 @@ class TestLogRotation:
|
||||
assert log_file.stat().st_size < MAX_LOG_SIZE
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
"""Test get_logger function."""
|
||||
|
||||
def test_get_logger_returns_logger(self):
|
||||
"""Test that get_logger returns a logger instance."""
|
||||
logger = get_logger()
|
||||
|
||||
assert logger is not None
|
||||
assert logger.name == "vlm"
|
||||
|
||||
def test_get_logger_creates_default_config(self):
|
||||
"""Test that get_logger creates default configuration if needed."""
|
||||
# Clear any existing handlers
|
||||
logger = logging.getLogger("vlm")
|
||||
logger.handlers.clear()
|
||||
|
||||
# Get logger should set up default configuration
|
||||
logger = get_logger()
|
||||
|
||||
assert len(logger.handlers) > 0
|
||||
|
||||
|
||||
class TestLogOperation:
|
||||
"""Test log_operation helper function."""
|
||||
|
||||
|
||||
@@ -76,6 +76,63 @@ def test_build_identity_lookup_and_enrich(tmp_path):
|
||||
assert enriched[0]["source_name"] == "Show.S01E01.mkv"
|
||||
|
||||
|
||||
def test_enrich_review_rows_sidecars_column(tmp_path):
|
||||
op = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=tmp_path / "dl/Show.S01E01.mkv",
|
||||
destination_path=tmp_path / "lib/series/Show/Season 01/S01E01.mkv",
|
||||
reason="organize",
|
||||
has_conflict=False,
|
||||
review_context={
|
||||
"title": "Show",
|
||||
"category": "series",
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"sidecars": [
|
||||
{"name": "Show.S01E01.zh.srt", "source_path": str(tmp_path / "dl/Show.S01E01.zh.srt"), "proposed_destination_path": str(tmp_path / "lib/series/Show/Season 01/Show.S01E01.zh.srt")},
|
||||
{"name": "Show.S01E01.nfo", "source_path": str(tmp_path / "dl/Show.S01E01.nfo"), "proposed_destination_path": str(tmp_path / "lib/series/Show/Season 01/Show.S01E01.nfo")},
|
||||
],
|
||||
},
|
||||
)
|
||||
plan = _minimal_plan([op])
|
||||
rows = [
|
||||
{
|
||||
"index": "1",
|
||||
"operation_type": "move",
|
||||
"risk_flags": "",
|
||||
"source_path": str(op.source_path),
|
||||
"destination_path": str(op.destination_path),
|
||||
"reason": op.reason,
|
||||
}
|
||||
]
|
||||
enriched = enrich_review_rows(rows, plan, library_root=tmp_path / "lib")
|
||||
assert enriched[0]["sidecars"] == "Show.S01E01.zh.srt, Show.S01E01.nfo"
|
||||
|
||||
|
||||
def test_enrich_review_rows_sidecars_empty_by_default(tmp_path):
|
||||
op = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=tmp_path / "dl/Show.S01E01.mkv",
|
||||
destination_path=tmp_path / "lib/series/Show/Season 01/S01E01.mkv",
|
||||
reason="organize",
|
||||
has_conflict=False,
|
||||
review_context={"title": "Show", "category": "series", "season": 1, "episode": 1},
|
||||
)
|
||||
plan = _minimal_plan([op])
|
||||
rows = [
|
||||
{
|
||||
"index": "1",
|
||||
"operation_type": "move",
|
||||
"risk_flags": "",
|
||||
"source_path": str(op.source_path),
|
||||
"destination_path": str(op.destination_path),
|
||||
"reason": op.reason,
|
||||
}
|
||||
]
|
||||
enriched = enrich_review_rows(rows, plan, library_root=tmp_path / "lib")
|
||||
assert enriched[0].get("sidecars", "") == ""
|
||||
|
||||
|
||||
def test_check_review_requirements_missing_csv(tmp_path):
|
||||
plan_path = tmp_path / "plan.json"
|
||||
op = FileOperation(
|
||||
@@ -137,6 +194,30 @@ def test_check_review_requirements_passes_after_apply_review(tmp_path):
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_check_review_requirements_detects_csv_path_mismatch(tmp_path):
|
||||
plan_path = tmp_path / "plan.json"
|
||||
csv_path = tmp_path / "review.csv"
|
||||
other_csv = tmp_path / "other.csv"
|
||||
op = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=tmp_path / "a.mkv",
|
||||
destination_path=tmp_path / "lib/a.mkv",
|
||||
reason="Series needs manual review (season exceeds configured threshold)",
|
||||
has_conflict=True,
|
||||
)
|
||||
plan = _minimal_plan([op])
|
||||
save_plan(plan, plan_path)
|
||||
rows, _ = review_plan(plan)
|
||||
save_review_csv(rows, csv_path)
|
||||
save_review_csv(rows, other_csv)
|
||||
|
||||
updated = apply_review_to_plan(load_plan(plan_path), csv_path)
|
||||
save_plan(updated, plan_path)
|
||||
|
||||
errors = check_review_requirements(load_plan(plan_path), plan_path, other_csv)
|
||||
assert any("does not match" in e for e in errors)
|
||||
|
||||
|
||||
def test_planner_noop_manual_review_does_not_block_execute_gate(tmp_path):
|
||||
plan_path = tmp_path / "plan.json"
|
||||
op = FileOperation(
|
||||
|
||||
@@ -63,6 +63,92 @@ def test_generate_plan_for_movie_with_year(config):
|
||||
assert "Some Movie (2020)" in operation.reason
|
||||
|
||||
|
||||
|
||||
def test_generate_plan_attaches_sidecar_context(tmp_path):
|
||||
"""Test move operations carry review-visible sidecar associations."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
video_extensions=[".mp4", ".mkv", ".avi"],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
)
|
||||
|
||||
src_dir = tmp_path / "downloads"
|
||||
src_dir.mkdir()
|
||||
video = src_dir / "Movie (2020).mkv"
|
||||
video.touch()
|
||||
sub = src_dir / "Movie (2020).zh.srt"
|
||||
sub.touch()
|
||||
nfo = src_dir / "Movie (2020).nfo"
|
||||
nfo.touch()
|
||||
unrelated = src_dir / "Movie (2020).1.srt"
|
||||
unrelated.touch()
|
||||
|
||||
video_file = VideoFile(
|
||||
path=video,
|
||||
filename=video.name,
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename=video.name,
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "move"
|
||||
sidecars = operation.review_context.get("sidecars")
|
||||
assert sidecars is not None
|
||||
assert [s["name"] for s in sidecars] == ["Movie (2020).nfo", "Movie (2020).zh.srt"]
|
||||
dest_dir = operation.destination_path.parent
|
||||
by_name = {s["name"]: s for s in sidecars}
|
||||
assert by_name["Movie (2020).zh.srt"]["proposed_destination_path"] == str(dest_dir / "Movie (2020).zh.srt")
|
||||
assert by_name["Movie (2020).nfo"]["proposed_destination_path"] == str(dest_dir / "Movie (2020).nfo")
|
||||
# Numeric-suffix file must not be associated
|
||||
assert all(s["name"] != "Movie (2020).1.srt" for s in sidecars)
|
||||
|
||||
|
||||
def test_generate_plan_no_sidecars_for_noop(tmp_path):
|
||||
"""Test no-op operations carry no sidecar context."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
video_extensions=[".mp4", ".mkv", ".avi"],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
)
|
||||
|
||||
src_dir = tmp_path / "downloads"
|
||||
src_dir.mkdir()
|
||||
video = src_dir / "random_movie.mkv"
|
||||
video.touch()
|
||||
sub = src_dir / "random_movie.srt"
|
||||
sub.touch()
|
||||
|
||||
video_file = VideoFile(
|
||||
path=video,
|
||||
filename=video.name,
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Random Movie",
|
||||
year=None,
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename=video.name,
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert "sidecars" not in (operation.review_context or {})
|
||||
|
||||
def test_generate_plan_for_movie_without_year(config):
|
||||
"""Test plan generation for a movie without year (needs review)."""
|
||||
video_file = VideoFile(
|
||||
@@ -263,6 +349,36 @@ def test_generate_plan_for_anime_category(config):
|
||||
assert "anime" in operation.reason.lower() or "not organized" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_for_anime_with_identity(config):
|
||||
"""Test plan generation for anime files with parsed identity uses anime templates."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/anime/Some.Anime.S01E05.mkv"),
|
||||
filename="Some.Anime.S01E05.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="anime"
|
||||
)
|
||||
|
||||
identity = SeriesIdentity(
|
||||
title="Some Anime",
|
||||
season=1,
|
||||
episodes=[5],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Some.Anime.S01E05.mkv",
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type in ("move", "rename")
|
||||
assert operation.destination_path is not None
|
||||
assert "anime" in str(operation.destination_path).lower()
|
||||
assert "series" not in str(operation.destination_path).lower()
|
||||
assert "Some_Anime" in operation.destination_path.name or "Some Anime" in str(operation.destination_path)
|
||||
|
||||
|
||||
def test_generate_plan_for_other_category(config):
|
||||
"""Test plan generation for other category files (no-op in v1)."""
|
||||
video_file = VideoFile(
|
||||
|
||||
@@ -82,19 +82,26 @@ class TestQuarantineManager:
|
||||
assert expected_quarantine_path.exists()
|
||||
assert expected_quarantine_path.read_text() == "test content"
|
||||
|
||||
def test_quarantine_anime_file_rejected(self, manager, config):
|
||||
"""Test that quarantining anime files returns a failed result."""
|
||||
def test_quarantine_anime_file_round_trip(self, manager, config):
|
||||
"""Test that quarantining and restoring anime files works."""
|
||||
# Create a test anime file
|
||||
anime_file = config.library_root / "anime" / "Test Anime.mkv"
|
||||
anime_file.write_text("test content")
|
||||
|
||||
result = manager.quarantine_file(anime_file)
|
||||
|
||||
assert result.success is False
|
||||
assert "Quarantine not supported for category 'anime'" in (result.error_message or "")
|
||||
assert result.success is True
|
||||
expected_quarantine_path = config.library_root / "anime" / ".quarantine" / "Test Anime.mkv"
|
||||
assert not anime_file.exists()
|
||||
assert expected_quarantine_path.exists()
|
||||
assert expected_quarantine_path.read_text() == "test content"
|
||||
|
||||
# Verify file was not moved
|
||||
# Restore and verify round trip
|
||||
restore_result = manager.restore_from_quarantine(expected_quarantine_path)
|
||||
assert restore_result.success is True
|
||||
assert anime_file.exists()
|
||||
assert anime_file.read_text() == "test content"
|
||||
assert not expected_quarantine_path.exists()
|
||||
|
||||
def test_quarantine_other_file_rejected(self, manager, config):
|
||||
"""Test that quarantining other files returns a failed result."""
|
||||
@@ -590,7 +597,7 @@ class TestQuarantineListing:
|
||||
|
||||
def test_list_quarantined_invalid_category(self, manager, config):
|
||||
"""Test listing with invalid category returns empty list."""
|
||||
entries = manager.list_quarantined(category="anime")
|
||||
entries = manager.list_quarantined(category="unconfigured")
|
||||
assert entries == []
|
||||
|
||||
entries = manager.list_quarantined(category="other")
|
||||
@@ -906,7 +913,7 @@ class TestQuarantineRestoration:
|
||||
category = manager._determine_category_from_quarantine(outside)
|
||||
assert category is None
|
||||
|
||||
# Unsupported category
|
||||
anime = config.library_root / "anime" / ".quarantine" / "Anime.mkv"
|
||||
category = manager._determine_category_from_quarantine(anime)
|
||||
# Unsupported category (directory not in configured categories)
|
||||
other = config.library_root / "other" / ".quarantine" / "Other.mkv"
|
||||
category = manager._determine_category_from_quarantine(other)
|
||||
assert category is None
|
||||
|
||||
+154
-145
@@ -24,32 +24,54 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestInventoryReport:
|
||||
"""Test inventory report generation."""
|
||||
|
||||
def test_generate_csv_report(self):
|
||||
"""Test generating CSV format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Show.S01E01.mkv"),
|
||||
"Show.S01E01.mkv",
|
||||
1000000000,
|
||||
datetime(2023, 2, 20, 14, 45, 0),
|
||||
"series",
|
||||
resolution="1280x720",
|
||||
codec="h265"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=datetime(2023, 2, 20, 14, 45, 0),
|
||||
resolution="1280x720", codec="h265"),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -90,22 +112,11 @@ class TestInventoryReport:
|
||||
def test_generate_json_report(self):
|
||||
"""Test generating JSON format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/anime/Anime1.mkv"),
|
||||
"Anime1.mkv",
|
||||
800000000,
|
||||
datetime(2023, 3, 10, 8, 15, 0),
|
||||
"anime"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264"),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=datetime(2023, 3, 10, 8, 15, 0)),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -182,13 +193,8 @@ class TestInventoryReport:
|
||||
def test_csv_schema_columns(self):
|
||||
"""Test that CSV has all required columns in correct order."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=datetime.now(timezone.utc)),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
@@ -210,13 +216,8 @@ class TestInventoryReport:
|
||||
"""Test that timestamps are formatted as ISO 8601."""
|
||||
naive_local = datetime(2023, 6, 15, 14, 30, 45)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
@@ -238,13 +239,8 @@ class TestInventoryReport:
|
||||
expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
report = generate_inventory_report(files, "json", Path("/test"))
|
||||
data = json.loads(report)
|
||||
@@ -263,9 +259,18 @@ class TestCompletenessReport:
|
||||
def test_generate_text_report_with_gaps(self):
|
||||
"""Test generating text format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("Breaking Bad", 2, [1, 3, 5], [2, 4]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=1,
|
||||
episodes_found=[1, 2, 4, 5], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=2,
|
||||
episodes_found=[1, 3, 5], episodes_missing=[2, 4],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="The Wire", season=1,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -290,8 +295,14 @@ class TestCompletenessReport:
|
||||
def test_generate_json_report_with_gaps(self):
|
||||
"""Test generating JSON format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=1,
|
||||
episodes_found=[1, 2, 4, 5], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="The Wire", season=1,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -349,9 +360,18 @@ class TestCompletenessReport:
|
||||
def test_multiple_seasons_same_series(self):
|
||||
"""Test report with multiple seasons of same series."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Show Name", 1, [1, 3], [2]),
|
||||
SeasonCompleteness("Show Name", 2, [1, 2, 4], [3]),
|
||||
SeasonCompleteness("Show Name", 3, [5, 7], [6]),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=1,
|
||||
episodes_found=[1, 3], episodes_missing=[2],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=2,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=3,
|
||||
episodes_found=[5, 7], episodes_missing=[6],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -369,31 +389,18 @@ class TestDuplicateReport:
|
||||
|
||||
def test_generate_text_report_with_duplicates(self):
|
||||
"""Test generating text format duplicate report."""
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
]
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
@@ -416,7 +423,7 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identities[0], files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
@@ -442,23 +449,15 @@ class TestDuplicateReport:
|
||||
|
||||
def test_generate_json_report_with_duplicates(self):
|
||||
"""Test generating JSON format duplicate report."""
|
||||
identity = MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.1080p.mkv")
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _movie("Inception", 2010,
|
||||
original_filename="Inception.2010.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.1080p.mkv"),
|
||||
"Inception.2010.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.720p.mkv"),
|
||||
"Inception.2010.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("Inception.2010.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Inception.2010.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
@@ -467,7 +466,7 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
@@ -495,23 +494,15 @@ class TestDuplicateReport:
|
||||
|
||||
def test_generate_text_report_series_duplicates(self):
|
||||
"""Test generating text report with series duplicates."""
|
||||
identity = SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv")
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
@@ -520,7 +511,7 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
@@ -562,21 +553,28 @@ class TestDuplicateReport:
|
||||
|
||||
def test_sorted_by_file_size(self):
|
||||
"""Test that duplicate groups are sorted by largest file size."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Create two duplicate groups with different sizes
|
||||
identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv")
|
||||
identity1 = _movie("Small Movie", 2020,
|
||||
original_filename="Small.Movie.mkv")
|
||||
files1 = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Small.Movie.1.mkv", 500_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Small.Movie.2.mkv", 600_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000},
|
||||
{'filename': 'Small.Movie.2.mkv', 'path': '/movies/Small.Movie.2.mkv', 'size_bytes': 600000000}
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv")
|
||||
identity2 = _movie("Large Movie", 2021,
|
||||
original_filename="Large.Movie.mkv")
|
||||
files2 = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Large.Movie.1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Large.Movie.2.mkv", 1_800_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000},
|
||||
@@ -584,8 +582,8 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2)
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
@@ -599,20 +597,21 @@ class TestDuplicateReport:
|
||||
|
||||
def test_sorted_by_quality_size_when_file_sizes_missing(self):
|
||||
"""Sort order should use quality_comparison sizes when VideoFile sizes are zero."""
|
||||
identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv")
|
||||
now = datetime.now(timezone.utc)
|
||||
identity1 = _movie("Tiny", 2020, original_filename="Tiny.mkv")
|
||||
files1 = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Tiny.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Tiny.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000},
|
||||
{"filename": "Tiny.2.mkv", "path": "/movies/Tiny.2.mkv", "size_bytes": 500000000},
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv")
|
||||
identity2 = _movie("Huge", 2021, original_filename="Huge.mkv")
|
||||
files2 = [
|
||||
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(timezone.utc), "movie"),
|
||||
_video("Huge.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Huge.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
|
||||
@@ -620,8 +619,8 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2),
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2),
|
||||
]
|
||||
report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos"))
|
||||
assert report.find("Huge") < report.find("Tiny")
|
||||
@@ -632,13 +631,20 @@ class TestSummaryReport:
|
||||
|
||||
def test_generate_summary_report(self):
|
||||
"""Test generating summary report with various files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "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(timezone.utc), "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(timezone.utc), "anime"),
|
||||
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "other"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=now),
|
||||
_video("Random.mkv", 500_000_000, "other",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
@@ -677,9 +683,12 @@ class TestSummaryReport:
|
||||
|
||||
def test_generate_summary_report_single_category(self):
|
||||
"""Test generating summary report with files in single category."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Movie1.mkv", 1_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
@@ -16,6 +16,41 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestReportsIntegration:
|
||||
"""Test report generation integrated with analysis engine."""
|
||||
|
||||
@@ -23,11 +58,16 @@ class TestReportsIntegration:
|
||||
"""Test complete workflow from series analysis to completeness report."""
|
||||
# Create test episodes with gaps
|
||||
episodes = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [4], 0.9, False, "Breaking.Bad.S01E04.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [1], 0.9, False, "The.Wire.S01E01.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [3], 0.9, False, "The.Wire.S01E03.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.mkv"),
|
||||
_series("Breaking Bad", episodes=[2],
|
||||
original_filename="Breaking.Bad.S01E02.mkv"),
|
||||
_series("Breaking Bad", episodes=[4],
|
||||
original_filename="Breaking.Bad.S01E04.mkv"),
|
||||
_series("The Wire", episodes=[1],
|
||||
original_filename="The.Wire.S01E01.mkv"),
|
||||
_series("The Wire", episodes=[3],
|
||||
original_filename="The.Wire.S01E03.mkv"),
|
||||
]
|
||||
|
||||
# Analyze completeness
|
||||
@@ -53,38 +93,22 @@ class TestReportsIntegration:
|
||||
def test_duplicate_workflow(self):
|
||||
"""Test complete workflow from duplicate detection to duplicate report."""
|
||||
# Create test identities and files
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080", codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720", codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
# Detect duplicates
|
||||
@@ -111,12 +135,18 @@ class TestReportsIntegration:
|
||||
def test_summary_workflow(self):
|
||||
"""Test summary report generation with mixed file types."""
|
||||
# Create test files
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "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(timezone.utc), "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(timezone.utc), "anime"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
# Generate summary report
|
||||
|
||||
+7
-77
@@ -110,8 +110,8 @@ 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."""
|
||||
def test_scan_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from discovery."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
@@ -122,82 +122,12 @@ class TestScanLibrary:
|
||||
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)
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_keeps_partial_find_results_when_find_exits_nonzero(self, tmp_path):
|
||||
"""Non-zero find exits should keep partial stdout and log the contract."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
visible_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
assert any("using 1 partial scan result" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
def test_scan_returns_empty_when_find_exits_nonzero_without_stdout(self, tmp_path):
|
||||
"""Non-zero find exits without stdout should produce an empty result deterministically."""
|
||||
(tmp_path / "movie").mkdir()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (b"", b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert result == []
|
||||
assert any("produced no scan results" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
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"
|
||||
@@ -356,7 +286,7 @@ class TestScanLibrary:
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=_fake_create,
|
||||
):
|
||||
), patch("shutil.which", return_value="/usr/bin/ffprobe"):
|
||||
result = scan_library(tmp_path, config, include_video_metadata=True)
|
||||
|
||||
assert len(result) == len(fake_paths)
|
||||
@@ -736,7 +666,7 @@ class TestExtractMetadata:
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
with patch('shutil.which', return_value='/usr/bin/ffprobe'), patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
@@ -823,7 +753,7 @@ class TestExtractMetadata:
|
||||
metadata_cache = {str(vf.path): vf for vf in cached_entries}
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("shutil.which", return_value="/usr/bin/ffprobe"), patch("subprocess.run") as mock_run:
|
||||
result = scan_library(tmp_path, config, metadata_cache=metadata_cache)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
@@ -146,7 +155,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -252,6 +261,137 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -484,6 +624,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uc-micro-py"
|
||||
version = "2.0.0"
|
||||
@@ -499,6 +651,7 @@ version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
@@ -517,6 +670,7 @@ tui = [
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user