Compare commits

...
6 Commits
Author SHA1 Message Date
windyboyandClaude Sonnet 4.5 9814b2b917 feat: enable anime parsing and organization with dedicated templates
Anime files with SxxEyy format are now parsed and organized into
anime-specific directories using configurable templates. Files with
absolute episode numbering (no season info) are marked needs_review
for manual handling. Also removes unused imports flagged by ruff.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-27 11:08:15 +08:00
windyboyandClaude Sonnet 4.5 8832f5da3f refactor: unify logging to standard logging.getLogger(__name__) pattern
Replace custom get_logger() wrapper with standard Python logging pattern
across executor.py and quarantine.py. Remove dead logger imports from
reports.py. Remove get_logger() function from logging_config.py and its
tests. Reduces logging patterns from 2 to 1.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-27 10:53:56 +08:00
windyboyandClaude Sonnet 4.5 fe03a31dd4 refactor: DLO-16/17/18/20 — CLI simplification, config Pydantic, planner split, type system unification
DLO-16: Reduce cli.py from 1073 to 83 lines by registering Click commands from commands/*.py modules
DLO-17: Migrate Config to Pydantic BaseModel for validation
DLO-18: Split planner.py (826 lines) into orchestration, path rendering, and duplicate handling modules
DLO-20: Unify type system — convert 14 dataclasses to Pydantic BaseModel, keep TypedDicts as JSON schema hints

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-27 10:47:04 +08:00
windyboyandClaude Sonnet 4.5 8a60aaf9a9 docs: consolidate CLAUDE.md into AGENTS.md and simplify
Merge detailed content from CLAUDE.md into AGENTS.md, then condense
to essential information. Removes Claude-specific documentation in
favor of tool-agnostic guidelines.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-25 14:05:44 +08:00
windyboyandClaude Sonnet 4.5 0f0636bf19 fix: complete rate limiter integration and report command structure
- Add RequestRateLimiter class to providers/base.py with wait() method
- Integrate rate limiter through enrichment pipeline (enrich_identities_data → _enrich_record_with_fresh_providers → _build_providers → TMDBProvider)
- Add _cmd wrapper functions to commands/report.py for CLI imports
- Fix SeriesIdentity import in reports.py
- Add sidecar file tracking to planner review_context for move operations
- Update tests to match new rate limiter signature

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-25 14:03:01 +08:00
windyboy dfa18ed405 refactor: consolidate skill docs, add anti-drift tests, and apply audit fixes
DLO-13: Restructure vlm-library-workflow skill as safety contract layer.
- Rewrite SKILL.md (69 lines): safety contract, execution threshold semantics,
  six-step high-risk loop, decision rules, phase skeleton
- Delete redundant references (cli-reference, workflow, command-recipes, dev-guide)
- Add triage.md (failure mapping + preflight) and dev-map.md (module→test mapping)
- Add tests/test_docs_consistency.py: 78 parametrized tests verifying documented
  vlm commands exist in CLI registry
- Add CSV path mismatch test to test_plan_review.py (4th safety gate path)
- Delete vlm-expert.skill (Gemini package, 7 months stale) and README Gemini section

DLO-2 audit fixes: rate limiter injection, symmetric quarantine categories,
review-plan safety gates, parser improvements, planner validation.

CLI modularization: commands/ directory with one module per command group.
2026-09-25 13:50:09 +08:00
60 changed files with 3471 additions and 3701 deletions
+79 -37
View File
@@ -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`
+11
View File
@@ -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
-272
View File
@@ -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>`
+3 -18
View File
@@ -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
+1 -1
View File
@@ -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).
+1
View File
@@ -11,6 +11,7 @@ authors = [
dependencies = [
"click>=8.1.0",
"pyyaml>=6.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
+43 -70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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
+22 -1
View File
@@ -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,
)
+30 -2
View File
@@ -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)
+36 -1
View File
@@ -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,
)
+49 -1
View File
@@ -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")
+37 -11
View File
@@ -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"],
identity = parse_anime(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"category": category,
"note": "Anime parsing deferred in v1",
"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)
+15 -1
View File
@@ -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)
+32 -1
View File
@@ -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)
+125 -276
View File
@@ -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],
) -> None:
"""Generate inventory report."""
config = ctx.config
logger = ctx.logger
try:
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")
click.echo()
click.echo(f"Generating inventory report in {format} format...")
click.echo(f"Loaded {len(video_files)} files\n")
report_format = "csv" if format == "text" else format
report_content = generate_inventory_report(video_files, report_format, config.library_root)
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)
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 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}")
except Exception as e:
command_error(
ctx,
f"Error generating inventory report: {e}",
f"Inventory report generation failed: {e}",
exc_info=True,
def _run_completeness(
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)
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))
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))
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))
@click.group()
@pass_context
def report(ctx: CLIContext):
"""Generate inventory, completeness, duplicate, and summary reports."""
@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")
@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")
+58 -1
View File
@@ -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)
+23 -1
View File
@@ -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",
)
+41 -1
View File
@@ -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
View File
@@ -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()]
+8 -1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -479,6 +479,9 @@ 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:
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))
-15
View File
@@ -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
View File
@@ -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]
+74
View File
@@ -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.
+60
View File
@@ -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
+335
View File
@@ -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
)
+6
View File
@@ -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
View File
@@ -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"]
+29 -4
View File
@@ -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):
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+98 -65
View File
@@ -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
+76
View File
@@ -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
+6 -6
View File
@@ -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."""
+130 -176
View File
@@ -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(
with pytest.raises(ValidationError, match="must start with"):
Config(
library_root=Path("/mnt/nas/videos"),
video_extensions=["mp4", ".mkv"] # Missing dot on first one
video_extensions=["mp4", ".mkv"],
)
errors = validate_config(config)
assert len(errors) > 0
assert any("must start with '.'" in err for err in errors)
def test_validate_empty_templates(self):
"""Test validating config with empty templates."""
config = Config(
with pytest.raises(ValidationError) as exc_info:
Config(
library_root=Path("/mnt/nas/videos"),
movie_template="",
series_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)
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(
with pytest.raises(ValidationError, match="must be relative"):
Config(
library_root=Path("/mnt/nas/videos"),
quarantine_dir="/absolute/path"
quarantine_dir="/absolute/path",
)
errors = validate_config(config)
assert len(errors) > 0
assert any("must be relative" in err for err in errors)
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(
with pytest.raises(ValidationError) as exc_info:
Config(
library_root=Path(""),
video_extensions=[],
movie_template="",
log_level="INVALID"
log_level="INVALID",
)
errors = validate_config(config)
# Should have multiple errors
assert len(errors) >= 4
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(
with pytest.raises(ValidationError, match="categories"):
Config(
library_root=Path("/test"),
categories={"movie": ["movie"]} # Missing series, anime
categories={"movie": ["movie"]},
)
errors = validate_config(config)
assert any("series" in e or "anime" in e for e in errors)
def test_validate_duplicate_directory_names(self):
"""Test validating config with duplicate directory names."""
config = Config(
with pytest.raises(ValidationError, match="Duplicate.*videos"):
Config(
library_root=Path("/test"),
categories={
"movie": ["movie", "videos"],
"series": ["series", "videos"], # Duplicate
"anime": ["anime"]
}
"series": ["series", "videos"],
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("Duplicate" in e and "videos" in e for e in errors)
def test_validate_case_insensitive_duplicates(self):
"""Test validating config with case-insensitive duplicates."""
config = Config(
with pytest.raises(ValidationError, match="Duplicate"):
Config(
library_root=Path("/test"),
categories={
"movie": ["Movie"],
"series": ["movie"], # Case-insensitive duplicate
"anime": ["anime"]
}
"series": ["movie"],
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("Duplicate" in e for e in errors)
def test_validate_valid_custom_categories(self):
"""Test validating config with valid custom categories."""
config = Config(
library_root=Path("/test"),
categories={
"movie": ["movie", "movies"],
"series": ["series", "tv"],
"anime": ["anime"]
}
"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(
with pytest.raises(ValidationError):
Config(
library_root=Path("/test"),
categories=["movie", "series"] # Wrong type
categories=["movie", "series"],
)
errors = validate_config(config)
assert any("must be a dictionary" in e for e in errors)
def test_validate_category_list_not_list(self):
"""Test validating config with category value not a list."""
config = Config(
with pytest.raises(ValidationError):
Config(
library_root=Path("/test"),
categories={
"movie": "movie", # Should be a list
"movie": "movie",
"series": ["series"],
"anime": ["anime"]
}
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("must be a list" in e for e in errors)
def test_validate_empty_category_list(self):
"""Test validating config with empty category list."""
config = Config(
with pytest.raises(ValidationError, match="cannot be empty"):
Config(
library_root=Path("/test"),
categories={
"movie": [], # Empty list
"movie": [],
"series": ["series"],
"anime": ["anime"]
}
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("cannot be empty" in e for e in errors)
def test_validate_rejects_unsupported_enrichment_provider(self):
"""Test validating config with unsupported enrichment provider."""
config = Config(
with pytest.raises(ValidationError, match="unsupported providers"):
Config(
library_root=Path("/test"),
enrichment_providers=["tmdb", "douban"],
)
errors = validate_config(config)
assert any("unsupported providers" in e for e in errors)
def test_validate_category_list_with_non_string(self):
"""Test validating config with non-string in category list."""
config = Config(
with pytest.raises(ValidationError):
Config(
library_root=Path("/test"),
categories={
"movie": ["movie", 123], # Non-string
"movie": ["movie", 123],
"series": ["series"],
"anime": ["anime"]
}
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("must contain strings" in e for e in errors)
def test_validate_category_list_with_empty_string(self):
"""Test validating config with empty string in category list."""
config = Config(
with pytest.raises(ValidationError, match="empty directory name"):
Config(
library_root=Path("/test"),
categories={
"movie": ["movie", ""], # Empty string
"movie": ["movie", ""],
"series": ["series"],
"anime": ["anime"]
}
"anime": ["anime"],
},
)
errors = validate_config(config)
assert any("empty directory name" in e for e in errors)
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"),
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)
+6 -4
View File
@@ -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)
+81
View File
@@ -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
View File
@@ -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
-23
View File
@@ -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."""
+81
View File
@@ -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(
+116
View File
@@ -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(
+16 -9
View File
@@ -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
View File
@@ -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")
+68 -38
View File
@@ -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
+5 -75
View File
@@ -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,81 +122,11 @@ 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)
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."""
@@ -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
Generated
+155 -1
View File
@@ -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" },
BIN
View File
Binary file not shown.