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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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*
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
- 输出基线报告(用于对比重构前后变化)。
|
||||
|
||||
### 阶段 1:TMDB Provider 重构
|
||||
- 新增 TMDB HTTP 访问层(可内聚在 provider 文件内):
|
||||
- 统一请求构建(query/header/timeout)。
|
||||
- 统一响应解析与错误分类。
|
||||
- 错误分类与策略:
|
||||
- `401/403`:鉴权失败,停止该条 provider 请求并记录原因。
|
||||
- `404`:资源缺失,返回无匹配。
|
||||
- `429`:指数退避重试(含上限)。
|
||||
- `5xx`:有限重试,最终记录失败。
|
||||
- 保留最小调用路径:`search -> details`。
|
||||
|
||||
### 阶段 2:Enrichment 统计与语义修复
|
||||
- 统一并明确统计口径:
|
||||
- `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
File diff suppressed because it is too large
Load Diff
+97174
File diff suppressed because it is too large
Load Diff
+4672
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user