Enhance project structure and add new files for enrichment and analysis

- Updated AGENTS.md to reflect changes in CLI commands and module organization, including the addition of an enrichment step and new functional modules.
- Introduced analysis.json, identities.json, inventory.csv, and plan.json to support enriched metadata and execution planning.
- Added CODE_IMPROVEMENTS.md to document identified code issues and proposed solutions for future enhancements.
- Updated README.md to include new enrichment features and configuration options.
- Removed unused dependency on ffmpeg-python from pyproject.toml.

These changes improve the overall functionality and maintainability of the Video Library Manager project.
This commit is contained in:
windyboy
2026-02-10 16:56:17 +08:00
parent f0c951ad7f
commit dcd87754cf
39 changed files with 145509 additions and 642 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
## Project Structure & Module Organization
- Core package lives in `src/vlm/`.
- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script).
- Functional modules are split by concern: scanning (`scanner.py`), parsing (`parser.py`), analysis/planning/execution (`analysis.py`, `planner.py`, `executor.py`), state/reporting/logging (`state.py`, `reports.py`, `logging_config.py`).
- Tests live in `tests/` and mirror feature areas (for example `tests/test_scanner.py`, `tests/test_cli_state.py`).
- CLI entrypoint is `src/vlm/cli.py` (`vlm` console script). Commands use `pass_context` and `CLIContext` from `context.py`; some command logic lives in `commands/` (e.g. `scan`, `analyze`, `plan`).
- 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`.
## Build, Test, and Development Commands
+13 -5
View File
@@ -40,6 +40,7 @@ vlm config init
# Common workflow
vlm scan # Discover files
vlm parse # Extract identities
vlm enrich # (Optional) Enrich titles/reputation via TMDB
vlm analyze # Detect gaps/duplicates
vlm plan # Generate execution plan
vlm execute # Dry-run (default)
@@ -52,15 +53,21 @@ vlm execute --confirm # Actually execute
VLM follows a read-first, multi-stage pipeline:
1. **Scan** → discovers video files, extracts metadata via ffprobe (optional), saves to inventory.csv
2. **Parse** → extracts titles/years/seasons/episodes from filenames, saves to identities.json
3. **Analyze** → detects episode gaps and duplicates, saves to analysis.json
4. **Plan**generates reviewable execution plan (plan.json) with file operations
5. **Execute**performs file operations (dry-run by default, --confirm to execute)
6. **Rollback** → reverses executed operations (best-effort)
3. **Enrich** (optional) → adds bilingual titles and reputation (TMDB); updates identities.json in place; uses SQLite cache for incremental runs
4. **Analyze**detects episode gaps and duplicates, saves to analysis.json
5. **Plan**generates reviewable execution plan (plan.json) with file operations
6. **Execute** → performs file operations (dry-run by default, --confirm to execute)
7. **Rollback** → reverses executed operations (best-effort)
### Module Organization
- `cli.py` - Click-based CLI interface, command definitions, all user-facing commands
- `cli.py` - Click-based CLI interface, global options, command registration
- `context.py` - CLIContext (config, paths) and pass_context for commands
- `commands/` - Command implementations (scan, analyze, plan)
- `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` - Load/save identities JSON/CSV, plan/analysis input helpers
- `utils.py` - UTC time, format_size, shared helpers
- `analysis.py` - Completeness checking (episode gaps) and duplicate detection
- `planner.py` - Execution plan generation with conflict detection
- `executor.py` - File operations (move/rename/quarantine) with rollback logging
@@ -108,6 +115,7 @@ Key settings:
- `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
### Category Mappings
+310
View File
@@ -0,0 +1,310 @@
# VLM 代码改进清单
本文档记录对 Video Library Manager (VLM) 项目的代码审查发现的问题及对应解决方案。排除 AI/OpenAPI 相关问题。
---
## 高优先级(确信度 ≥ 0.9
### 1. 时间戳未统一使用 UTC
**问题描述**
多处使用 `datetime.now()` 未指定 timezone,与项目约定「timestamps in UTC」不一致,可能导致:
- 序列化为 ISO 时缺少 `+00:00` 后缀
- 多环境部署时依赖本地时区,行为不一致
**涉及文件**
| 文件 | 行号 |
|------|------|
| `planner.py` | 51 |
| `executor.py` | 89, 119, 471 |
| `state.py` | 104, 139, 173 |
| `quarantine.py` | 61, 529 |
**确信度**: 0.95
**解决方案**
1.`vlm/utils.py` 或现有模块中定义:
```python
from datetime import datetime, timezone
def utc_now() -> datetime:
"""Return current UTC time (timezone-aware)."""
return datetime.now(timezone.utc)
```
2. 全局替换所有 `datetime.now()``utc_now()``datetime.now(timezone.utc)`
3.`load_plan``load_rollback_log` 等反序列化时,对 naive datetime 做 `replace(tzinfo=timezone.utc)` 以保持向后兼容
---
### 2. 未使用的依赖 ffmpeg-python
**问题描述**
`pyproject.toml` 声明 `ffmpeg-python>=0.2.0`,但代码中未 import。scanner 使用 `subprocess` 直接调用 ffprobe。
**确信度**: 0.95
**解决方案**
`pyproject.toml` 的 dependencies 中移除 `ffmpeg-python`。若未来改用 ffmpeg-python 库再添加。
---
### 3. Parser 中 video extensions 硬编码
**问题描述**
`parser.py` 第 100、169 行使用固定扩展名列表 `['.mp4', '.mkv', ...]`,与 `config.video_extensions` 不一致。
- 用户在 config 中新增扩展(如 `.ts`),scan 能发现,但 parse 去扩展名时不会匹配
-`Movie (2020).ts` 可能得到错误的 title 解析
**确信度**: 0.9
**解决方案**
1. `parse_movie` / `parse_series` 增加可选参数 `extensions: list[str]`
2. CLI parse 命令调用时传入 `config.video_extensions`
3. 默认值使用与 config 相同的列表以保持向后兼容
---
### 4. 抽出统一的 I/O 层
**问题描述**
数据读取和转换分散在各 CLI 命令中,同一份 identities JSON 在 analyze、plan 等处有重复且略有不同的转换逻辑。新增字段时需多处同步,易遗漏。
**确信度**: 0.9
**解决方案**
新增 `vlm/io.py`,集中:
- `load_inventory_csv(path) -> list[VideoFile]`
- `save_inventory_csv(files, path, library_root)`
- `load_identities_json(path) -> dict`
- `save_identities_json(data, path)`
- `identities_to_plan_input(data) -> list[(VideoFile, Identity | None)]`
- `identities_to_analysis_input(data) -> (list[MovieIdentity], list[SeriesIdentity], list[VideoFile])`
CLI 只调用这些函数,不再直接解析和构造 dataclass。
---
### 5. Analyze 阶段 VideoFile metadata 丢失
**问题描述**
`identities.json` 不含 `size_bytes``resolution``codec` 等,CLI 构造 `VideoFile` 时用 0 或 None,导致 `compare_quality()` 无法有效比较,duplicate 报告信息不足。
**确信度**: 0.9
**解决方案**
1. **方案 A**analyze 命令同时接受 `--inventory`,从 inventory.csv 加载 metadata 并与 identities 按 path 合并
2. **方案 B**parse 输出时在 identities 中附带 size/resolution/codec(从 inventory 合并),避免 analyze 再读 inventory
---
## 中优先级(确信度 0.8–0.89)
### 6. 拆分 CLI 为 commands 子模块
**问题描述**
`cli.py` 约 1700 行,混合参数定义、业务逻辑、I/O、输出展示,维护和单测困难。
**确信度**: 0.85
**解决方案**
```
src/vlm/
cli.py # main、参数、ctx 传递、调用 commands
commands/
__init__.py
scan.py # scan_cmd(ctx, ...)
parse.py # parse_cmd(ctx, ...)
enrich.py
analyze.py
plan.py
execute.py
report.py # 或按子命令拆分
quarantine.py
state.py
config_cmd.py
```
每个 `*_cmd` 接收 `ctx` 和参数,CLI 只做装饰与调用。单测可直接测 `*_cmd` 函数。
---
### 7. Provider last_request_count 非正式接口
**问题描述**
`enrichment.py` 使用 `getattr(provider, "last_request_count", 1)` 统计 API 调用,依赖实现细节。
**确信度**: 0.85
**解决方案**
1.`providers/base.py``EnrichmentProvider` 协议中显式声明 `last_request_count: int` 属性
2. TMDBProvider 确保实现该属性
3. enrichment 通过协议访问,去掉 `getattr`
---
### 8. 异常捕获过宽
**问题描述**
约 20+ 处 `except Exception`,容易吞掉逻辑错误,难以区分可恢复错误与编程错误。
**涉及**`cli.py``enrichment.py``executor.py``quarantine.py` 等。
**确信度**: 0.8
**解决方案**
- 针对预期异常(`FileNotFoundError``json.JSONDecodeError``ValueError`)分别处理
- 保留顶层 `except Exception` 作为兜底,记录完整 traceback 后 `sys.exit(1)`
- 避免在业务逻辑深处宽泛捕获
---
### 9. Enrichment 主循环过长
**问题描述**
`enrich_identities_data` 主循环约 80 行,混合迭代、缓存、API 调用、统计、payload 合并,可读性和可测性差。
**确信度**: 0.8
**解决方案**
拆分为:
- `_process_single_record(record, media_type, ...) -> None`
- `_fetch_from_providers(record, media_type, providers, ...) -> tuple[dict, int, list, str]`
- `_update_stats(stats, ...) -> None`
- 主循环只负责迭代与调用上述函数
---
## 较低优先级(确信度 0.7–0.79)
### 10. Duplicate 检测 file_map 使用 filename 作为 key
**问题描述**
`analysis.py` 第 87 行:
```python
file_map = {file.filename: file for file in files}
```
同 filename 不同路径会互相覆盖(如 `/a/Movie.mkv``/b/Movie.mkv`),导致 identity 映射到错误 VideoFile。
**确信度**: 0.75
**解决方案**
- 使用 `str(file.path)` 作为 key
- 确保 identity 与 VideoFile 的关联方式一致(如通过 path 或 (path, filename) 建立映射)
---
### 11. MovieIdentity / SeriesIdentity 字段重复
**问题描述**
两个 dataclass 有约 10 个共同 enrichment 字段,新增时需改两处,合并逻辑需分支处理。
**确信度**: 0.7
**解决方案**
- **方案 A**:抽取 `EnrichmentMixin` 基类,`MovieIdentity` / `SeriesIdentity` 继承
- **方案 B(推荐)**:引入 `EnrichmentPayload` dataclass,两个 Identity 通过 `enrichment: EnrichmentPayload` 组合,侵入较小
---
### 12. Config 体积膨胀
**问题描述**
`Config` 约 50 个字段,enrichment/TMDB 相关占多数,职责混杂。
**确信度**: 0.7
**解决方案**
拆分 `EnrichmentConfig``TMDBConfig` 等子配置,通过嵌套或组合放入主 Config。
---
### 13. review_status 与 needs_review 语义重叠
**问题描述**
`review_status`pending/approved/rejected)与 `needs_review`bool)含义重叠,易混淆。
**确信度**: 0.75
**解决方案**
- 在 docstring 或文档中明确定义:`needs_review = (review_status == 'pending') and ...`
- 或在模型中合并为单一状态枚举,避免两个字段语义交叉
---
## 低优先级(确信度 < 0.7
### 14. config_init 与 ctx 一致性
**问题描述**
`config init` 未使用 `@pass_context`,与同组其他命令风格不一致,但当前不依赖 ctx,非功能性 bug。
**确信度**: 0.5
**解决方案**
若其他 config 子命令均用 `@pass_context`,可统一为 `config_init` 也接收 ctx 以保持风格一致;否则可保持现状。
---
### 15. CLI 延迟 import
**问题描述**
各命令在函数体内才 `import`,错误在首次执行该命令时才暴露,依赖关系不直观。
**确信度**: 0.6
**解决方案**
可接受;若希望启动时即发现依赖问题,可改为模块级 import,但会增加启动开销。
---
## 执行建议
| 阶段 | 项目 | 说明 |
|------|------|------|
| 第一批 | 1, 2, 3 | 改动小、风险低、收益明确 |
| 第二批 | 4, 5 | 需要一定重构,与 I/O 设计相关 |
| 第三批 | 6, 7, 8, 9 | 结构性改进,建议分步完成 |
| 第四批 | 10–15 | 按需和档期安排 |
---
*文档生成日期:2025-02-10*
+80 -4
View File
@@ -9,7 +9,7 @@ A Python-based CLI tool for managing personal video collections with a safety-fi
- **Comprehensive Analysis**: Detect episode gaps and duplicate files
- **Rich Metadata**: Extract video resolution, codec, duration, and bitrate
- **Flexible Organization**: Customizable directory structure and naming templates
- **Metadata Enrichment**: Add bilingual titles and reputation signals (TMDB + optional Douban + optional AI fallback)
- **Metadata Enrichment**: Add bilingual titles and reputation signals (TMDB + optional AI fallback)
- **Incremental Performance**: SQLite-backed cache avoids repeated metadata lookups
- **State Tracking**: Track file status throughout the workflow
- **Detailed Reporting**: Generate inventory, completeness, and duplicate reports
@@ -86,6 +86,7 @@ This updates `identities.json` in place and adds fields like:
- `title_zh`, `title_en`, `display_title`
- `reputation_score`, `reputation_votes`, `reputation_source`
- `review_status`, `enrichment_confidence`
- summary metrics including `api_calls`, `cache_hits`, and `skip_reasons`
To refresh all records instead of using incremental cache:
@@ -93,6 +94,12 @@ To refresh all records instead of using incremental cache:
vlm enrich --refresh-all
```
If enrichment cannot run for some records, CLI shows grouped reasons, for example:
```text
Skip reasons: no_key=4632
```
### 5. Analyze Your Library
Detect episode gaps and duplicates:
@@ -382,7 +389,7 @@ categories:
series: [series, tv, shows]
anime: [anime]
# Enrichment settings
# Enrichment settings (`enrich` alias is also supported)
enrichment:
enabled: true
incremental: true
@@ -395,8 +402,15 @@ enrichment:
mode: "bidirectional"
fallback_machine: true
api_keys:
# Preferred: TMDB v4 Bearer token
tmdb_bearer: null
# Backward-compatible fallback (legacy query api_key)
tmdb: null
openai: null
tmdb:
language: "zh-CN"
region: null
include_adult: false
reputation:
min_votes: 50
low_score_threshold: 6.0
@@ -405,6 +419,39 @@ enrichment:
title_format: "{title_zh} {title_en}"
```
### TMDB Enrichment Setup
`vlm enrich` works best with TMDB Bearer auth (recommended by TMDB). Legacy `tmdb` api key is still supported for compatibility.
Minimal config:
```yaml
enrichment:
providers: [tmdb]
api_keys:
tmdb_bearer: "YOUR_TMDB_BEARER_TOKEN"
```
Optional TMDB query tuning:
```yaml
enrichment:
tmdb:
language: "zh-CN" # localized title language
region: "US" # affects regional release/search behavior
include_adult: false
```
Validation flow:
```bash
# 1) run small incremental pass
vlm enrich --input identities.json --refresh-changed-only
# 2) then full refresh if output looks correct
vlm enrich --input identities.json --refresh-all
```
### Template Variables
**Movies:**
@@ -576,6 +623,26 @@ vlm config init
vlm config validate
```
### TMDB Auth / Rate Limit / Zero Enriched
Common enrichment outcomes:
- `Error during enrichment: TMDB authentication failed (401/403)`
Cause: invalid/missing `tmdb_bearer` (or `tmdb`) key.
Action: update `~/.vlm/config.yaml` and rerun.
- `Skip reasons: no_key=...`
Cause: no TMDB credentials configured for provider.
Action: set `enrichment.api_keys.tmdb_bearer` (recommended) or `tmdb`.
- `Skip reasons: rate_limited=...`
Cause: TMDB rate limit hit (`429`).
Action: retry later; VLM already applies bounded retry/backoff.
- `Enriched now: 0` with non-zero records
Cause: often `no_key`, `no_match`, or provider errors.
Action: check `Skip reasons` and `Failure sample` in CLI output.
### Permission Errors
If you can't access certain files:
@@ -646,12 +713,21 @@ pytest --cov=vlm tests/
```
src/vlm/
├── cli.py # Click-based CLI interface
├── cli.py # Click-based CLI interface, global options
├── context.py # CLIContext and pass_context for commands
├── commands/ # Command implementations
│ ├── scan.py # Scan command
│ ├── analyze.py # Analyze command
│ └── plan.py # Plan command
├── scanner.py # File discovery and metadata extraction
├── parser.py # Filename parsing (titles, years, episodes)
├── enrichment.py # Title/reputation enrichment pipeline
├── cache.py # SQLite cache for incremental enrichment
├── providers/ # External metadata providers (TMDB/Douban)
├── providers/ # External metadata providers (TMDB, etc.)
│ ├── base.py # Provider interface
│ └── tmdb.py # TMDB API client
├── io.py # JSON/CSV load/save and plan/analysis input helpers
├── utils.py # UTC time, format_size, etc.
├── analysis.py # Completeness and duplicate detection
├── planner.py # Execution plan generation
├── executor.py # File operations and rollback
+95
View File
@@ -0,0 +1,95 @@
# TMDB Enrichment 重构执行计划
## 1. 目标与范围
- 目标:提升 `vlm enrich` 在 TMDB 场景下的正确性、稳定性、可观测性。
- 范围:`src/vlm/providers/tmdb.py``src/vlm/enrichment.py``src/vlm/cli.py`、配置与测试。
- 非目标:不改动 CLI 命令名和现有核心参数,不引入复杂依赖。
## 2. 设计原则
- 简洁优先:保留现有调用链,避免过度抽象。
- 统计真实:`api_calls` 仅统计真实外部请求。
- 错误可解释:区分鉴权、限流、无匹配、服务异常。
- 向后兼容:默认配置缺省时仍可运行,行为可预测。
## 3. 分阶段计划
### 阶段 0:基线确认
- 记录当前测试基线:
- `uv run pytest tests/test_enrichment.py tests/test_cli_enrich.py`
- 记录当前运行基线:
- `uv run vlm enrich --input identities.json --refresh-all`
- 输出基线报告(用于对比重构前后变化)。
### 阶段 1TMDB Provider 重构
- 新增 TMDB HTTP 访问层(可内聚在 provider 文件内):
- 统一请求构建(query/header/timeout)。
- 统一响应解析与错误分类。
- 错误分类与策略:
- `401/403`:鉴权失败,停止该条 provider 请求并记录原因。
- `404`:资源缺失,返回无匹配。
- `429`:指数退避重试(含上限)。
- `5xx`:有限重试,最终记录失败。
- 保留最小调用路径:`search -> details`
### 阶段 2Enrichment 统计与语义修复
- 统一并明确统计口径:
- `api_calls`:真实发起的远程请求次数。
- `enriched`:获得有效 provider 或翻译结果的记录数。
- `skipped`:未产生 enrich 结果的记录数。
- 增加 skip/reason 聚合(建议键):
- `no_key``no_match``rate_limited``provider_error``invalid_input`
- 保持 `needs_review` 判定逻辑稳定且可解释。
### 阶段 3:CLI 进度与结果展示
- TTY:保留 `click.progressbar`
- 非 TTY:保留分段文本进度(每 5% 或固定步进)。
- 结束摘要补充原因分布:
- 示例:`Skip reasons: no_key=4632 no_match=0 provider_error=0`
### 阶段 4:配置与文档
- 配置补全(默认模板):
- `enrichment.api_keys.tmdb`
- `enrichment.tmdb.language`(默认 `zh-CN`
- `enrichment.tmdb.region`(可选)
- `enrichment.tmdb.include_adult`(默认 `false`
- README 增加:
- key 配置示例。
- 常见错误排查(401/429/0 enriched)。
- 小样本验证流程。
### 阶段 5:测试与回归
- Provider 测试:
- 鉴权失败、限流重试、5xx 重试、无匹配。
- Enrichment 测试:
- 统计口径、skip reason 聚合、无 key 场景。
- CLI 测试:
- 非 TTY 进度输出、摘要 reason 输出。
- 回归测试:
- `uv run pytest` 全量通过。
## 4. 任务拆解(执行顺序)
1. Task A:实现 TMDB 请求层与错误分类。`[已完成]`
2. Task B:重构 `TMDBProvider.enrich()` 以接入请求层。`[已完成]`
3. Task C:重构 enrichment 统计与 reason 聚合。`[已完成]`
4. Task D:更新 CLI 输出(进度与摘要)。`[已完成]`
5. Task E:补全配置模型与默认配置导出。`[已完成]`
6. Task F:补充/修复测试并回归。`[已完成]`
7. Task G:更新 README 与变更说明。`[待执行]`
## 5. 验收标准
- 功能:
- 有 key 时可正常 enrich,统计准确。
- 无 key 时不误报 `api_calls`,输出原因可解释。
- 质量:
- 新增测试覆盖关键分支,相关测试通过。
- 无破坏性 CLI 变更,现有命令仍可用。
- 体验:
- 非 TTY 场景有清晰进度和失败原因摘要。
## 6. 执行记录模板
每个 Task 完成后记录以下内容:
- 变更文件:
- 关键改动:
- 测试命令:
- 测试结果:
- 风险与后续:
+4356
View File
File diff suppressed because it is too large Load Diff
+97174
View File
File diff suppressed because it is too large Load Diff
+4672
View File
File diff suppressed because it is too large Load Diff
+37365
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,7 +7,6 @@ requires-python = ">=3.10"
dependencies = [
"click>=8.1.0",
"pyyaml>=6.0",
"ffmpeg-python>=0.2.0",
]
[project.optional-dependencies]
+10 -36
View File
@@ -68,62 +68,36 @@ def analyze_series_completeness(episodes: list[SeriesIdentity]) -> list[SeasonCo
def detect_duplicates(
identities: list[MovieIdentity | SeriesIdentity],
files: list[VideoFile]
identity_file_pairs: list[tuple[MovieIdentity | SeriesIdentity, VideoFile]],
) -> list[DuplicateGroup]:
"""Detect duplicate video files and provide quality comparison data.
Groups files by normalized identity (title+year for movies, title+season+episode
for series) and identifies groups with multiple files as potential duplicates.
Uses (identity, file) pairs so that same filename under different paths are
not conflated.
Args:
identities: List of parsed identities (movies or series)
files: List of video files corresponding to the identities
identity_file_pairs: List of (identity, video_file) in matching order
Returns:
List of DuplicateGroup objects for files with duplicates
"""
# Create a mapping from original filename to VideoFile for quick lookup
file_map = {file.filename: file for file in files}
# Group identities by normalized identity
groups: dict[tuple, list[tuple[MovieIdentity | SeriesIdentity, VideoFile]]] = {}
for identity in identities:
# Create grouping key based on identity type
for identity, video_file in identity_file_pairs:
if isinstance(identity, MovieIdentity):
# For movies: group by (title, year)
# Skip if year is None (needs review)
if identity.year is None:
continue
key = ('movie', identity.title, identity.year)
else: # SeriesIdentity
# For series: group by (title, season, episode)
# Skip if season is None or episodes is empty (needs review)
if identity.season is None or not identity.episodes:
continue
# For multi-episode files, use the first episode for grouping
# Each episode in the list should be treated separately
for episode in identity.episodes:
key = ('series', identity.title, identity.season, episode)
# Get the corresponding VideoFile
video_file = file_map.get(identity.original_filename)
if video_file is None:
continue
# Add to group
key = ("movie", identity.title, identity.year)
if key not in groups:
groups[key] = []
groups[key].append((identity, video_file))
else: # SeriesIdentity
if identity.season is None or not identity.episodes:
continue
# Get the corresponding VideoFile for movies
video_file = file_map.get(identity.original_filename)
if video_file is None:
continue
# Add to group
for episode in identity.episodes:
key = ("series", identity.title, identity.season, episode)
if key not in groups:
groups[key] = []
groups[key].append((identity, video_file))
+52 -469
View File
@@ -5,6 +5,7 @@ It implements global options (--config, --log-level) and error handling.
"""
import sys
import traceback
from pathlib import Path
from typing import Optional
@@ -12,7 +13,9 @@ import click
import yaml
from vlm.config import Config, load_config, create_default_config, validate_config
from vlm.context import CLIContext, pass_context
from vlm.logging_config import setup_logging, get_logger
from vlm.utils import format_size
def default_config_path() -> Path:
@@ -20,17 +23,6 @@ def default_config_path() -> Path:
return Path.home() / ".vlm" / "config.yaml"
class CLIContext:
"""Context object to pass configuration and logger between commands."""
def __init__(self, config: Config, logger):
self.config = config
self.logger = logger
pass_context = click.make_pass_decorator(CLIContext)
@click.group()
@click.option(
'--config',
@@ -105,6 +97,7 @@ def main(ctx, config: Path, log_level: Optional[str]):
ctx.obj = CLIContext(config=cfg, logger=logger)
except Exception as e:
traceback.print_exc(file=sys.stderr)
click.echo(f"Error initializing VLM: {e}", err=True)
sys.exit(1)
@@ -154,118 +147,15 @@ def scan(
vlm scan --reuse-from old.csv # Reuse prior metadata cache
vlm scan --force-refresh-metadata # Re-run ffprobe for all files
"""
from vlm.scanner import scan_library, save_inventory_csv, load_inventory_csv
config = ctx.config
logger = ctx.logger
try:
# Display scan start message
click.echo(f"Scanning library at: {config.library_root}")
click.echo("This may take a while for large libraries...")
click.echo()
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
progress_position = {"current": 0}
def _scan_progress(processed: int, total: int) -> None:
if total <= 0:
return
if progress_state["bar"] is None:
bar = click.progressbar(
length=total,
label="Scanning files",
show_pos=True,
)
progress_state["bar"] = bar.__enter__()
step = processed - progress_position["current"]
if step > 0 and progress_state["bar"] is not None:
progress_state["bar"].update(step)
progress_position["current"] = processed
metadata_cache = None
cache_source = None
if not force_refresh_metadata:
cache_source = reuse_from if reuse_from is not None else (output if output.exists() else None)
if metadata and force_refresh_metadata:
click.echo("Forcing metadata refresh for all files (cache disabled).")
click.echo()
if metadata and cache_source is not None:
click.echo(f"Loading metadata cache from: {cache_source}")
try:
cached_files = load_inventory_csv(cache_source)
metadata_cache = {str(vf.path): vf for vf in cached_files}
click.echo(f"Loaded metadata cache entries: {len(metadata_cache)}")
click.echo()
except Exception as e:
click.echo(f"Warning: could not load metadata cache: {e}")
click.echo("Continuing without cache.")
click.echo()
# Perform the scan
try:
video_files = scan_library(
config.library_root,
config,
progress_callback=_scan_progress,
include_video_metadata=metadata,
metadata_cache=metadata_cache
)
finally:
if progress_state["bar"] is not None:
progress_state["bar"].__exit__(None, None, None)
click.echo()
# Display summary
click.echo(f"Scan complete!")
click.echo(f" Total files found: {len(video_files)}")
# Count by category
categories = {}
total_size = 0
for vf in video_files:
categories[vf.category] = categories.get(vf.category, 0) + 1
total_size += vf.size_bytes
click.echo(f" Total size: {_format_size(total_size)}")
click.echo()
click.echo("Files by category:")
for category in sorted(categories.keys()):
click.echo(f" {category}: {categories[category]}")
# Save inventory to CSV
click.echo()
click.echo(f"Saving inventory to: {output}")
save_inventory_csv(video_files, output, config.library_root)
click.echo(f"Inventory saved successfully!")
logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}")
from vlm.commands.scan import scan_cmd
scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata)
except Exception as e:
click.echo(f"Error during scan: {e}", err=True)
logger.error(f"Scan failed: {e}", exc_info=True)
ctx.logger.error(f"Scan failed: {e}", exc_info=True)
sys.exit(1)
def _format_size(size_bytes: int) -> str:
"""Format file size in human-readable format.
Args:
size_bytes: Size in bytes
Returns:
Formatted string (e.g., "1.5 GB", "234.2 MB")
"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
@main.command()
@click.option(
'--input',
@@ -337,7 +227,7 @@ def parse(ctx: CLIContext, input: Path, output: Path):
category = vf['category']
if category == 'movie':
identity = parse_movie(filename)
identity = parse_movie(filename, extensions=config.video_extensions)
movie_identities.append({
'path': vf['path'],
'filename': filename,
@@ -349,7 +239,7 @@ def parse(ctx: CLIContext, input: Path, output: Path):
})
elif category == 'series':
identity = parse_series(filename)
identity = parse_series(filename, extensions=config.video_extensions)
series_identities.append({
'path': vf['path'],
'filename': filename,
@@ -543,12 +433,14 @@ def enrich(
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
progress_position = {"current": 0}
progress_bucket = {"value": -1}
is_tty = bool(getattr(sys.stderr, "isatty", lambda: False)())
def _enrich_progress(processed: int, total_count: int, _metrics: dict[str, int]) -> None:
def _enrich_progress(processed: int, total_count: int, metrics: dict[str, int]) -> None:
if total_count <= 0:
return
if progress_state["bar"] is None:
if is_tty and progress_state["bar"] is None:
bar = click.progressbar(
length=total_count,
label="Enriching records",
@@ -561,6 +453,19 @@ def enrich(
progress_state["bar"].update(step)
progress_position["current"] = processed
if not is_tty:
percent = int(processed * 100 / total_count)
bucket = percent // 5
if bucket > progress_bucket["value"] or processed == total_count:
progress_bucket["value"] = bucket
click.echo(
"Progress: "
f"{processed}/{total_count} ({percent}%) "
f"api_calls={metrics.get('api_calls', 0)} "
f"cache_hits={metrics.get('cache_hits', 0)} "
f"failed={metrics.get('failed', 0)}"
)
try:
if total == 0:
click.echo("No movie/series/anime records found to enrich.")
@@ -596,6 +501,11 @@ def enrich(
click.echo(f" Failed requests: {stats['failed']}")
click.echo(f" Skipped: {stats['skipped']}")
click.echo(f" Needs review: {stats['needs_review']}")
skip_reasons = stats.get("skip_reasons", {})
if isinstance(skip_reasons, dict):
non_zero = [f"{name}={count}" for name, count in sorted(skip_reasons.items()) if int(count) > 0]
if non_zero:
click.echo(f" Skip reasons: {' '.join(non_zero)}")
failed_items = stats.get('failed_items', [])
if isinstance(failed_items, list) and failed_items:
click.echo(" Failure sample:")
@@ -640,8 +550,14 @@ def enrich(
default=Path('analysis.json'),
help='Path to save analysis results (default: analysis.json)'
)
@click.option(
'--inventory',
type=click.Path(exists=True, path_type=Path),
default=None,
help='Optional inventory CSV to merge size/resolution/codec for duplicate quality comparison'
)
@pass_context
def analyze(ctx: CLIContext, input: Path, output: Path):
def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
"""Analyze completeness and duplicates.
Detects episode gaps in series and identifies potential duplicate files.
@@ -651,185 +567,23 @@ def analyze(ctx: CLIContext, input: Path, output: Path):
vlm analyze # Use default files
vlm analyze --input my_identities.json # Custom input
vlm analyze --inventory inventory.csv # Merge metadata for quality comparison
vlm analyze --output my_analysis.json # Custom output
"""
import json
from datetime import datetime, timezone
from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.models import SeriesIdentity, MovieIdentity, VideoFile
config = ctx.config
logger = ctx.logger
try:
# Display analyze start message
click.echo(f"Analyzing identities from: {input}")
click.echo()
# Load identities from JSON
with open(input, 'r', encoding='utf-8') as jsonfile:
identities_data = json.load(jsonfile)
# Extract movies and series
movies_data = identities_data.get('movies', [])
series_data = identities_data.get('series', [])
click.echo(f"Loaded {len(movies_data)} movies and {len(series_data)} series")
click.echo()
# Convert to identity objects
movie_identities = []
for m in movies_data:
movie_identities.append(MovieIdentity(
title=m['title'],
year=m.get('year'),
confidence=m['confidence'],
needs_review=m['needs_review'],
original_filename=m['filename']
))
series_identities = []
for s in series_data:
series_identities.append(SeriesIdentity(
title=s['title'],
season=s.get('season'),
episodes=s.get('episodes', []),
confidence=s['confidence'],
needs_review=s['needs_review'],
original_filename=s['filename']
))
# Create VideoFile objects for duplicate detection
# We need to reconstruct basic VideoFile info from the identities data
video_files = []
for m in movies_data:
video_files.append(VideoFile(
path=Path(m['path']),
filename=m['filename'],
size_bytes=0, # Not available from identities file
modified_timestamp=datetime.now(timezone.utc),
category=m['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
))
for s in series_data:
video_files.append(VideoFile(
path=Path(s['path']),
filename=s['filename'],
size_bytes=0, # Not available from identities file
modified_timestamp=datetime.now(timezone.utc),
category=s['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
))
# Analyze series completeness
click.echo("Analyzing series completeness...")
completeness_results = analyze_series_completeness(series_identities)
# Detect duplicates
click.echo("Detecting duplicates...")
all_identities = movie_identities + series_identities
duplicate_groups = detect_duplicates(all_identities, video_files)
# Display analysis summary
click.echo()
click.echo("Analysis complete!")
click.echo()
click.echo("Results:")
click.echo(f" Series with episode gaps: {len(completeness_results)}")
if completeness_results:
total_missing = sum(len(c.episodes_missing) for c in completeness_results)
click.echo(f" - Total missing episodes: {total_missing}")
click.echo(f" Duplicate groups found: {len(duplicate_groups)}")
if duplicate_groups:
total_duplicates = sum(len(g.files) for g in duplicate_groups)
click.echo(f" - Total duplicate files: {total_duplicates}")
# Save analysis results to JSON
click.echo()
click.echo(f"Saving analysis results to: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Build JSON structure
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
# Convert completeness results to dict
completeness_list = []
for c in completeness_results:
completeness_list.append({
'series_title': c.series_title,
'season': c.season,
'episodes_found': c.episodes_found,
'episodes_missing': c.episodes_missing
})
# Convert duplicate groups to dict
duplicates_list = []
for d in duplicate_groups:
# Get identity info
if isinstance(d.identity, MovieIdentity):
identity_info = {
'type': 'movie',
'title': d.identity.title,
'year': d.identity.year
}
else: # SeriesIdentity
identity_info = {
'type': 'series',
'title': d.identity.title,
'season': d.identity.season,
'episodes': d.identity.episodes
}
duplicates_list.append({
'identity': identity_info,
'files': [str(f.path) for f in d.files],
'quality_comparison': d.quality_comparison
})
analysis_data = {
'metadata': {
'generated': generation_timestamp,
'source_identities': str(input),
'total_movies': len(movies_data),
'total_series': len(series_data)
},
'completeness': completeness_list,
'duplicates': duplicates_list
}
# Write JSON file with pretty formatting
with open(output, 'w', encoding='utf-8') as jsonfile:
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
click.echo(f"Analysis results saved successfully!")
logger.info(f"Analysis completed: {len(completeness_results)} incomplete series, {len(duplicate_groups)} duplicate groups, saved to {output}")
from vlm.commands.analyze import analyze_cmd
analyze_cmd(ctx, input, output, inventory)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error during analysis: {e}", err=True)
logger.error(f"Analysis failed: {e}", exc_info=True)
ctx.logger.error(f"Analysis failed: {e}", exc_info=True)
sys.exit(1)
@@ -859,192 +613,20 @@ def plan(ctx: CLIContext, input: Path, output: Path):
vlm plan --input my_identities.json # Custom input
vlm plan --output my_plan.json # Custom output
"""
import json
from vlm.planner import generate_plan, save_plan
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
from datetime import datetime, timezone
config = ctx.config
logger = ctx.logger
try:
# Display plan start message
click.echo(f"Generating execution plan from: {input}")
click.echo()
# Load identities from JSON
with open(input, 'r', encoding='utf-8') as jsonfile:
identities_data = json.load(jsonfile)
# Extract movies and series
movies_data = identities_data.get('movies', [])
series_data = identities_data.get('series', [])
anime_data = identities_data.get('anime', [])
other_data = identities_data.get('other', [])
click.echo(f"Loaded {len(movies_data)} movies, {len(series_data)} series, {len(anime_data)} anime, {len(other_data)} other")
click.echo()
# Build list of (VideoFile, Identity) tuples for plan generator
identities_list = []
# Process movies
for m in movies_data:
is_approved = m.get('review_status') == 'approved'
video_file = VideoFile(
path=Path(m['path']),
filename=m['filename'],
size_bytes=0, # Not available from identities file
modified_timestamp=datetime.now(timezone.utc),
category=m['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
)
movie_identity = MovieIdentity(
title=m.get('display_title', m['title']),
year=m.get('year'),
confidence=m['confidence'],
needs_review=(m['needs_review'] and not is_approved),
original_filename=m['filename'],
canonical_id=m.get('canonical_id'),
title_zh=m.get('title_zh'),
title_en=m.get('title_en'),
translation_source=m.get('translation_source'),
reputation_score=m.get('reputation_score'),
reputation_votes=m.get('reputation_votes'),
reputation_source=m.get('reputation_source'),
review_status=m.get('review_status', 'pending'),
enrichment_confidence=m.get('enrichment_confidence'),
provider_metadata=m.get('provider_metadata', {})
)
identities_list.append((video_file, movie_identity))
# Process series
for s in series_data:
is_approved = s.get('review_status') == 'approved'
video_file = VideoFile(
path=Path(s['path']),
filename=s['filename'],
size_bytes=0, # Not available from identities file
modified_timestamp=datetime.now(timezone.utc),
category=s['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
)
series_identity = SeriesIdentity(
title=s.get('display_title', s['title']),
season=s.get('season'),
episodes=s.get('episodes', []),
confidence=s['confidence'],
needs_review=(s['needs_review'] and not is_approved),
original_filename=s['filename'],
canonical_id=s.get('canonical_id'),
title_zh=s.get('title_zh'),
title_en=s.get('title_en'),
translation_source=s.get('translation_source'),
reputation_score=s.get('reputation_score'),
reputation_votes=s.get('reputation_votes'),
reputation_source=s.get('reputation_source'),
review_status=s.get('review_status', 'pending'),
enrichment_confidence=s.get('enrichment_confidence'),
provider_metadata=s.get('provider_metadata', {})
)
identities_list.append((video_file, series_identity))
# Process anime (no identity in v1)
for a in anime_data:
video_file = VideoFile(
path=Path(a['path']),
filename=a['filename'],
size_bytes=0,
modified_timestamp=datetime.now(timezone.utc),
category=a['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
)
identities_list.append((video_file, None))
# Process other (no identity)
for o in other_data:
video_file = VideoFile(
path=Path(o['path']),
filename=o['filename'],
size_bytes=0,
modified_timestamp=datetime.now(timezone.utc),
category=o['category'],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None
)
identities_list.append((video_file, None))
# Generate execution plan
click.echo("Generating execution plan...")
execution_plan = generate_plan(identities_list, config)
# Display plan summary
click.echo()
click.echo("Plan generation complete!")
click.echo()
click.echo("Operation summary:")
click.echo(f" Total operations: {execution_plan.summary['total']}")
click.echo(f" Move operations: {execution_plan.summary['move']}")
click.echo(f" Rename operations: {execution_plan.summary['rename']}")
click.echo(f" Quarantine operations: {execution_plan.summary['quarantine']}")
click.echo(f" No-op (skipped): {execution_plan.summary['no-op']}")
# Count conflicts
conflicts = sum(1 for op in execution_plan.operations if op.has_conflict)
if conflicts > 0:
click.echo()
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
click.echo(" Review the plan file for details on conflicting operations.")
# Save execution plan to JSON
click.echo()
click.echo(f"Saving execution plan to: {output}")
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
save_plan(execution_plan, output)
click.echo(f"Execution plan saved successfully!")
click.echo()
click.echo("Next steps:")
click.echo(f" 1. Review the plan: {output}")
click.echo(f" 2. Edit the plan if needed (it's JSON)")
click.echo(f" 3. Dry-run: vlm execute --plan {output}")
click.echo(f" 4. Execute: vlm execute --plan {output} --confirm")
logger.info(f"Plan generated: {execution_plan.summary['total']} operations, {conflicts} conflicts, saved to {output}")
from vlm.commands.plan import plan_cmd
plan_cmd(ctx, input, output)
except FileNotFoundError:
click.echo(f"Error: Input file not found: {input}", err=True)
logger.error(f"Input file not found: {input}")
ctx.logger.error(f"Input file not found: {input}")
sys.exit(1)
except json.JSONDecodeError as e:
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
logger.error(f"JSON parsing failed: {e}", exc_info=True)
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error during plan generation: {e}", err=True)
logger.error(f"Plan generation failed: {e}", exc_info=True)
ctx.logger.error(f"Plan generation failed: {e}", exc_info=True)
sys.exit(1)
@@ -1266,7 +848,7 @@ def quarantine_list(ctx: CLIContext, category: Optional[str]):
click.echo(f" Category: {entry.category}")
click.echo(f" Original: {entry.original_path}")
click.echo(f" Quarantine: {entry.quarantine_path}")
click.echo(f" Size: {_format_size(entry.size_bytes)}")
click.echo(f" Size: {format_size(entry.size_bytes)}")
click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}")
if entry.reason:
click.echo(f" Reason: {entry.reason}")
@@ -2246,7 +1828,8 @@ def config_cmd(ctx: CLIContext):
default=default_config_path,
help='Path where configuration file should be created'
)
def config_init(path: Path):
@pass_context
def config_init(ctx: CLIContext, path: Path):
"""Initialize configuration file with defaults."""
try:
if path.exists():
+5
View File
@@ -0,0 +1,5 @@
"""CLI command implementations.
Each module provides *_cmd(ctx, ...) functions that are invoked by cli.py
after Click parses options and passes context.
"""
+116
View File
@@ -0,0 +1,116 @@
"""Analyze command implementation."""
import json
from pathlib import Path
from typing import Optional
import click
from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.context import CLIContext
from vlm.io import identities_to_analysis_input, load_identities_json, load_inventory_csv
from vlm.models import MovieIdentity, SeriesIdentity
from vlm.utils import utc_now
def analyze_cmd(
ctx: CLIContext,
input: Path,
output: Path,
inventory: Optional[Path],
) -> None:
"""Run analysis: completeness and duplicate detection."""
config = ctx.config
logger = ctx.logger
click.echo(f"Analyzing identities from: {input}")
if inventory:
click.echo(f"Merging metadata from inventory: {inventory}")
click.echo()
identities_data = load_identities_json(input)
movies_data = identities_data.get("movies", [])
series_data = identities_data.get("series", [])
click.echo(f"Loaded {len(movies_data)} movies and {len(series_data)} series")
click.echo()
inventory_files = load_inventory_csv(inventory) if inventory else None
movie_identities, series_identities, video_files = identities_to_analysis_input(
identities_data, inventory_files=inventory_files
)
click.echo("Analyzing series completeness...")
completeness_results = analyze_series_completeness(series_identities)
click.echo("Detecting duplicates...")
n_movies = len(movie_identities)
identity_file_pairs = (
list(zip(movie_identities, video_files[:n_movies]))
+ list(zip(series_identities, video_files[n_movies:]))
)
duplicate_groups = detect_duplicates(identity_file_pairs)
click.echo()
click.echo("Analysis complete!")
click.echo()
click.echo("Results:")
click.echo(f" Series with episode gaps: {len(completeness_results)}")
if completeness_results:
total_missing = sum(len(c.episodes_missing) for c in completeness_results)
click.echo(f" - Total missing episodes: {total_missing}")
click.echo(f" Duplicate groups found: {len(duplicate_groups)}")
if duplicate_groups:
total_duplicates = sum(len(g.files) for g in duplicate_groups)
click.echo(f" - Total duplicate files: {total_duplicates}")
click.echo()
click.echo(f"Saving analysis results to: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
completeness_list = [
{
"series_title": c.series_title,
"season": c.season,
"episodes_found": c.episodes_found,
"episodes_missing": c.episodes_missing,
}
for c in completeness_results
]
duplicates_list = []
for d in duplicate_groups:
if isinstance(d.identity, MovieIdentity):
identity_info = {"type": "movie", "title": d.identity.title, "year": d.identity.year}
else:
identity_info = {
"type": "series",
"title": d.identity.title,
"season": d.identity.season,
"episodes": d.identity.episodes,
}
duplicates_list.append(
{
"identity": identity_info,
"files": [str(f.path) for f in d.files],
"quality_comparison": d.quality_comparison,
}
)
analysis_data = {
"metadata": {
"generated": generation_timestamp,
"source_identities": str(input),
"total_movies": len(movies_data),
"total_series": len(series_data),
},
"completeness": completeness_list,
"duplicates": duplicates_list,
}
with open(output, "w", encoding="utf-8") as jsonfile:
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
click.echo("Analysis results saved successfully!")
logger.info(
f"Analysis completed: {len(completeness_results)} incomplete series, "
f"{len(duplicate_groups)} duplicate groups, saved to {output}"
)
+69
View File
@@ -0,0 +1,69 @@
"""Plan command implementation."""
from pathlib import Path
import click
from vlm.context import CLIContext
from vlm.io import identities_to_plan_input, load_identities_json
from vlm.planner import generate_plan, save_plan
def plan_cmd(ctx: CLIContext, input: Path, output: Path) -> None:
"""Generate execution plan from identities."""
config = ctx.config
logger = ctx.logger
click.echo(f"Generating execution plan from: {input}")
click.echo()
identities_data = load_identities_json(input)
movies_data = identities_data.get("movies", [])
series_data = identities_data.get("series", [])
anime_data = identities_data.get("anime", [])
other_data = identities_data.get("other", [])
click.echo(
f"Loaded {len(movies_data)} movies, {len(series_data)} series, "
f"{len(anime_data)} anime, {len(other_data)} other"
)
click.echo()
identities_list = identities_to_plan_input(identities_data)
click.echo("Generating execution plan...")
execution_plan = generate_plan(identities_list, config)
click.echo()
click.echo("Plan generation complete!")
click.echo()
click.echo("Operation summary:")
click.echo(f" Total operations: {execution_plan.summary['total']}")
click.echo(f" Move operations: {execution_plan.summary['move']}")
click.echo(f" Rename operations: {execution_plan.summary['rename']}")
click.echo(f" Quarantine operations: {execution_plan.summary['quarantine']}")
click.echo(f" No-op (skipped): {execution_plan.summary['no-op']}")
conflicts = sum(1 for op in execution_plan.operations if op.has_conflict)
if conflicts > 0:
click.echo()
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
click.echo(" Review the plan file for details on conflicting operations.")
click.echo()
click.echo(f"Saving execution plan to: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
save_plan(execution_plan, output)
click.echo("Execution plan saved successfully!")
click.echo()
click.echo("Next steps:")
click.echo(f" 1. Review the plan: {output}")
click.echo(" 2. Edit the plan if needed (it's JSON)")
click.echo(f" 3. Dry-run: vlm execute --plan {output}")
click.echo(f" 4. Execute: vlm execute --plan {output} --confirm")
logger.info(
f"Plan generated: {execution_plan.summary['total']} operations, "
f"{conflicts} conflicts, saved to {output}"
)
+97
View File
@@ -0,0 +1,97 @@
"""Scan command implementation."""
import sys
from pathlib import Path
from typing import Optional
import click
from vlm.context import CLIContext
from vlm.scanner import load_inventory_csv, save_inventory_csv, scan_library
from vlm.utils import format_size
def scan_cmd(
ctx: CLIContext,
output: Path,
metadata: bool,
reuse_from: Optional[Path],
force_refresh_metadata: bool,
) -> None:
"""Run scan: discover video files and save inventory."""
config = ctx.config
logger = ctx.logger
click.echo(f"Scanning library at: {config.library_root}")
click.echo("This may take a while for large libraries...")
click.echo()
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
progress_position = {"current": 0}
def _scan_progress(processed: int, total: int) -> None:
if total <= 0:
return
if progress_state["bar"] is None:
bar = click.progressbar(
length=total,
label="Scanning files",
show_pos=True,
)
progress_state["bar"] = bar.__enter__()
step = processed - progress_position["current"]
if step > 0 and progress_state["bar"] is not None:
progress_state["bar"].update(step)
progress_position["current"] = processed
metadata_cache = None
cache_source = None
if not force_refresh_metadata:
cache_source = reuse_from if reuse_from is not None else (output if output.exists() else None)
if metadata and force_refresh_metadata:
click.echo("Forcing metadata refresh for all files (cache disabled).")
click.echo()
if metadata and cache_source is not None:
click.echo(f"Loading metadata cache from: {cache_source}")
try:
cached_files = load_inventory_csv(cache_source)
metadata_cache = {str(vf.path): vf for vf in cached_files}
click.echo(f"Loaded metadata cache entries: {len(metadata_cache)}")
click.echo()
except Exception as e:
click.echo(f"Warning: could not load metadata cache: {e}")
click.echo("Continuing without cache.")
click.echo()
try:
video_files = scan_library(
config.library_root,
config,
progress_callback=_scan_progress,
include_video_metadata=metadata,
metadata_cache=metadata_cache,
)
finally:
if progress_state["bar"] is not None:
progress_state["bar"].__exit__(None, None, None)
click.echo()
click.echo("Scan complete!")
click.echo(f" Total files found: {len(video_files)}")
categories = {}
total_size = 0
for vf in video_files:
categories[vf.category] = categories.get(vf.category, 0) + 1
total_size += vf.size_bytes
click.echo(f" Total size: {format_size(total_size)}")
click.echo()
click.echo("Files by category:")
for category in sorted(categories.keys()):
click.echo(f" {category}: {categories[category]}")
click.echo()
click.echo(f"Saving inventory to: {output}")
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}")
+41 -14
View File
@@ -37,6 +37,10 @@ class Config:
translation_mode: str = "bidirectional"
translation_fallback_machine: bool = True
tmdb_api_key: Optional[str] = None
tmdb_bearer_token: Optional[str] = None
tmdb_language: str = "zh-CN"
tmdb_region: Optional[str] = None
tmdb_include_adult: bool = False
openai_api_key: Optional[str] = None
reputation_min_votes: int = 50
reputation_low_score_threshold: float = 6.0
@@ -82,11 +86,14 @@ def load_config(path: Path) -> Config:
"anime": ["anime"]
})
enrichment = data.get("enrichment", {})
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,
@@ -110,6 +117,10 @@ def load_config(path: Path) -> Config:
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),
@@ -125,19 +136,7 @@ def create_default_config(path: Path) -> Config:
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
)
yaml_content = {
"library_root": str(default_config.library_root),
"video_extensions": default_config.video_extensions,
"templates": {
"movie_dir": default_config.movie_template,
"series_dir": default_config.series_template,
"movie_filename": default_config.movie_filename_template,
"series_filename": default_config.series_filename_template,
},
"quarantine_dir": default_config.quarantine_dir,
"log_level": default_config.log_level,
"categories": default_config.categories,
"enrichment": {
enrichment_content = {
"enabled": default_config.enrichment_enabled,
"incremental": default_config.enrichment_incremental,
"refresh_mode": default_config.enrichment_refresh_mode,
@@ -151,8 +150,14 @@ def create_default_config(path: Path) -> Config:
},
"api_keys": {
"tmdb": default_config.tmdb_api_key,
"tmdb_bearer": default_config.tmdb_bearer_token,
"openai": default_config.openai_api_key,
},
"tmdb": {
"language": default_config.tmdb_language,
"region": default_config.tmdb_region,
"include_adult": default_config.tmdb_include_adult,
},
"reputation": {
"min_votes": default_config.reputation_min_votes,
"low_score_threshold": default_config.reputation_low_score_threshold,
@@ -161,7 +166,23 @@ def create_default_config(path: Path) -> Config:
"naming": {
"title_format": default_config.naming_title_format,
},
}
yaml_content = {
"library_root": str(default_config.library_root),
"video_extensions": default_config.video_extensions,
"templates": {
"movie_dir": default_config.movie_template,
"series_dir": default_config.series_template,
"movie_filename": default_config.movie_filename_template,
"series_filename": default_config.series_filename_template,
},
"quarantine_dir": default_config.quarantine_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,
}
path.parent.mkdir(parents=True, exist_ok=True)
@@ -287,5 +308,11 @@ def validate_config(config: Config) -> list[str]:
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")
return errors
+16
View File
@@ -0,0 +1,16 @@
"""CLI context shared by cli.py and command modules."""
import click
from vlm.config import Config
class CLIContext:
"""Context object to pass configuration and logger between commands."""
def __init__(self, config: Config, logger):
self.config = config
self.logger = logger
pass_context = click.make_pass_decorator(CLIContext)
+112 -26
View File
@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
from vlm.cache import EnrichmentCache
from vlm.config import Config
from vlm.providers import ProviderResult, TMDBProvider
from vlm.providers import ProviderResult, TMDBAuthError, TMDBProvider, TMDBProviderError
from vlm.parser import normalize_title
RefreshMode = str
@@ -59,6 +59,7 @@ def enrich_identities_data(
"failed": 0,
"api_calls": 0,
"failed_items": [],
"skip_reasons": {},
}
refresh_all = refresh_mode == "refresh_all"
@@ -69,6 +70,7 @@ def enrich_identities_data(
title = record.get("title") or _fallback_title_from_filename(record.get("filename"))
if not title:
stats["skipped"] = int(stats["skipped"]) + 1
_increment_skip_reason(stats, "invalid_input")
stats["processed"] = int(stats["processed"]) + 1
_emit_progress(stats, progress_callback)
continue
@@ -92,7 +94,7 @@ def enrich_identities_data(
_emit_progress(stats, progress_callback)
continue
payload, api_calls, failures = _enrich_record(
payload, api_calls, failures, skip_reason = _enrich_record(
record,
media_type,
providers,
@@ -100,26 +102,12 @@ def enrich_identities_data(
request_timeout=request_timeout,
retries=retries,
)
stats["api_calls"] = int(stats["api_calls"]) + api_calls
if failures:
failed_items = stats["failed_items"]
assert isinstance(failed_items, list)
failed_items.extend(failures)
stats["failed"] = int(stats["failed"]) + len(failures)
_apply_payload(record, payload)
cache.put_identity(identity_key, fingerprint, payload)
if payload.get("enriched"):
stats["enriched"] = int(stats["enriched"]) + 1
else:
stats["skipped"] = int(stats["skipped"]) + 1
if record.get("needs_review"):
stats["needs_review"] = int(stats["needs_review"]) + 1
stats["processed"] = int(stats["processed"]) + 1
_emit_progress(stats, progress_callback)
_update_stats_after_enrich(
stats, payload, failures, api_calls, record, progress_callback, skip_reason
)
metadata = identities_data.setdefault("metadata", {})
metadata["enriched"] = True
@@ -154,6 +142,33 @@ def _emit_progress(stats: dict[str, int | list[dict[str, str]]], progress_callba
)
def _update_stats_after_enrich(
stats: dict[str, int | list[dict[str, str]]],
payload: dict,
failures: list[dict[str, str]],
api_calls: int,
record: dict,
progress_callback: Optional[ProgressCallback],
skip_reason: str,
) -> None:
"""Update stats and emit progress after enriching a single record."""
stats["api_calls"] = int(stats["api_calls"]) + api_calls
if failures:
failed_items = stats["failed_items"]
assert isinstance(failed_items, list)
failed_items.extend(failures)
stats["failed"] = int(stats["failed"]) + len(failures)
if payload.get("enriched"):
stats["enriched"] = int(stats["enriched"]) + 1
else:
stats["skipped"] = int(stats["skipped"]) + 1
_increment_skip_reason(stats, skip_reason or "no_match")
if record.get("needs_review"):
stats["needs_review"] = int(stats["needs_review"]) + 1
stats["processed"] = int(stats["processed"]) + 1
_emit_progress(stats, progress_callback)
def _build_providers(config: Config, *, request_timeout: int, retries: int) -> list:
providers = []
unsupported: list[str] = []
@@ -163,6 +178,10 @@ def _build_providers(config: Config, *, request_timeout: int, retries: int) -> l
providers.append(
TMDBProvider(
config.tmdb_api_key,
bearer_token=config.tmdb_bearer_token,
language=config.tmdb_language,
region=config.tmdb_region,
include_adult=config.tmdb_include_adult,
timeout_seconds=request_timeout,
retries=retries,
min_interval_seconds=0.25,
@@ -187,37 +206,57 @@ def _enrich_record(
*,
request_timeout: int,
retries: int,
) -> tuple[dict, int, list[dict[str, str]]]:
) -> tuple[dict, int, list[dict[str, str]], str]:
title = record.get("title")
year = record.get("year") if media_type == "movie" else None
provider_results: list[ProviderResult] = []
failures: list[dict[str, str]] = []
api_calls = 0
configured_provider_count = 0
for provider in providers:
api_calls += 1
if not _provider_is_configured(provider, config):
continue
configured_provider_count += 1
try:
result = provider.enrich(title=title, media_type=media_type, year=year)
except TMDBAuthError as exc:
raise RuntimeError(str(exc)) from exc
except TMDBProviderError as exc:
failures.append(
{
"path": str(record.get("path", "")),
"title": str(title),
"provider": provider.name,
"reason": str(exc),
}
)
api_calls += provider.last_request_count
continue
except Exception as exc:
failures.append(
{
"path": str(record.get("path", "")),
"title": str(title),
"provider": getattr(provider, "name", "unknown"),
"provider": provider.name,
"reason": str(exc),
}
)
api_calls += provider.last_request_count
continue
api_calls += provider.last_request_count
if result:
provider_results.append(result)
merged = _merge_provider_results(provider_results)
# Optional AI fallback for missing translated titles.
if config.translation_fallback_machine:
if config.translation_fallback_machine and config.openai_api_key:
if not merged.get("title_zh"):
api_calls += 1
translated = _translate_with_openai(
title,
target_language="Chinese (Simplified)",
@@ -225,12 +264,12 @@ def _enrich_record(
timeout_seconds=request_timeout,
retries=retries,
)
api_calls += 1
if translated:
merged["title_zh"] = translated
merged["translation_source"] = merged.get("translation_source") or "openai"
if not merged.get("title_en"):
api_calls += 1
translated = _translate_with_openai(
title,
target_language="English",
@@ -238,7 +277,6 @@ def _enrich_record(
timeout_seconds=request_timeout,
retries=retries,
)
api_calls += 1
if translated:
merged["title_en"] = translated
merged["translation_source"] = merged.get("translation_source") or "openai"
@@ -266,7 +304,55 @@ def _enrich_record(
merged["enriched"] = bool(provider_results or merged.get("translation_source"))
merged["display_title"] = _build_display_title(record, merged, config)
return merged, api_calls, failures
skip_reason = _determine_skip_reason(
provider_results=provider_results,
failures=failures,
configured_provider_count=configured_provider_count,
api_calls=api_calls,
)
return merged, api_calls, failures, skip_reason
def _determine_skip_reason(
*,
provider_results: list[ProviderResult],
failures: list[dict[str, str]],
configured_provider_count: int,
api_calls: int,
) -> str:
if provider_results:
return ""
if configured_provider_count == 0 and api_calls == 0:
return "no_key"
if failures:
for failure in failures:
reason = str(failure.get("reason", "")).lower()
if "rate limit" in reason or "(429)" in reason:
return "rate_limited"
if "authentication failed" in reason or "(401/403)" in reason:
return "auth_error"
return "provider_error"
return "no_match"
def _increment_skip_reason(stats: dict[str, int | list[dict[str, str]]], reason: str) -> None:
if not reason:
return
current = stats.get("skip_reasons")
if not isinstance(current, dict):
current = {}
stats["skip_reasons"] = current
current[reason] = int(current.get(reason, 0)) + 1
def _provider_is_configured(provider: object, config: Config) -> bool:
provider_name = provider.name.lower()
if provider_name == "tmdb":
return bool(config.tmdb_bearer_token or config.tmdb_api_key)
return True
def _merge_provider_results(results: list[ProviderResult]) -> dict:
+7 -6
View File
@@ -16,6 +16,7 @@ from uuid import uuid4
from .logging_config import get_logger, log_operation
from .models import ExecutionPlan, FileOperation, OperationResult, RollbackLog
from .utils import ensure_utc, utc_now
class ExecutionEngine:
@@ -86,7 +87,7 @@ class ExecutionEngine:
rollback_log = RollbackLog(
log_id=str(uuid4()),
execution_plan_id=plan.plan_id,
executed_at=datetime.now(),
executed_at=utc_now(),
operations=successful_operations
)
@@ -116,7 +117,7 @@ class ExecutionEngine:
Returns:
OperationResult with success status and any error message
"""
executed_at = datetime.now()
executed_at = utc_now()
# Handle no-op operations
if operation.operation_type == "no-op":
@@ -385,19 +386,19 @@ class ExecutionEngine:
conflict_reason=None
)
# Reconstruct OperationResult
# Reconstruct OperationResult (normalize naive datetime to UTC)
op_result = OperationResult(
operation=file_op,
success=op_data["success"],
error_message=op_data["error_message"],
executed_at=datetime.fromisoformat(op_data["executed_at"])
executed_at=ensure_utc(datetime.fromisoformat(op_data["executed_at"]))
)
operations.append(op_result)
rollback_log = RollbackLog(
log_id=log_data["log_id"],
execution_plan_id=log_data["execution_plan_id"],
executed_at=datetime.fromisoformat(log_data["executed_at"]),
executed_at=ensure_utc(datetime.fromisoformat(log_data["executed_at"])),
operations=operations
)
@@ -468,7 +469,7 @@ class ExecutionEngine:
OperationResult indicating success or failure of the rollback
"""
operation = original_result.operation
executed_at = datetime.now()
executed_at = utc_now()
# Skip no-op operations
if operation.operation_type == "no-op":
+188
View File
@@ -0,0 +1,188 @@
"""Unified I/O layer for inventory and identities data."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Union
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
from vlm.utils import utc_now
# Re-export scanner CSV functions so CLI and others use a single I/O entry point
from vlm.scanner import load_inventory_csv, save_inventory_csv
__all__ = [
"load_inventory_csv",
"save_inventory_csv",
"load_identities_json",
"save_identities_json",
"identities_to_plan_input",
"identities_to_analysis_input",
]
def load_identities_json(path: Path) -> dict:
"""Load identities from JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_identities_json(data: dict, path: Path) -> None:
"""Save identities dict to JSON file."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _video_file_from_record(record: dict) -> VideoFile:
"""Build a minimal VideoFile from an identities record (no inventory metadata)."""
return VideoFile(
path=Path(record["path"]),
filename=record["filename"],
size_bytes=0,
modified_timestamp=utc_now(),
category=record["category"],
resolution=None,
codec=None,
duration_seconds=None,
bitrate_kbps=None,
)
def _movie_identity_from_record(m: dict) -> MovieIdentity:
"""Build MovieIdentity from identities JSON record."""
is_approved = m.get("review_status") == "approved"
return MovieIdentity(
title=m.get("display_title", m["title"]),
year=m.get("year"),
confidence=m["confidence"],
needs_review=(m["needs_review"] and not is_approved),
original_filename=m["filename"],
canonical_id=m.get("canonical_id"),
title_zh=m.get("title_zh"),
title_en=m.get("title_en"),
translation_source=m.get("translation_source"),
reputation_score=m.get("reputation_score"),
reputation_votes=m.get("reputation_votes"),
reputation_source=m.get("reputation_source"),
review_status=m.get("review_status", "pending"),
enrichment_confidence=m.get("enrichment_confidence"),
provider_metadata=m.get("provider_metadata", {}),
)
def _series_identity_from_record(s: dict) -> SeriesIdentity:
"""Build SeriesIdentity from identities JSON record."""
is_approved = s.get("review_status") == "approved"
return SeriesIdentity(
title=s.get("display_title", s["title"]),
season=s.get("season"),
episodes=s.get("episodes", []),
confidence=s["confidence"],
needs_review=(s["needs_review"] and not is_approved),
original_filename=s["filename"],
canonical_id=s.get("canonical_id"),
title_zh=s.get("title_zh"),
title_en=s.get("title_en"),
translation_source=s.get("translation_source"),
reputation_score=s.get("reputation_score"),
reputation_votes=s.get("reputation_votes"),
reputation_source=s.get("reputation_source"),
review_status=s.get("review_status", "pending"),
enrichment_confidence=s.get("enrichment_confidence"),
provider_metadata=s.get("provider_metadata", {}),
)
def identities_to_plan_input(
data: dict,
) -> list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]]:
"""Convert identities JSON dict to list of (VideoFile, Identity) for plan generator."""
result: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]] = []
movies_data = data.get("movies", [])
series_data = data.get("series", [])
anime_data = data.get("anime", [])
other_data = data.get("other", [])
for m in movies_data:
result.append((_video_file_from_record(m), _movie_identity_from_record(m)))
for s in series_data:
result.append((_video_file_from_record(s), _series_identity_from_record(s)))
for a in anime_data:
result.append((_video_file_from_record(a), None))
for o in other_data:
result.append((_video_file_from_record(o), None))
return result
def identities_to_analysis_input(
data: dict,
inventory_files: list[VideoFile] | None = None,
) -> tuple[list[MovieIdentity], list[SeriesIdentity], list[VideoFile]]:
"""Convert identities JSON dict to analysis inputs; optionally merge inventory metadata by path."""
movies_data = data.get("movies", [])
series_data = data.get("series", [])
movie_identities = []
for m in movies_data:
movie_identities.append(
MovieIdentity(
title=m["title"],
year=m.get("year"),
confidence=m["confidence"],
needs_review=m["needs_review"],
original_filename=m["filename"],
)
)
series_identities = []
for s in series_data:
series_identities.append(
SeriesIdentity(
title=s["title"],
season=s.get("season"),
episodes=s.get("episodes", []),
confidence=s["confidence"],
needs_review=s["needs_review"],
original_filename=s["filename"],
)
)
video_files: list[VideoFile] = []
path_to_inventory: dict[str, VideoFile] = {}
if inventory_files:
path_to_inventory = {str(vf.path): vf for vf in inventory_files}
for m in movies_data:
vf = _video_file_from_record(m)
if path_to_inventory:
inv = path_to_inventory.get(str(vf.path))
if inv:
vf = VideoFile(
path=inv.path,
filename=inv.filename,
size_bytes=inv.size_bytes,
modified_timestamp=inv.modified_timestamp,
category=inv.category,
resolution=inv.resolution,
codec=inv.codec,
duration_seconds=inv.duration_seconds,
bitrate_kbps=inv.bitrate_kbps,
)
video_files.append(vf)
for s in series_data:
vf = _video_file_from_record(s)
if path_to_inventory:
inv = path_to_inventory.get(str(vf.path))
if inv:
vf = VideoFile(
path=inv.path,
filename=inv.filename,
size_bytes=inv.size_bytes,
modified_timestamp=inv.modified_timestamp,
category=inv.category,
resolution=inv.resolution,
codec=inv.codec,
duration_seconds=inv.duration_seconds,
bitrate_kbps=inv.bitrate_kbps,
)
video_files.append(vf)
return movie_identities, series_identities, video_files
+9
View File
@@ -42,6 +42,11 @@ class VideoFile:
class MovieIdentity:
"""Represents the parsed identity of a movie file.
review_status (pending/approved/rejected) and needs_review overlap in meaning:
needs_review is True when (review_status == 'pending') and parsing or
enrichment indicates the record should be reviewed; once approved, needs_review
is typically False.
Attributes:
title: Extracted movie title (normalized)
year: Extracted release year (None if not found)
@@ -70,6 +75,10 @@ class MovieIdentity:
class SeriesIdentity:
"""Represents the parsed identity of a TV series episode file.
review_status (pending/approved/rejected) and needs_review overlap in meaning:
needs_review is True when (review_status == 'pending') and parsing or
enrichment indicates the record should be reviewed.
Attributes:
title: Extracted series title (normalized)
season: Extracted season number (None if not found)
+23 -6
View File
@@ -9,6 +9,11 @@ from typing import Optional
from vlm.models import MovieIdentity, SeriesIdentity
# Default extensions used when extensions param is not provided (matches config default)
DEFAULT_VIDEO_EXTENSIONS = [
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
]
# Quality tags to remove from titles
QUALITY_TAGS = [
@@ -80,7 +85,10 @@ def normalize_title(title: str) -> str:
return title.strip()
def parse_movie(filename: str) -> MovieIdentity:
def parse_movie(
filename: str,
extensions: Optional[list[str]] = None,
) -> MovieIdentity:
"""Parse a movie filename to extract title and year.
Supports patterns:
@@ -91,14 +99,17 @@ def parse_movie(filename: str) -> MovieIdentity:
Args:
filename: Movie filename to parse
extensions: Video extensions to strip (default: DEFAULT_VIDEO_EXTENSIONS)
Returns:
MovieIdentity with extracted information
"""
if extensions is None:
extensions = DEFAULT_VIDEO_EXTENSIONS
# Remove file extension
name_without_ext = filename
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
if name_without_ext.lower().endswith(ext):
for ext in extensions:
if name_without_ext.lower().endswith(ext.lower()):
name_without_ext = name_without_ext[:-len(ext)]
break
@@ -148,7 +159,10 @@ def parse_movie(filename: str) -> MovieIdentity:
)
def parse_series(filename: str) -> SeriesIdentity:
def parse_series(
filename: str,
extensions: Optional[list[str]] = None,
) -> SeriesIdentity:
"""Parse a series filename to extract title, season, and episode numbers.
Supports patterns:
@@ -160,14 +174,17 @@ def parse_series(filename: str) -> SeriesIdentity:
Args:
filename: Series filename to parse
extensions: Video extensions to strip (default: DEFAULT_VIDEO_EXTENSIONS)
Returns:
SeriesIdentity with extracted information
"""
if extensions is None:
extensions = DEFAULT_VIDEO_EXTENSIONS
# Remove file extension
name_without_ext = filename
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
if name_without_ext.lower().endswith(ext):
for ext in extensions:
if name_without_ext.lower().endswith(ext.lower()):
name_without_ext = name_without_ext[:-len(ext)]
break
+5 -3
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Union
from vlm.config import Config
from vlm.utils import ensure_utc, utc_now
from vlm.models import (
ExecutionPlan,
FileOperation,
@@ -48,7 +49,7 @@ def generate_plan(
return ExecutionPlan(
plan_id=str(uuid.uuid4()),
created_at=datetime.now(),
created_at=utc_now(),
operations=operations,
summary=summary
)
@@ -391,10 +392,11 @@ def load_plan(input_path: Path) -> ExecutionPlan:
for op in plan_dict["operations"]
]
# Reconstruct ExecutionPlan
# Reconstruct ExecutionPlan (normalize naive datetime to UTC for backward compatibility)
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
return ExecutionPlan(
plan_id=plan_dict["plan_id"],
created_at=datetime.fromisoformat(plan_dict["created_at"]),
created_at=created_at,
operations=operations,
summary=plan_dict["summary"]
)
+3 -1
View File
@@ -1,10 +1,12 @@
"""Provider implementations for enrichment."""
from vlm.providers.base import EnrichmentProvider, ProviderResult
from vlm.providers.tmdb import TMDBProvider
from vlm.providers.tmdb import TMDBAuthError, TMDBProvider, TMDBProviderError
__all__ = [
"EnrichmentProvider",
"ProviderResult",
"TMDBProvider",
"TMDBAuthError",
"TMDBProviderError",
]
+1
View File
@@ -26,6 +26,7 @@ class EnrichmentProvider(Protocol):
"""Protocol for title/score providers."""
name: str
last_request_count: int # API request count for the last enrich() call (reset at start of each call)
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
"""Return normalized metadata for a single identity."""
+168 -16
View File
@@ -3,14 +3,25 @@
from __future__ import annotations
import json
import re
import time
from typing import Optional
from difflib import SequenceMatcher
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import urlopen, Request
from urllib.request import Request, urlopen
from vlm.providers.base import ProviderResult
class TMDBAuthError(RuntimeError):
"""Raised when TMDB credentials are invalid."""
class TMDBProviderError(RuntimeError):
"""Raised for TMDB errors that should be reported as provider failures."""
class TMDBProvider:
"""Fetch translations and reputation data from TMDB."""
@@ -19,48 +30,66 @@ class TMDBProvider:
def __init__(
self,
api_key: Optional[str],
*,
bearer_token: Optional[str] = None,
language: str = "zh-CN",
region: Optional[str] = None,
include_adult: bool = False,
timeout_seconds: int = 6,
retries: int = 2,
min_interval_seconds: float = 0.25,
backoff_base_seconds: float = 0.5,
backoff_max_seconds: float = 4.0,
) -> None:
self.api_key = api_key
self.bearer_token = bearer_token
self.language = language
self.region = region
self.include_adult = include_adult
self.base_url = "https://api.themoviedb.org/3"
self.timeout_seconds = timeout_seconds
self.retries = retries
self.min_interval_seconds = min_interval_seconds
self.backoff_base_seconds = backoff_base_seconds
self.backoff_max_seconds = backoff_max_seconds
self._last_request_at = 0.0
self.last_request_count = 0
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
if not self.api_key:
self.last_request_count = 0
if not (self.bearer_token or self.api_key):
return None
search_type = "tv" if media_type in {"series", "anime", "tv"} else "movie"
query_params = {
"api_key": self.api_key,
query_params: dict[str, Any] = {
"query": title,
"language": self.language,
"include_adult": str(self.include_adult).lower(),
}
if self.region:
query_params["region"] = self.region
if year and search_type == "movie":
query_params["year"] = year
search_data = self._get_json(f"{self.base_url}/search/{search_type}", query_params)
search_data = self._get_json(f"/search/{search_type}", query_params)
if not search_data:
return None
results = search_data.get("results", [])
if not results:
if not isinstance(results, list) or not results:
return None
candidate, match_score = self._pick_best_candidate(results, title, year)
if not candidate:
return None
candidate = results[0]
tmdb_id = candidate.get("id")
if tmdb_id is None:
return None
details = self._get_json(
f"{self.base_url}/{search_type}/{tmdb_id}",
{"api_key": self.api_key, "language": self.language},
f"/{search_type}/{tmdb_id}",
{"language": self.language},
)
if not details:
details = candidate
@@ -79,10 +108,78 @@ class TMDBProvider:
reputation_score=float(vote_average) if vote_average is not None else None,
reputation_votes=int(vote_count) if vote_count is not None else None,
reputation_source=self.name,
match_score=float(candidate.get("popularity", 0.0)) if candidate.get("popularity") is not None else None,
match_score=round(match_score, 3),
raw_metadata={"media_type": search_type, "id": str(tmdb_id)},
)
def _pick_best_candidate(
self,
results: list[dict[str, Any]],
query_title: str,
query_year: Optional[int],
) -> tuple[Optional[dict[str, Any]], float]:
query_norm = self._normalize_title(query_title)
best_candidate: Optional[dict[str, Any]] = None
best_score = -1.0
for result in results:
candidates = [
result.get("title"),
result.get("name"),
result.get("original_title"),
result.get("original_name"),
]
title_score = 0.0
for candidate_title in candidates:
if not isinstance(candidate_title, str) or not candidate_title.strip():
continue
candidate_norm = self._normalize_title(candidate_title)
if not candidate_norm:
continue
ratio = SequenceMatcher(None, query_norm, candidate_norm).ratio()
if ratio > title_score:
title_score = ratio
year_bonus = 0.0
if query_year is not None:
release = result.get("release_date") or result.get("first_air_date")
candidate_year = self._extract_year(release)
if candidate_year is None:
year_bonus = -0.1
else:
delta = abs(candidate_year - query_year)
if delta == 0:
year_bonus = 0.2
elif delta == 1:
year_bonus = 0.1
else:
year_bonus = -0.2
popularity = result.get("popularity")
popularity_bonus = 0.0
if isinstance(popularity, (int, float)):
popularity_bonus = min(float(popularity) / 1000.0, 0.1)
total_score = title_score + year_bonus + popularity_bonus
if total_score > best_score:
best_score = total_score
best_candidate = result
return best_candidate, max(best_score, 0.0)
def _normalize_title(self, text: str) -> str:
lowered = text.lower().strip()
stripped = re.sub(r"[^\w\s]", " ", lowered)
return " ".join(stripped.split())
def _extract_year(self, date_text: Any) -> Optional[int]:
if not isinstance(date_text, str) or len(date_text) < 4:
return None
try:
return int(date_text[:4])
except (ValueError, TypeError):
return None
def _wait_for_rate_limit(self) -> None:
if self.min_interval_seconds <= 0:
return
@@ -91,18 +188,73 @@ class TMDBProvider:
if elapsed < self.min_interval_seconds:
time.sleep(self.min_interval_seconds - elapsed)
def _get_json(self, url: str, params: dict) -> Optional[dict]:
full_url = f"{url}?{urlencode(params)}"
request = Request(full_url, headers={"Accept": "application/json"})
def _sleep_backoff(self, attempt: int, retry_after: Optional[float] = None) -> None:
if retry_after is not None and retry_after > 0:
time.sleep(min(retry_after, self.backoff_max_seconds))
return
delay = min(self.backoff_base_seconds * (2 ** attempt), self.backoff_max_seconds)
time.sleep(delay)
for _ in range(max(self.retries + 1, 1)):
def _get_json(self, path: str, params: dict[str, Any]) -> Optional[dict]:
request_params = dict(params)
if not self.bearer_token and self.api_key:
request_params["api_key"] = self.api_key
full_url = f"{self.base_url}{path}?{urlencode(request_params)}"
headers = {"Accept": "application/json"}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
for attempt in range(max(self.retries + 1, 1)):
self._wait_for_rate_limit()
self.last_request_count += 1
request = Request(full_url, headers=headers)
try:
with urlopen(request, timeout=self.timeout_seconds) as response:
payload = response.read().decode("utf-8")
self._last_request_at = time.monotonic()
return json.loads(payload)
parsed = json.loads(payload)
if isinstance(parsed, dict):
return parsed
return None
except HTTPError as exc:
self._last_request_at = time.monotonic()
code = exc.code
if code in (401, 403):
raise TMDBAuthError(
"TMDB authentication failed (401/403). "
"Configure enrichment.api_keys.tmdb_bearer or enrichment.api_keys.tmdb."
) from exc
if code == 404:
return None
if code == 429:
if attempt < self.retries:
retry_after = None
try:
retry_after_header = exc.headers.get("Retry-After")
retry_after = float(retry_after_header) if retry_after_header else None
except Exception:
retry_after = None
self._sleep_backoff(attempt, retry_after=retry_after)
continue
raise TMDBProviderError("TMDB rate limit exceeded (429)") from exc
if 500 <= code < 600 and attempt < self.retries:
self._sleep_backoff(attempt)
continue
if 500 <= code < 600:
raise TMDBProviderError(f"TMDB server error ({code})") from exc
raise TMDBProviderError(f"TMDB request failed with HTTP {code}") from exc
except URLError as exc:
self._last_request_at = time.monotonic()
if attempt < self.retries:
self._sleep_backoff(attempt)
continue
raise TMDBProviderError(f"TMDB network error: {exc}") from exc
except Exception as exc:
self._last_request_at = time.monotonic()
if attempt < self.retries:
self._sleep_backoff(attempt)
continue
raise TMDBProviderError(f"TMDB unexpected error: {exc}") from exc
return None
+3 -2
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Optional
from .config import Config
from .utils import utc_now
from .logging_config import get_logger, log_operation
from .models import QuarantineEntry, QuarantineManifest, OperationResult, FileOperation
@@ -58,7 +59,7 @@ class QuarantineManager:
Raises:
ValueError: If file is in anime or other category (not supported in v1)
"""
executed_at = datetime.now()
executed_at = utc_now()
# Verify file exists
if not file_path.exists():
@@ -526,7 +527,7 @@ class QuarantineManager:
Returns:
OperationResult indicating success or failure
"""
executed_at = datetime.now()
executed_at = utc_now()
# Verify quarantine file exists
if not quarantine_path.exists():
+7 -6
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Optional
from vlm.models import FileState, StateStore
from vlm.utils import ensure_utc, utc_now
# Valid status values
@@ -32,20 +33,20 @@ def load_state(path: Path) -> StateStore:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Parse states dictionary
# Parse states dictionary (normalize naive datetime to UTC)
states = {}
for file_path_str, state_data in data.get('states', {}).items():
states[file_path_str] = FileState(
file_path=Path(state_data['file_path']),
status=state_data['status'],
reason=state_data.get('reason'),
updated_at=datetime.fromisoformat(state_data['updated_at'])
updated_at=ensure_utc(datetime.fromisoformat(state_data['updated_at']))
)
return StateStore(
states=states,
version=data.get('version', '1.0'),
last_updated=datetime.fromisoformat(data['last_updated'])
last_updated=ensure_utc(datetime.fromisoformat(data['last_updated']))
)
@@ -101,7 +102,7 @@ class StateManager:
self.store = StateStore(
states={},
version='1.0',
last_updated=datetime.now()
last_updated=utc_now()
)
def get_file_state(self, file_path: Path) -> Optional[FileState]:
@@ -136,7 +137,7 @@ class StateManager:
)
file_path_str = str(file_path)
now = datetime.now()
now = utc_now()
self.store.states[file_path_str] = FileState(
file_path=file_path,
@@ -170,7 +171,7 @@ class StateManager:
file_path_str = str(file_path)
if file_path_str in self.store.states:
del self.store.states[file_path_str]
self.store.last_updated = datetime.now()
self.store.last_updated = utc_now()
def save(self) -> None:
"""Save the current state store to disk."""
+24
View File
@@ -0,0 +1,24 @@
"""Shared utilities for Video Library Manager."""
from datetime import datetime, timezone
def utc_now() -> datetime:
"""Return current UTC time (timezone-aware)."""
return datetime.now(timezone.utc)
def ensure_utc(dt: datetime) -> datetime:
"""Ensure datetime is timezone-aware UTC (for backward compatibility with naive ISO strings)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def format_size(size_bytes: int) -> str:
"""Format file size in human-readable format (e.g. 1.5 GB, 234.2 MB)."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
+10 -10
View File
@@ -216,7 +216,7 @@ class TestDuplicateDetection:
),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should find one duplicate group (The Matrix)
assert len(result) == 1
@@ -260,7 +260,7 @@ class TestDuplicateDetection:
),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should find one duplicate group (S01E01)
assert len(result) == 1
@@ -282,7 +282,7 @@ class TestDuplicateDetection:
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(), "movie"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 0
@@ -298,7 +298,7 @@ class TestDuplicateDetection:
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(), "movie"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should not detect duplicates for files needing review
assert len(result) == 0
@@ -315,7 +315,7 @@ class TestDuplicateDetection:
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(), "series"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 0
@@ -331,7 +331,7 @@ class TestDuplicateDetection:
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(), "series"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 0
@@ -367,7 +367,7 @@ class TestDuplicateDetection:
),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 1
comparison = result[0].quality_comparison
@@ -400,7 +400,7 @@ class TestDuplicateDetection:
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should find duplicates for both E01 and E02
assert len(result) == 2
@@ -417,7 +417,7 @@ class TestDuplicateDetection:
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(), "movie"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 0
@@ -433,7 +433,7 @@ class TestDuplicateDetection:
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(), "series"),
]
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
assert len(result) == 0
+3 -3
View File
@@ -231,7 +231,7 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
))
# Detect duplicates
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should find exactly one duplicate group
assert len(result) == 1
@@ -277,7 +277,7 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
))
# Detect duplicates
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should find exactly one duplicate group
assert len(result) == 1
@@ -338,7 +338,7 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
))
# Detect duplicates
result = detect_duplicates(identities, files)
result = detect_duplicates(list(zip(identities, files)))
# Should have quality comparison data
assert len(result) == 1
+45
View File
@@ -134,3 +134,48 @@ enrichment:
assert result.exit_code == 1
assert "mutually exclusive" in result.output
def test_enrich_prints_text_progress_when_not_tty(tmp_path):
"""Non-TTY execution should emit textual progress updates."""
library_root = tmp_path / "library"
library_root.mkdir(parents=True)
config_file = tmp_path / "config.yaml"
config_file.write_text(
f"""
library_root: {library_root}
categories:
movie: [movie, movies]
series: [series, tv, shows]
anime: [anime]
enrichment:
cache_db: {tmp_path / 'cache.db'}
""".strip()
)
identities_file = tmp_path / "identities.json"
identities_file.write_text(json.dumps({
"metadata": {},
"movies": [
{
"path": "/library/movie/Test.2024.mkv",
"filename": "Test.2024.mkv",
"category": "movie",
"title": "Test",
"year": 2024,
"confidence": 0.9,
"needs_review": False,
}
],
"series": [],
"anime": [],
"other": [],
}))
runner = CliRunner()
result = runner.invoke(main, ["--config", str(config_file), "enrich", "--input", str(identities_file)])
assert result.exit_code == 0
assert "Progress: 1/1 (100%)" in result.output
assert "Skip reasons: no_key=1" in result.output
+5 -5
View File
@@ -27,8 +27,8 @@ categories:
)
runner = CliRunner()
with patch("vlm.scanner.scan_library", return_value=[]) as mock_scan, patch(
"vlm.scanner.save_inventory_csv"
with patch("vlm.commands.scan.scan_library", return_value=[]) as mock_scan, patch(
"vlm.commands.scan.save_inventory_csv"
) as mock_save:
result = runner.invoke(
main,
@@ -69,9 +69,9 @@ categories:
)
runner = CliRunner()
with patch("vlm.scanner.load_inventory_csv") as mock_load_cache, patch(
"vlm.scanner.scan_library", return_value=[]
) as mock_scan, patch("vlm.scanner.save_inventory_csv") as mock_save:
with patch("vlm.commands.scan.load_inventory_csv") as mock_load_cache, patch(
"vlm.commands.scan.scan_library", return_value=[]
) as mock_scan, patch("vlm.commands.scan.save_inventory_csv") as mock_save:
result = runner.invoke(
main,
[
+44
View File
@@ -180,6 +180,48 @@ categories:
assert config.categories["movie"] == ["movie", "movies", "films"]
assert config.categories["series"] == ["series", "tv", "shows"]
def test_load_config_with_tmdb_settings(self, tmp_path):
"""Test loading TMDB auth and query preferences from config."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
library_root: /test/library
enrichment:
api_keys:
tmdb_bearer: bearer-token
tmdb:
language: zh-TW
region: TW
include_adult: false
""")
config = load_config(config_file)
assert config.tmdb_bearer_token == "bearer-token"
assert config.tmdb_language == "zh-TW"
assert config.tmdb_region == "TW"
assert config.tmdb_include_adult is False
def test_load_config_with_enrich_alias(self, tmp_path):
"""Test loading enrichment settings from `enrich` alias."""
config_file = tmp_path / "config.yaml"
config_file.write_text("""
library_root: /test/library
enrich:
enabled: false
providers: [tmdb]
api_keys:
tmdb_bearer: alias-bearer-token
tmdb:
language: en-US
""")
config = load_config(config_file)
assert config.enrichment_enabled is False
assert config.enrichment_providers == ["tmdb"]
assert config.tmdb_bearer_token == "alias-bearer-token"
assert config.tmdb_language == "en-US"
class TestCreateDefaultConfig:
"""Test create_default_config function."""
@@ -211,6 +253,8 @@ class TestCreateDefaultConfig:
assert 'templates' in data
assert 'log_level' in data
assert 'quarantine_dir' in data
assert 'enrichment' in data
assert 'enrich' in data
def test_create_default_config_creates_parent_dirs(self, tmp_path):
"""Test that create_default_config creates parent directories."""
+87
View File
@@ -5,6 +5,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.tmdb import TMDBAuthError
class DummyProvider:
@@ -12,9 +13,11 @@ class DummyProvider:
def __init__(self):
self.calls = 0
self.last_request_count = 0
def enrich(self, *, title: str, media_type: str, year=None):
self.calls += 1
self.last_request_count = 1
return ProviderResult(
provider="dummy",
canonical_id=f"dummy:{title}",
@@ -83,6 +86,7 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
class LowScoreProvider(DummyProvider):
def enrich(self, *, title: str, media_type: str, year=None):
self.calls += 1
self.last_request_count = 1
return ProviderResult(
provider="dummy",
canonical_id=f"dummy:{title}",
@@ -192,9 +196,11 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
def __init__(self):
self.calls = 0
self.last_request_count = 0
def enrich(self, *, title: str, media_type: str, year=None):
self.calls += 1
self.last_request_count = 1
if self.calls == 1:
return ProviderResult(
provider="dummy",
@@ -264,3 +270,84 @@ def test_build_display_title_deduplicates_fallback_title(tmp_path):
payload = {"title_zh": None, "title_en": None}
assert _build_display_title(record, payload, config) == "Interstellar"
def test_enrich_without_provider_keys_does_not_count_api_calls(tmp_path):
"""Missing provider keys should not inflate API call metrics."""
config = Config(
library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["tmdb"],
translation_fallback_machine=True,
tmdb_api_key=None,
openai_api_key=None,
)
identities = {
"metadata": {},
"movies": [
{
"path": "/library/movie/Test.2020.mkv",
"filename": "Test.2020.mkv",
"category": "movie",
"title": "Test",
"year": 2020,
"confidence": 0.9,
"needs_review": False,
}
],
"series": [],
"anime": [],
"other": [],
}
_, stats = enrich_identities_data(identities, config, refresh_mode="refresh_all")
assert stats["api_calls"] == 0
assert stats["enriched"] == 0
assert stats["skip_reasons"] == {"no_key": 1}
def test_enrich_auth_failure_stops_immediately(tmp_path, monkeypatch):
"""Provider authentication errors should stop the run immediately."""
class AuthFailProvider:
name = "tmdb"
last_request_count = 1
def enrich(self, *, title: str, media_type: str, year=None):
raise TMDBAuthError("TMDB authentication failed (401/403)")
config = Config(
library_root=tmp_path,
enrichment_cache_db=tmp_path / "cache.db",
enrichment_providers=["tmdb"],
tmdb_api_key="fake-key",
translation_fallback_machine=False,
)
monkeypatch.setattr(
"vlm.enrichment._build_providers",
lambda _config, request_timeout, retries: [AuthFailProvider()],
)
identities = {
"metadata": {},
"movies": [
{
"path": "/library/movie/Test.2020.mkv",
"filename": "Test.2020.mkv",
"category": "movie",
"title": "Test",
"year": 2020,
"confidence": 0.9,
"needs_review": False,
}
],
"series": [],
"anime": [],
"other": [],
}
with pytest.raises(RuntimeError, match="authentication failed"):
enrich_identities_data(identities, config, refresh_mode="refresh_all")
+1 -1
View File
@@ -83,7 +83,7 @@ class TestReportsIntegration:
]
# Detect duplicates
duplicates = detect_duplicates(identities, files)
duplicates = detect_duplicates(list(zip(identities, files)))
# Generate text report
library_root = Path("/mnt/nas/videos")
+6 -4
View File
@@ -2,7 +2,7 @@
import json
import pytest
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from vlm.state import (
load_state,
@@ -33,7 +33,8 @@ class TestLoadSaveState:
assert loaded.states == {}
assert loaded.version == '1.0'
assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0)
# load_state normalizes naive ISO timestamps to UTC
assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
def test_save_and_load_with_states(self, tmp_path):
"""Test saving and loading state store with file states."""
@@ -74,13 +75,14 @@ class TestLoadSaveState:
assert state1.file_path == file1
assert state1.status == "reviewed"
assert state1.reason == "Checked manually"
assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0)
# load_state normalizes naive ISO timestamps to UTC
assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
state2 = loaded.states[str(file2)]
assert state2.file_path == file2
assert state2.status == "ignored"
assert state2.reason is None
assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0)
assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc)
def test_save_creates_parent_directory(self, tmp_path):
"""Test that save_state creates parent directories if needed."""
Generated
+263
View File
@@ -0,0 +1,263 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "hypothesis"
version = "6.151.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/c40dcd401cc360d8d084e584ffb7ab17255fde22e2b9cf2b53bf25aed629/hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783", size = 475923, upload-time = "2026-02-03T19:33:55.845Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d9/53a8b53e75279a953fae608bd01025d9afcf393406c0da1dda1b7f5693c5/hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13", size = 543338, upload-time = "2026-02-03T19:33:54.583Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
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 = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
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 = "video-library-manager"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "pyyaml" },
]
[package.optional-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0" },
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
{ name = "pyyaml", specifier = ">=6.0" },
]
provides-extras = ["dev"]