commit remaining modified project files

This commit is contained in:
windyboy
2026-04-07 08:07:18 +08:00
parent d010cf936c
commit fb128c70d6
28 changed files with 4140 additions and 19643 deletions
+3
View File
@@ -20,6 +20,9 @@ uv pip install -e .
# Install with dev dependencies (pytest, hypothesis)
uv pip install -e ".[dev]"
# Optional Textual UI for `vlm review-plan --tui`
uv pip install -e ".[tui]"
```
### Testing
+22 -5
View File
@@ -1,7 +1,7 @@
# GEMINI.md
## Documentation Status
- Synced with repository refactor baseline on 2026-02-16 (source of truth: `CHANGELOG.md`).
- Synced with repository refactor baseline on 2026-02-20 (source of truth: `CHANGELOG.md`).
This document provides a comprehensive overview of the Video Library Manager (VLM) project, intended to be used as instructional context for Gemini.
@@ -11,10 +11,12 @@ The Video Library Manager (VLM) is a Python-based CLI tool designed for managing
**Core Functionality:**
* **Scanning & Parsing:** Discovers video files, extracts metadata (file info, video properties via `ffprobe`), and parses filenames to identify titles, years, seasons, and episodes.
* **Scanning & Parsing:** Discovers video files, extracts metadata. Proactively detects `ffprobe` for video properties with graceful fallback to file-level metadata. Parses filenames for titles, years, seasons, and episodes, with specific support for Anime-style hyphenated numbering and release group stripping.
* **Metadata Enrichment:** Augments local data with information from TMDB, including bilingual titles and reputation scores. It uses a local SQLite cache to improve performance.
* **Analysis:** Detects duplicate files (with quality comparisons) and identifies gaps in TV series episodes.
* **Planning & Execution:** Generates a reviewable JSON-based execution plan for file operations (move, rename, quarantine). The plan is executed only upon user confirmation.
* **Planning & Execution:** Generates a reviewable JSON-based execution plan for file operations (move, rename, quarantine). Supports "safe mode" and directory preservation.
* **Plan Review Cycle:** Exports high-risk operations to CSV for manual confirmation (`review-plan`) and synchronizes user decisions back to the master plan (`apply-review`), enabling a full human-in-the-loop workflow.
* **Quarantine Management:** Safely isolates files for review, with full support for listing and restoration.
* **Reporting:** Creates reports for inventory, duplicate files, and series completeness.
* **State Management:** Tracks the status of files throughout the organization workflow.
@@ -29,14 +31,25 @@ The Video Library Manager (VLM) is a Python-based CLI tool designed for managing
**Architecture:**
The project follows a modular structure located in the `src/vlm` directory. Key modules include:
The project follows a modular structure located in the `src/vlm` directory.
* `cli.py`: The main entry point for the CLI, using Click.
* `commands/*.py`: Implementation of the individual CLI commands (scan, parse, enrich, etc.).
* `commands/*.py`: Implementation of the individual CLI commands (scan, parse, enrich, analyze, plan, execute/rollback).
* `scanner.py`, `parser.py`, `enrichment.py`, `analysis.py`, `planner.py`, `executor.py`: Core logic for the different stages of the workflow.
* `io.py`: Unified I/O layer for JSON and CSV handling.
* `cache.py`: Local SQLite cache for TMDB metadata.
* `context.py`: CLI context and state management for command execution.
* `duplicate_resolve.py`: Logic for resolving duplicate files based on quality and metadata.
* `logging_config.py`: Centralized logging configuration.
* `plan_review.py`: Risk analysis and manual review generation for execution plans.
* `quarantine.py`: Management of quarantined files (listing, adding, restoring).
* `reports.py`: Generation of inventory, completeness, and duplicate reports.
* `state.py`: File status tracking and persistence (reviewed, ignored, planned, etc.).
* `transaction.py`: Atomic filesystem operations and transaction logging for reliability.
* `providers/tmdb.py`: Client for interacting with the TMDB API.
* `models.py`: Defines the data structures used throughout the application.
* `config.py`: Manages application configuration from a YAML file.
* `utils.py`: General utility functions (formatting, path handling).
## Building and Running
@@ -57,9 +70,13 @@ The main entry point is the `vlm` command.
* Enrich metadata: `uv run vlm enrich`
* Analyze the library: `uv run vlm analyze`
* Generate a plan: `uv run vlm plan`
* Review a plan: `uv run vlm review-plan`
* Execute the plan (dry-run): `uv run vlm execute`
* Execute the plan (with confirmation): `uv run vlm execute --confirm`
* Rollback the last execution: `uv run vlm rollback`
* Generate reports: `uv run vlm report [inventory|completeness|duplicates|summary]`
* Manage quarantine: `uv run vlm quarantine [list|add|restore]`
* Manage file states: `uv run vlm state [show|set|query|clear]`
**Running tests:**
+8
View File
@@ -35,6 +35,9 @@ uv pip install -e .
# Install with development dependencies
uv pip install -e ".[dev]"
# Optional: Textual TUI for `vlm review-plan --tui`
uv pip install -e ".[tui]"
```
## Quick Start
@@ -230,6 +233,8 @@ vlm plan --analysis artifacts/analysis.json
# 7. Review the plan in terminal (summary + high-risk preview)
vlm review-plan
# Optional: interactive full-screen review (install `.[tui]` first)
vlm review-plan --tui
# Optional: control preview size
vlm review-plan --preview-limit 20
# Optional: show every high-risk operation in terminal
@@ -352,6 +357,9 @@ vlm plan --input my_identities.json --analysis my_analysis.json --output my_plan
# Export high-risk operations and preview them in terminal
vlm review-plan
# Interactive Textual UI (install optional dependency: uv pip install -e ".[tui]")
vlm review-plan --tui
# Preview first N high-risk operations in terminal (default: 10)
vlm review-plan --preview-limit 20
+152
View File
@@ -0,0 +1,152 @@
# Technical review: video-library-manager
## Metadata
| Field | Value |
|--------|--------|
| Repository path | `dl-organizer` (package `vlm`) |
| Verification date (UTC) | 2026-04-06 |
| Git revision verified | `ea21e15` |
| Test run | `uv run pytest -q`**496 passed** |
## Scope
- Source: `src/vlm/**/*.py`, `tests/`
- Config: `pyproject.toml`
- Assumption: single-user local library; artifacts semi-trusted; no formal threat model in repo.
## Executive summary
Architecture and tests are solid. Verified gaps: **no `library_root` check on move/rename source** in executor; **`by_quality` falls back to index 0 without surfacing reason**; **quarantine raises `ValueError` for unsupported category** while `execute_plan` has no per-operation try/except, so one bad op can abort the run; **duplicate join uses exact string path keys**; **`find` non-zero exit still consumes stdout**.
Weighted score (010): **8.0**.
## Score rubric
| Criterion | Score | Notes |
|-----------|-------|--------|
| Module boundaries / pipeline | 8.5 | Commands → domain modules; scan→parse→analyze→plan→execute is explicit. |
| Execution safety (filesystem) | 7.0 | Destination under root checked; **source not checked** for move/rename. |
| Planning / duplicate logic | 7.5 | Multiple strategies; string path identity; silent `by_quality` fallback; unknown `strategy` → keep index 0 (`duplicate_resolve.py:48`). |
| Data I/O & validation | 8.0 | `io.py` validates identities/analysis/plan shapes; validated objects remain dict-typed at boundaries (`type: ignore` in places). |
| Error handling consistency | 7.5 | Quarantine: `raise` vs `OperationResult` mismatch on category rejection. |
| Test signal | 8.5 | Broad `tests/`; path safety and resolver covered (`test_path_safety.py`, `test_duplicate_resolve.py`, etc.). |
| Dependencies | 9.0 | Runtime: `click`, `pyyaml` only (`pyproject.toml:79`). |
## Findings
### F1 [High] Move/rename: source not constrained to `library_root`
**Evidence:** `ExecutionEngine._perform_operation` checks destination with `is_within_root(..., self.config.library_root)` then calls `operation.source_path.rename(...)` without checking the source (`executor.py:354377`).
**Impact:** Malformed or hand-edited `plan.json` can rename **from any path the process can access** into the library tree. `QuarantineManager.quarantine_file` instead requires `file_path.relative_to(self.config.library_root)` (`quarantine.py:133137`).
**Recommendation:** For `move` / `rename`, require `is_within_root(operation.source_path, self.config.library_root)` (after `Path` resolution policy is defined). Fail with `OperationResult(success=False)`. Optional config gate for deliberate “import from outside,” default off. Add test alongside `tests/test_path_safety.py:76103`.
---
### F2 [Medium] `by_quality`: silent fallback to first item
**Evidence:** `choose_keep_index``if quality_comparison is None or len(quality_comparison) != len(items): return 0` (`duplicate_resolve.py:4042`).
**Impact:** Behavior equals **input order**, not quality, with no structured flag in plan metadata from this function alone.
**Recommendation:** Return `None` and skip auto-quarantine for that group, or require CLI/plan failure when `duplicate_keep == by_quality` and rows misaligned, or write explicit `metadata` / summary line when fallback occurs.
---
### F3 [Medium] Duplicate group ↔ identities join: exact path strings
**Evidence:** `path_to_index = {str(vf.path): i for ...}` and `indices = [path_to_index[p] for p in paths if p in path_to_index]` (`planner.py:108111`). Analysis `files` must match `str(VideoFile.path)` byte-for-byte.
**Impact:** Symlinks, differing normalization between analysis writer and identities loader, or OS case rules can **drop** files from duplicate resolution.
**Recommendation:** Normalize keys (e.g. `utils.canonical_path_str`) at both analysis emission and plan consumption; document contract; regression test if symlinks are in scope.
---
### F4 [LowMedium] Quarantine: `ValueError` vs `OperationResult`
**Evidence:** Unsupported category path does `raise ValueError(error_msg)` after logging (`quarantine.py:116129`). Other failure modes return `OperationResult`.
**Evidence:** `execute_plan` loop calls `execute_operation` with no try/except (`executor.py:110112`).
**Impact:** One invalid quarantine operation (e.g. manual plan) can **abort** the whole execute pass instead of recording a single failed result.
**Recommendation:** Return failed `OperationResult` for unsupported category; reserve exceptions for invariant violations only.
---
### F5 [Low] `find` non-zero exit: stdout still parsed
**Evidence:** On `process.returncode != 0`, code logs stderr if present, then always parses `stdout` into paths (`scanner.py:178192`).
**Impact:** Partial or stale listing possible without hard failure.
**Recommendation:** Document behavior; optionally fail if `returncode != 0` and empty result, or add `--strict-scan`.
---
### F6 [Low] Optional dependencies: `textual` in both `dev` and `tui`
**Evidence:** `pyproject.toml:1316` (`dev`), `1819` (`tui`).
**Impact:** Install surface ambiguity only.
**Recommendation:** Document that `dev` includes Textual for TUI-related tests, or slim `dev` if CI wants fewer deps.
---
### F7 [Informational] Validated JSON remains dict-typed at I/O edge
**Evidence:** `validate_plan_json` returns `dict` (`io.py:224251`); constructors may still bridge dict ↔ dataclass elsewhere.
**Impact:** Field drift between validator and `models.py` possible over time.
**Recommendation:** Single factory path: validated dict → domain objects for hot paths.
---
### F8 [Informational] Unknown `duplicate_keep` strategy string
**Evidence:** After strategy checks, `choose_keep_index` falls through to `return 0` (`duplicate_resolve.py:48`).
**Impact:** Config typo **keeps first duplicate** like `first_seen` without failing load.
**Recommendation:** Validate `duplicate_keep` in `config.py` / `validate_config` against an allowlist; reject unknown values.
## Verified strengths
| Claim | Evidence |
|--------|----------|
| `find` invoked without shell string | argv list: `subprocess.Popen(command, ...)` (`scanner.py:171172`, `164169`). |
| Path component sanitization | `sanitize_path_component` strips controls and separators (`utils.py:6575`); planner uses it before templates (`planner.py:301`, `419`). |
| Destination under library in planner | `is_within_root(destination, config.library_root)` (`planner.py:321`, `440`). |
| Quarantine manifest two-phase | Pending manifest write before `rename` (`quarantine.py:221268` region). |
| JSON artifact validation | `_validate_parsed_identities_json`, `_validate_analysis_json`, `validate_plan_json` (`io.py:157251`). |
| Parallel ffprobe | `ThreadPoolExecutor` when `include_video_metadata` and more than one path (`scanner.py:85101`). |
## Priority order
1. F1
2. F2
3. F4
4. F3
5. F8, F5, F6, F7
## Verification log (document vs codebase)
| Statement in this doc | Checked against |
|------------------------|-----------------|
| F1 source not under root guard | `executor.py:321377` |
| F2 by_quality fallback | `duplicate_resolve.py:4043` |
| F3 path_to_index | `planner.py:108111` |
| F4 raise + no try in loop | `quarantine.py:116129`, `executor.py:110112` |
| F5 find returncode | `scanner.py:178192` |
| F6 pyproject optional | `pyproject.toml:1220` |
| F7 validate_plan_json returns dict | `io.py:224251` |
| F8 unknown strategy | `duplicate_resolve.py:3648` |
| Strengths table | `scanner.py`, `utils.py`, `planner.py`, `quarantine.py`, `io.py` as cited |
| 496 tests passed | `uv run pytest -q` on 2026-04-06 |
This document is evidence-based against the paths above; behavior not re-listed here is **not** claimed verified.
+2364 -19482
View File
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -1,5 +1,5 @@
# vlm_schema_version: 1.0
# Generated: 2026-02-13T03:25:12
# Generated: 2026-02-20T15:00:47
# Library Root: /mnt/Downloads
path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps
/mnt/Downloads/anime/[Nekomoe kissaten][ZENSHU][01][1080p][CHS].mp4,[Nekomoe kissaten][ZENSHU][01][1080p][CHS].mp4,702447940,2025-01-10T00:07:25,anime,1920x1080,h264,1434.877098,3916
@@ -878,7 +878,7 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/anime/[ANi] 不時輕聲地以俄語遮羞的鄰座艾莉同學 - 12 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 不時輕聲地以俄語遮羞的鄰座艾莉同學 - 12 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,301871147,2024-09-18T15:06:29,anime,1920x1080,h264,1510.037333,1599
/mnt/Downloads/anime/[ANi] 判處勇者刑 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 判處勇者刑 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,389019769,2026-01-29T13:46:20,anime,1920x1080,h264,1470.122667,2116
/mnt/Downloads/anime/[ANi] 新人大叔冒險者,被最強隊伍操到死成無敵 - 10 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 新人大叔冒險者,被最強隊伍操到死成無敵 - 10 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,487855442,2024-09-10T01:29:26,anime,1920x1080,h264,1424.9862,2738
/mnt/Downloads/anime/《盾之勇者成名錄 第二季》#7/《盾之勇者成名錄 第二季》#7 (日語原聲)【Ani-One ULTRA】.mp4,《盾之勇者成名錄 第二季》#7 (日語原聲)【Ani-One ULTRA】.mp4,182456935,2022-05-19T01:46:49,anime,1920x1080,h264,1420.109206,1027
/mnt/Downloads/anime/《盾之勇者成名錄 第二季》#7/《盾之勇者成名錄 第二季》#7 (日語原聲)【Ani-One ULTRA】.mp4,《盾之勇者成名錄 第二季》#7 (日語原聲)【Ani-One ULTRA】.mp4,182456935,2022-05-19T01:46:49,anime,,,,
/mnt/Downloads/anime/[ANi] 失憶投捕 - 11 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 失憶投捕 - 11 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,462126467,2024-06-25T23:11:27,anime,1920x1080,h264,1435.008,2576
/mnt/Downloads/anime/[ANi] 身為暗殺者的我明顯比勇者還強 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 身為暗殺者的我明顯比勇者還強 - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,621838661,2025-10-21T05:36:29,anime,1920x1080,h264,1430.074622,3478
/mnt/Downloads/anime/[ANi] 使人誤解的工房主~關於原英雄隊伍的雜役人員,實際上除了戰鬥能力外全是SSS的故事~ - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 使人誤解的工房主~關於原英雄隊伍的雜役人員,實際上除了戰鬥能力外全是SSS的故事~ - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,284650804,2025-04-20T23:12:12,anime,1920x1080,h264,1420.074667,1603
@@ -1842,6 +1842,7 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/anime/[Nekomoe kissaten][Ishura][11][1080p][JPSC].mp4,[Nekomoe kissaten][Ishura][11][1080p][JPSC].mp4,296077840,2024-03-15T00:08:45,anime,1920x1080,h264,1422.086667,1665
/mnt/Downloads/anime/[Nekomoe kissaten][Dungeon Meshi][19][1080p][JPTC].mp4,[Nekomoe kissaten][Dungeon Meshi][19][1080p][JPTC].mp4,449481550,2024-05-11T15:25:25,anime,1920x1080,h264,1531.114667,2348
/mnt/Downloads/anime/[orion origin] Boukyaku Battery [04] [1080p] [H265 AAC] [CHTJPN].mp4,[orion origin] Boukyaku Battery [04] [1080p] [H265 AAC] [CHTJPN].mp4,431953387,2024-06-27T12:13:53,anime,1920x1080,hevc,1435.039002,2408
/mnt/Downloads/anime/[ANi] 判處勇者刑 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 判處勇者刑 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,399301238,2026-02-20T07:12:32,anime,1920x1080,h264,1470.037333,2173
/mnt/Downloads/anime/[ANi] 我獨自升級 第二季 -起於闇影- - 14 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 我獨自升級 第二季 -起於闇影- - 14 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,608358255,2025-01-12T00:18:23,anime,1920x1080,h264,1425.365333,3414
/mnt/Downloads/anime/[ANi] 素材採集家的異世界旅行記 - 02 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 素材採集家的異世界旅行記 - 02 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,363409357,2025-10-21T01:22:01,anime,1920x1080,h264,1420.022911,2047
/mnt/Downloads/anime/Record.of.Ragnarok.S02E01.1080p.NF.WEB-DL.DUAL.DDP2.0.H.264-SMURF/Record.of.Ragnarok.S02E04.The.Final.Labor.1080p.NF.WEB-DL.DUAL.DDP2.0.H.264-SMURF.mkv,Record.of.Ragnarok.S02E04.The.Final.Labor.1080p.NF.WEB-DL.DUAL.DDP2.0.H.264-SMURF.mkv,1210343939,2023-02-05T02:57:53,anime,1920x1080,h264,1496.096,6472
@@ -2181,6 +2182,7 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/anime/[LoliHouse] DanMachi S5 - 09 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv,[LoliHouse] DanMachi S5 - 09 [WebRip 1080p HEVC-10bit AAC SRTx2].mkv,318361428,2024-12-19T13:59:53,anime,1920x1080,hevc,1420.016,1793
/mnt/Downloads/anime/[ANi] 香格里拉・開拓異境~糞作獵手挑戰神作~ 第二季 - 20 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 香格里拉・開拓異境~糞作獵手挑戰神作~ 第二季 - 20 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,818993745,2025-02-23T10:45:22,anime,1920x1080,h264,1422.066622,4607
/mnt/Downloads/anime/[Dont be a simp] Ao no Hako - 23 [NF WebRip 1080p HEVC-10bit E-AC-3 Multi-Subs].mkv,[Dont be a simp] Ao no Hako - 23 [NF WebRip 1080p HEVC-10bit E-AC-3 Multi-Subs].mkv,527846281,2025-03-16T23:28:16,anime,1920x1080,hevc,1434.176,2944
/mnt/Downloads/anime/[ANi] 魔都精兵的奴隸 第二季 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 魔都精兵的奴隸 第二季 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,386592868,2026-02-20T07:11:40,anime,1920x1080,h264,1422.066622,2174
/mnt/Downloads/anime/[ANi] 永遠的黃昏 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 永遠的黃昏 - 05 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,303831547,2025-10-30T23:55:09,anime,1920x1080,h264,1434.965333,1693
/mnt/Downloads/anime/[ANi] NUKITASHI 住在拔作島上的我該如何是好? [年齡限制版] - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] NUKITASHI 住在拔作島上的我該如何是好? [年齡限制版] - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,456559198,2025-08-02T12:56:07,anime,1920x1080,h264,1425.045333,2563
/mnt/Downloads/anime/[ANi] 貫徹輔助魔法支援弱小隊友的宮廷魔法師,慘遭驅逐後目標成為最強冒險者 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,[ANi] 貫徹輔助魔法支援弱小隊友的宮廷魔法師,慘遭驅逐後目標成為最強冒險者 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4,367252101,2025-11-16T00:36:47,anime,1920x1080,h264,1417.152,2073
@@ -2595,7 +2597,6 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/西游降魔篇.Journey.to.the.West.Conquering.the.Demons.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/Journey.to.the.West.Conquering.the.Demons.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,Journey.to.the.West.Conquering.the.Demons.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,7069807110,2021-02-17T16:35:58,movie,1920x816,hevc,6583.264,8591
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/唐伯虎点秋香.Flirting.Scholar.1993.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/Flirting.Scholar.1993.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,Flirting.Scholar.1993.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,9766127065,2021-02-17T16:36:04,movie,1920x1080,hevc,6131.167,12742
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/鹿鼎记2:神龙教.Royal.Tramp.II.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/Royal.Tramp.II.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,Royal.Tramp.II.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,6108040701,2021-02-17T16:35:54,movie,1920x1024,hevc,5859.648,8339
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/赌侠.God.of.Gamblers.II.1991.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/God.of.Gamblers.II.1991.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,God.of.Gamblers.II.1991.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,5467405922,2021-02-17T16:35:53,movie,1920x1080,hevc,6286.334,6957
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/望夫成龙.Love.is.Love.1990.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/Love.is.Love.1990.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,Love.is.Love.1990.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,5004906707,2021-02-17T16:31:05,movie,1920x1038,hevc,5734.02,6982
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/大内密探零零发.Forbidden.City.Cop.1996.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/Forbidden.City.Cop.1996.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,Forbidden.City.Cop.1996.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,3857331231,2021-02-17T16:36:00,movie,1920x1080,hevc,5322.667,5797
/mnt/Downloads/movies/周星驰.Stephen.Chow.1988-2017.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/武状元苏乞儿.King.of.Beggars.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/King.of.Beggars.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,King.of.Beggars.1992.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv,6798150592,2021-02-17T16:36:14,movie,1920x1024,hevc,6002.747,9060
@@ -2929,7 +2930,6 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/movies/Dog.2022.1080p.BluRay.x264.DTS-WiKi/Sample/Dog.2022.1080p.BluRay.x264.DTS-WiKi.Sample.mkv,Dog.2022.1080p.BluRay.x264.DTS-WiKi.Sample.mkv,122723290,2022-05-26T10:20:05,movie,1920x800,h264,62.563,15692
/mnt/Downloads/movies/Vernost AKA Fidelity 2019 1080p BluRay DD5.1 x264-BdC.mkv,Vernost AKA Fidelity 2019 1080p BluRay DD5.1 x264-BdC.mkv,8768133740,2020-12-24T14:16:05,movie,1920x804,h264,4906.112,14297
/mnt/Downloads/movies/Swordsman.2.1992.BluRay.1080p.2Audio.TrueHD.5.1.x265.10bit-BeiTai/Swordsman.2.1992.BluRay.1080p.2Audio.TrueHD.5.1.x265.10bit-BeiTai.mkv,Swordsman.2.1992.BluRay.1080p.2Audio.TrueHD.5.1.x265.10bit-BeiTai.mkv,14811770763,2020-06-26T14:41:14,movie,1920x1080,hevc,6514.976,18187
/mnt/Downloads/movies/Detective VS. Sleuths 2022 WEB-DL 4K H.265 DDP 2.0 & AAC 4Audios-Dave.mkv,Detective VS. Sleuths 2022 WEB-DL 4K H.265 DDP 2.0 & AAC 4Audios-Dave.mkv,3684770524,2022-08-27T10:57:48,movie,3840x1612,hevc,6056.832,4866
/mnt/Downloads/movies/Extraction.2020.1080p.NF.WEB-DL.DDP5.1.Atmos.HDR.HEVC-CMRG/Extraction.2020.1080p.NF.WEB-DL.DDP5.1.Atmos.HDR.HEVC-CMRG.mkv,Extraction.2020.1080p.NF.WEB-DL.DDP5.1.Atmos.HDR.HEVC-CMRG.mkv,4768429320,2020-04-26T05:42:43,movie,1920x1080,hevc,7046.688,5413
/mnt/Downloads/movies/Julie.&.Julia.2009.1080p.BluRay.x265.10bit.DTS-WiKi/Julie.&.Julia.2009.1080p.BluRay.x265.10bit.DTS-WiKi.mkv,Julie.&.Julia.2009.1080p.BluRay.x265.10bit.DTS-WiKi.mkv,9734910533,2025-03-13T10:49:37,movie,1920x1040,hevc,7392.395,10535
/mnt/Downloads/movies/Moonlight.Express.1999.720p.BluRay.x264-WiKi/Moonlight.Express.1999.720p.BluRay.x264-WiKi.mkv,Moonlight.Express.1999.720p.BluRay.x264-WiKi.mkv,7898122818,2025-08-28T01:02:19,movie,1280x720,h264,6337.472,9970
@@ -3469,6 +3469,22 @@ path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_s
/mnt/Downloads/series/ロングバケーション Complete 1080p friDay WEB-DL H264 AAC-e@DoA/ロングバケーション 第05話 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,ロングバケーション 第05話 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,2668262246,2024-11-04T11:27:48,series,1920x1080,h264,2799.999,7623
/mnt/Downloads/series/ロングバケーション Complete 1080p friDay WEB-DL H264 AAC-e@DoA/ロングバケーション 第11話 END 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,ロングバケーション 第11話 END 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,3979194519,2024-11-04T11:40:55,series,1920x1080,h264,4152.895,7665
/mnt/Downloads/series/ロングバケーション Complete 1080p friDay WEB-DL H264 AAC-e@DoA/ロングバケーション 第02話 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,ロングバケーション 第02話 1080p friDay WEB-DL H264 AAC-e@DoA.mkv,2668983508,2024-11-04T11:20:28,series,1920x1080,h264,2799.999,7625
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E08.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E08.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1798772511,2026-02-13T13:48:45,series,1920x960,hevc,3803.84,3783
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E09.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E09.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1407236434,2026-02-13T13:49:06,series,1920x960,hevc,2969.408,3791
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E10.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E10.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1653738958,2026-02-13T13:49:31,series,1920x960,hevc,3456.48,3827
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E06.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E06.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1653532654,2026-02-13T13:48:03,series,1920x960,hevc,3437.664,3848
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E07.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E07.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1885292179,2026-02-13T13:48:26,series,1920x960,hevc,3881.92,3885
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E11.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E11.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1825045002,2026-02-13T13:49:52,series,1920x960,hevc,3819.104,3822
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E05.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E05.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1754877803,2026-02-13T13:47:42,series,1920x960,hevc,3702.913,3791
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E13.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E13.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1837918178,2026-02-13T13:50:32,series,1920x960,hevc,3743.392,3927
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E12.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E12.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1698962042,2026-02-13T13:50:13,series,1920x960,hevc,3560.608,3817
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E04.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E04.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1731875232,2026-02-13T13:47:21,series,1920x960,hevc,3577.6,3872
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E01.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E01.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1642198530,2026-02-13T13:46:12,series,1920x960,hevc,3453.472,3804
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E16.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E16.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,2449083931,2026-02-13T13:51:27,series,1920x960,hevc,5134.176,3816
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E14.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E14.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1870321249,2026-02-13T13:50:48,series,1920x960,hevc,3924.96,3812
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E02.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E02.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1527611794,2026-02-13T13:46:34,series,1920x960,hevc,3245.28,3765
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E03.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E03.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1813565311,2026-02-13T13:46:59,series,1920x960,hevc,3793.824,3824
/mnt/Downloads/series/苦尽柑来遇见你S01.When.Life.Gives.You.Tangerines.2025.1080p.NF.WEBrip.x265.AC3£cXcY@FRDS/When.Life.Gives.You.Tangerines.E15.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,When.Life.Gives.You.Tangerines.E15.2025.1080p.NF.WEBrip.x265.10bit.AC3£cXcY@FRDS.mkv,1996773867,2026-02-13T13:51:09,series,1920x960,hevc,4178.4,3823
/mnt/Downloads/series/[公益律师].Pro.Bono.2025.S01.Complete.1080p.NF.WEB-DL.H264.AAC-UBWEB/[公益律师].Pro.Bono.2025.S01E08.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,[公益律师].Pro.Bono.2025.S01E08.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,3097440685,2026-01-17T12:14:11,series,1920x1080,h264,4856.918,5101
/mnt/Downloads/series/[公益律师].Pro.Bono.2025.S01.Complete.1080p.NF.WEB-DL.H264.AAC-UBWEB/[公益律师].Pro.Bono.2025.S01E12.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,[公益律师].Pro.Bono.2025.S01E12.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,2940702325,2026-01-17T12:15:57,series,1920x1080,h264,4611.926,5101
/mnt/Downloads/series/[公益律师].Pro.Bono.2025.S01.Complete.1080p.NF.WEB-DL.H264.AAC-UBWEB/[公益律师].Pro.Bono.2025.S01E06.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,[公益律师].Pro.Bono.2025.S01E06.1080p.NF.WEB-DL.H264.AAC-UBWEB.mkv,2752522398,2026-01-17T12:13:15,series,1920x1080,h264,4319.616,5097
Can't render this file because it is too large.
@@ -0,0 +1,127 @@
# Refactor and Refinement Execution Plan
## Objective
Refactor the current `vlm` codebase to reduce structural technical debt while preserving the existing safety-first pipeline behavior. The plan focuses on improving CLI maintainability, strengthening stage boundaries, clarifying state semantics, enabling provider and strategy extensibility, and increasing testability and observability without changing the user-visible safety guarantees.
## Context and Scope
This plan is based on the verified review findings in the code analysis report, especially the structural issues around CLI size and repetitive error handling, weak dict/JSON contracts between stages, analyze-phase ordering coupling, duplicated sample-path rules, filesystem-dependent plan generation, configuration semantic overlap, review-state ambiguity, provider hard-coding, and the lack of unified state and strategy abstractions. Key source areas include `src/vlm/cli.py:158-603`, `src/vlm/commands/parse.py:66-155`, `src/vlm/io.py:85-249`, `src/vlm/commands/analyze.py:41-55`, `src/vlm/planner.py:34-40`, `src/vlm/planner.py:43-76`, `src/vlm/planner.py:354-358`, `src/vlm/enrichment.py:203-225`, `src/vlm/state.py:22-205`, `src/vlm/models.py:46-53`, and `src/vlm/models.py:79-85`.
## Assumptions
- Preserve the current pipeline order and safety behavior unless a change explicitly improves safety or determinism.
- Avoid introducing breaking changes to the CLI surface in the first refactor wave.
- Prefer incremental, reviewable changes that can ship independently.
- Keep the artifact-driven workflow as the default, while making room for future hybrid or streaming modes.
- Treat documentation, tests, and observability updates as first-class deliverables rather than afterthoughts.
## Implementation Plan
### Phase 0: Baseline, guardrails, and dependency mapping
- [ ] Capture the current behavior baseline for the full workflow, including `scan`, `parse`, `enrich`, `analyze`, `plan`, and `execute`, so every later refactor can be compared against the existing safety model. This is necessary because the codebase relies on many cross-stage assumptions and the review identified several fragile boundaries.
- [ ] Map the exact data flow between artifact files and in-memory models for `inventory`, `identities`, `analysis`, and `plan` outputs. This reduces the chance of accidental schema drift while refactoring the stage contracts.
- [ ] Identify the minimum set of high-value integration paths to protect first: CLI startup, parse/enrich transition, analyze pairing, plan generation, execute rollback, and quarantine handling. These paths correspond to the highest-risk areas in the review.
### Phase 1: CLI decomposition and unified error handling
- [ ] Extract a shared command execution/error-wrapping layer from `src/vlm/cli.py:158-603` so repeated `try/except + echo + logger + exit` logic is centralized. This is needed to stop the CLI file from continuing to grow and to keep error behavior consistent.
- [ ] Split non-core commands and support workflows into smaller command modules and keep the CLI module focused on registration and dispatch. This reduces the blast radius of future command additions and improves discoverability.
- [ ] Standardize CLI-level error presentation so file, JSON, validation, and OS errors all follow one predictable response shape. This improves user experience and avoids duplicated branching.
- [x] Update the CLI workflow description to match the actual supported flow, including `enrich`, so the user-facing guidance reflects the true pipeline. This is a low-cost refinement that removes user confusion.
### Phase 2: Stronger stage contracts and typed intermediate models
- [ ] Replace the most fragile dict-based stage boundaries with typed records or `TypedDict` models, starting with parse output and the io conversion layer in `src/vlm/commands/parse.py:66-155` and `src/vlm/io.py:85-249`. This directly addresses schema drift and makes refactors safer.
- [x] Define explicit schemas for `identities.json`, `analysis.json`, and `plan.json`, and validate them on load/save. This adds a durable guardrail against silent data-shape changes.
- [ ] Align enrichment output mutation with typed contracts so fields like `display_title`, `needs_review`, and review metadata have a single authoritative shape. This prevents inconsistent stage assumptions.
- [x] Refactor `identities_to_analysis_input()` so it returns explicit identity-file pairs instead of relying on positional slicing and zipping in `src/vlm/commands/analyze.py:41-55`. This removes the fragile ordering dependency identified in the review.
### Phase 3: Shared rule extraction and deterministic planning
- [x] Extract the duplicated sample-path rule from `src/vlm/planner.py:34-40`, `src/vlm/duplicate_resolve.py:11-16`, and `src/vlm/plan_review.py:12-16` into a single shared helper. This reduces duplication and guarantees consistent classification behavior.
- [x] Separate logical plan generation from live environment validation so plan output becomes reproducible and execute-time validation becomes an explicit pass. This addresses the filesystem-state coupling in `src/vlm/planner.py:43-76` and `src/vlm/planner.py:354-358`.
- [x] Tag any environment-derived metadata in plan artifacts as snapshots rather than intrinsic plan facts. This makes the distinction between logical intent and runtime validation clear.
- [ ] Review plan-related metadata and operation structures so they can support deterministic comparisons across runs. This is important for review tooling and regression analysis.
### Phase 4: Unified state model and review semantics
- [ ] Introduce a centralized state model that clearly separates processing, review, execution, and quarantine semantics. This directly addresses the spread of state concepts across `src/vlm/state.py:22-205`, `src/vlm/models.py:46-53`, and `src/vlm/models.py:79-85`.
- [ ] Make `needs_review` a derived or secondary field rather than the main source of truth, and ensure `review_status` and `review_reason` are the primary review semantics. This removes the current overlap and reduces future workflow ambiguity.
- [ ] Align execution and rollback state transitions with the centralized model so success, failure, rollback, and restore paths are all represented consistently.
- [ ] Review persisted state files and transition logic for atomicity and consistency after the model changes are introduced.
### Phase 5: Provider extensibility and strategy abstractions
- [ ] Replace hard-coded provider assembly in `src/vlm/enrichment.py:203-225` with a registry or plugin-style registration mechanism. This enables new metadata sources without requiring direct edits to the core enrichment orchestration.
- [ ] Introduce a strategy layer for naming, conflict handling, and keep/delete decisions so `Config` is no longer the only mechanism for behavior variation. This addresses the current static-config limitation in `src/vlm/config.py:13-148`.
- [ ] Refactor media-type and operation-type dispatch toward registered handlers rather than expanding `if/elif` chains in the parser and executor. This makes future media types and operation kinds easier to add.
- [ ] Keep the default built-in behavior intact while allowing new strategies to be added incrementally. This limits regression risk while improving extensibility.
### Phase 6: Error taxonomy and observability
- [ ] Define a small domain exception hierarchy for enrichment, planning, and execution safety failures, and update command-layer handling to use those typed errors. This makes failure handling more precise than broad exception catching.
- [ ] Add structured logging or event fields for high-value workflow events such as cache hits, quarantine operations, rollback actions, and plan conflicts. This improves supportability and analysis quality.
- [ ] Add stage-level timing and outcome metrics so long-running operations can be measured consistently. This is especially useful once the pipeline grows beyond small libraries.
### Phase 7: Testing expansion and validation coverage
- [ ] Add integration tests for cross-stage transitions, especially parse→enrich, analyze→plan, and plan→execute. These paths need stronger guarantees than isolated unit tests.
- [ ] Add end-to-end tests that run a complete safe workflow against a controlled fixture library, including rollback and quarantine behavior. This validates the pipeline as a whole rather than one module at a time.
- [ ] Add performance-oriented checks or benchmark fixtures for larger datasets so future changes can be evaluated against scaling regressions.
- [ ] Extend property-based coverage where the new typed contracts or state transitions create meaningful invariants.
### Phase 8: UX refinement and future-mode readiness
- [ ] Refresh help text and workflow guidance after the CLI and pipeline changes are stable, so the documented flow stays aligned with actual behavior.
- [ ] Evaluate whether a hybrid execution mode or a guided interactive mode should be introduced once the state model and contracts are stable. This is a future-facing refinement to improve usability without sacrificing safety.
- [ ] Keep the artifact-first mode as the default until the alternative execution modes have matching safety guarantees and test coverage.
## Verification Criteria
- [ ] CLI startup and command registration still work, and repeated command-level error handling is no longer duplicated across the main CLI file.
- [x] Stage artifact schemas are explicitly validated, and malformed inputs fail early with clear errors.
- [x] Analyze no longer depends on positional assumptions between identities and video files.
- [x] Sample-path classification produces one consistent result across planner, duplicate resolution, and review flows.
- [x] Plan generation is reproducible for the same logical inputs, with live filesystem checks clearly separated as validation snapshots.
- [ ] Review state semantics are no longer ambiguous, and `review_status` is the primary review source of truth.
- [ ] Provider registration can be extended without editing the core orchestration logic.
- [ ] Integration and E2E coverage exists for the main safe workflow and rollback/quarantine scenarios.
- [ ] Structured logs or metrics expose workflow health and failure patterns.
- [ ] The default user-visible pipeline still preserves the safety-first execution model.
## Potential Risks and Mitigations
1. **Risk: Refactor scope expands faster than the code can be stabilized**
Mitigation: Keep the work split into independently shippable phases and require the baseline behavior to remain intact after each phase.
2. **Risk: Typed contracts introduce temporary friction in serialization/deserialization code**
Mitigation: Introduce schema validation and typed records incrementally, starting with the highest-risk artifacts.
3. **Risk: State model changes cascade through planner, executor, and review flows**
Mitigation: Centralize the new model first, then migrate consumers one by one while keeping compatibility adapters where needed.
4. **Risk: Provider and strategy abstraction can become too generic too early**
Mitigation: Start with the current built-in cases as default registrations before allowing external extensibility.
5. **Risk: New tests may be slow or hard to maintain if they overuse large fixtures**
Mitigation: Prefer focused fixtures for unit/integration coverage and reserve large datasets for targeted performance checks.
## Alternative Approaches
1. **Incremental refactor first**: Keep the current artifact-first architecture and only extract the most painful seams now. Trade-off: lowest regression risk, but slower progress on deeper extensibility issues.
2. **Boundary-first refactor**: Prioritize typed contracts, state model, and deterministic planning before provider and strategy work. Trade-off: better long-term clarity, but requires more cross-module updates early.
3. **Platform-style refactor**: Introduce registries, strategies, and structured observability as a broader platform layer. Trade-off: highest flexibility, but the largest immediate complexity increase.
## Status Tracking
- **Not Started**: Phase 0 baseline capture and dependency mapping
- **Partially Completed**: Phase 1 CLI decomposition and unified error handling
- **Partially Completed**: Phase 2 stronger stage contracts and typed intermediate models
- **Partially Completed**: Phase 3 shared rule extraction and deterministic planning
- **Not Started**: Phase 4 unified state model and review semantics
- **Not Started**: Phase 5 provider extensibility and strategy abstractions
- **Not Started**: Phase 6 error taxonomy and observability
- **Not Started**: Phase 7 testing expansion and validation coverage
- **Not Started**: Phase 8 UX refinement and future-mode readiness
+4
View File
@@ -13,6 +13,10 @@ dependencies = [
dev = [
"pytest>=7.4.0",
"hypothesis>=6.82.0",
"textual>=0.47.0",
]
tui = [
"textual>=0.47.0",
]
[project.scripts]
+49
View File
@@ -84,6 +84,11 @@ def _command_error(ctx: CLIContext, user_message: str, logger_message: str, *, e
raise SystemExit(1)
def _review_plan_tui_streams_ok() -> bool:
"""Return True if stdin/stdout appear to be an interactive terminal."""
return sys.stdin.isatty() and sys.stdout.isatty()
def _load_or_create_config(config: Path) -> Config:
"""Load configuration from disk or create a default config file."""
if config.exists():
@@ -527,6 +532,12 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
default=False,
help='Show all high-risk operations in console preview'
)
@click.option(
'--tui',
is_flag=True,
default=False,
help='Interactive Textual UI (requires: uv pip install -e ".[tui]")'
)
@pass_context
def review_plan_cmd(
ctx: CLIContext,
@@ -536,6 +547,7 @@ def review_plan_cmd(
episode_threshold: int,
preview_limit: int,
show_all: bool,
tui: bool,
):
"""Review a plan and export high-risk operations for manual confirmation."""
from vlm.planner import load_plan
@@ -552,6 +564,19 @@ def review_plan_cmd(
click.echo("Error: --preview-limit must be >= 1", err=True)
sys.exit(1)
if tui:
if not _review_plan_tui_streams_ok():
click.echo("Error: --tui requires an interactive terminal (TTY)", err=True)
sys.exit(1)
try:
from textual.app import App as _TextualApp # noqa: F401
except ImportError:
click.echo(
'Error: Textual is not installed. Install with: uv pip install -e ".[tui]"',
err=True,
)
sys.exit(1)
click.echo(f"Loading plan: {input}")
execution_plan = load_plan(input)
rows, counters = review_plan(
@@ -560,6 +585,30 @@ def review_plan_cmd(
episode_threshold=episode_threshold,
)
if tui:
from vlm.review_tui import ReviewTUIContext, run_plan_review_tui
tui_ctx = ReviewTUIContext(
rows=rows,
counters=counters,
library_root=ctx.config.library_root,
output_csv=output,
plan_input=input,
summary_text=preferred_plan_summary(execution_plan),
)
rc = run_plan_review_tui(tui_ctx)
if rc != 0:
click.echo("Plan review aborted (no CSV written).", err=True)
sys.exit(rc)
click.echo(f"Saved manual review CSV to: {output}")
logger.info(
"Plan review TUI completed: total=%s high_risk=%s output=%s",
counters["total_operations"],
counters["high_risk_operations"],
output,
)
return
save_review_csv(rows, output)
click.echo()
+20 -10
View File
@@ -13,7 +13,13 @@ from vlm.io import (
load_inventory_csv,
save_analysis_json,
)
from vlm.models import MovieIdentity, SeriesIdentity
from vlm.models import (
AnalysisCompletenessRecord,
AnalysisDuplicateIdentityRecord,
AnalysisDuplicateRecord,
MovieIdentity,
SeriesIdentity,
)
def analyze_cmd(
@@ -39,19 +45,16 @@ def analyze_cmd(
click.echo()
inventory_files = load_inventory_csv(inventory) if inventory else None
movie_identities, series_identities, video_files = identities_to_analysis_input(
movie_pairs, series_pairs = identities_to_analysis_input(
identities_data, inventory_files=inventory_files
)
series_identities = [identity for identity, _ in series_pairs]
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:]))
)
identity_file_pairs = movie_pairs + series_pairs
duplicate_groups = detect_duplicates(identity_file_pairs)
click.echo()
@@ -71,7 +74,7 @@ def analyze_cmd(
click.echo(f"Saving analysis results to: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
completeness_list = [
completeness_list: list[AnalysisCompletenessRecord] = [
{
"series_title": c.series_title,
"season": c.season,
@@ -80,14 +83,21 @@ def analyze_cmd(
}
for c in completeness_results
]
duplicates_list = []
duplicates_list: list[AnalysisDuplicateRecord] = []
for d in duplicate_groups:
if isinstance(d.identity, MovieIdentity):
identity_info = {"type": "movie", "title": d.identity.title, "year": d.identity.year}
identity_info: AnalysisDuplicateIdentityRecord = {
"type": "movie",
"title": d.identity.title,
"year": d.identity.year,
"season": None,
"episodes": [],
}
else:
identity_info = {
"type": "series",
"title": d.identity.title,
"year": None,
"season": d.identity.season,
"episodes": d.identity.episodes,
}
+8 -7
View File
@@ -9,6 +9,7 @@ import click
from vlm.context import CLIContext
from vlm.io import load_inventory_csv, save_identities_json
from vlm.models import IdentityRecord, MovieIdentityRecord, ParsedIdentitiesJSON, SeriesIdentityRecord, VideoFile
from vlm.parser import parse_movie, parse_series
from vlm.utils import utc_now
@@ -20,7 +21,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
click.echo(f"Parsing identities from: {input}")
path_to_metadata: dict[str, object] = {}
path_to_metadata: dict[str, VideoFile] = {}
if inventory:
click.echo(f"Loading video metadata from: {inventory}")
inventory_files = load_inventory_csv(inventory)
@@ -30,7 +31,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
click.echo()
inventory_files = load_inventory_csv(input)
video_files = [
video_files: list[dict[str, str]] = [
{
"path": str(vf.path),
"filename": vf.filename,
@@ -42,10 +43,10 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
click.echo(f"Loaded {len(video_files)} files from inventory")
click.echo()
movie_identities: list[dict] = []
series_identities: list[dict] = []
anime_files: list[dict] = []
other_files: list[dict] = []
movie_identities: list[MovieIdentityRecord] = []
series_identities: list[SeriesIdentityRecord] = []
anime_files: list[IdentityRecord] = []
other_files: list[IdentityRecord] = []
def get_video_metadata(file_path: str) -> dict:
"""Extract video metadata from inventory if available."""
@@ -141,7 +142,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
schema_version = "2.0" if path_to_metadata else "1.0"
identities_data = {
identities_data: ParsedIdentitiesJSON = {
"vlm_schema_version": schema_version,
"metadata": {
"generated": generation_timestamp,
+5 -2
View File
@@ -82,9 +82,12 @@ def plan_cmd(ctx: CLIContext, input: Path, output: Path, analysis: Optional[Path
click.echo(" Review the plan file for details on conflicting operations.")
# Check for directory warnings
directory_warning = execution_plan.metadata.get("directory_warning", False)
validation_snapshot = execution_plan.metadata.get("validation_snapshot", {})
if not isinstance(validation_snapshot, dict):
validation_snapshot = {}
directory_warning = bool(validation_snapshot.get("directory_warning", False))
if directory_warning:
emptied_dirs = execution_plan.metadata.get("emptied_directories", [])
emptied_dirs = validation_snapshot.get("emptied_directories", [])
click.echo()
click.echo(f" ⚠️ Directory preservation warning: {len(emptied_dirs)} directories will be emptied")
click.echo(" These directories will be preserved but may be empty after execution.")
+4 -11
View File
@@ -6,14 +6,7 @@ from pathlib import Path
from typing import Optional, Union
from vlm.models import MovieIdentity, SeriesIdentity
def _is_sample_path(path: Path) -> bool:
"""Identify likely sample clips by path component or filename token."""
parts = [part.casefold() for part in path.parts]
if "sample" in parts:
return True
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", path.stem.casefold()))
from vlm.utils import is_sample_path
def choose_keep_index(
@@ -125,7 +118,7 @@ def _by_quality_index(
def key(idx_item: tuple[int, tuple[Path, Union[MovieIdentity, SeriesIdentity]]]) -> tuple:
idx, (path, _) = idx_item
qc = quality_comparison[idx] if idx < len(quality_comparison) else {}
is_sample = _is_sample_path(path)
is_sample = is_sample_path(path)
return (is_sample, *quality_key(path, qc, idx))
indexed = list(enumerate(items))
@@ -156,7 +149,7 @@ def _by_reputation_index(
has_rep = identity.reputation_score is not None
score = identity.reputation_score if identity.reputation_score is not None else -1.0
votes = identity.reputation_votes if identity.reputation_votes is not None else -1
is_sample = _is_sample_path(path)
is_sample = is_sample_path(path)
# Prefer non-sample, then reputation, then better quality, then lower index.
return (is_sample, not has_rep, -score, -votes, *quality_key(path, qc, idx))
@@ -199,7 +192,7 @@ def _by_reputation_quality_time_index(
score = identity.reputation_score if identity.reputation_score is not None else -1.0
votes = identity.reputation_votes if identity.reputation_votes is not None else -1
modified_ts = _parse_modified_timestamp(qc.get("modified_timestamp"))
is_sample = _is_sample_path(path)
is_sample = is_sample_path(path)
return (
is_sample,
not has_rep,
+248 -75
View File
@@ -6,7 +6,17 @@ import json
from pathlib import Path
from typing import Union
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
from vlm.models import (
AnalysisJSON,
AnalysisCompletenessRecord,
AnalysisDuplicateRecord,
MovieIdentity,
MovieIdentityRecord,
ParsedIdentitiesJSON,
SeriesIdentity,
SeriesIdentityRecord,
VideoFile,
)
from vlm.utils import utc_now
# Re-export scanner CSV functions so CLI and others use a single I/O entry point
@@ -39,28 +49,230 @@ def save_json_file(data: dict, path: Path) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def load_analysis_json(path: Path) -> dict:
def _ensure_dict(value: object, label: str) -> dict:
if not isinstance(value, dict):
raise ValueError(f"{label} must be an object")
return value
def _ensure_list(value: object, label: str) -> list:
if not isinstance(value, list):
raise ValueError(f"{label} must be a list")
return value
def _ensure_str(value: object, label: str) -> str:
if not isinstance(value, str):
raise ValueError(f"{label} must be a string")
return value
def _ensure_bool(value: object, label: str) -> bool:
if not isinstance(value, bool):
raise ValueError(f"{label} must be a boolean")
return value
def _ensure_int(value: object, label: str, *, allow_none: bool = False) -> int | None:
if value is None and allow_none:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{label} must be an integer")
return value
def _ensure_float(value: object, label: str, *, allow_none: bool = False) -> float | None:
if value is None and allow_none:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{label} must be a number")
return float(value)
def _ensure_string_dict(value: object, label: str) -> dict[str, str]:
mapping = _ensure_dict(value, label)
for key, item in mapping.items():
if not isinstance(key, str) or not isinstance(item, str):
raise ValueError(f"{label} must map strings to strings")
return mapping # type: ignore[return-value]
def _validate_video_metadata(record: dict, label: str) -> None:
if "video_metadata" not in record:
return
metadata = _ensure_dict(record["video_metadata"], f"{label}.video_metadata")
if "size_bytes" in metadata:
_ensure_int(metadata["size_bytes"], f"{label}.video_metadata.size_bytes")
if "modified_timestamp" in metadata:
_ensure_str(metadata["modified_timestamp"], f"{label}.video_metadata.modified_timestamp")
if "resolution" in metadata and metadata["resolution"] is not None:
_ensure_str(metadata["resolution"], f"{label}.video_metadata.resolution")
if "codec" in metadata and metadata["codec"] is not None:
_ensure_str(metadata["codec"], f"{label}.video_metadata.codec")
if "duration_seconds" in metadata:
_ensure_float(
metadata["duration_seconds"],
f"{label}.video_metadata.duration_seconds",
allow_none=True,
)
if "bitrate_kbps" in metadata:
_ensure_int(
metadata["bitrate_kbps"],
f"{label}.video_metadata.bitrate_kbps",
allow_none=True,
)
def _validate_identity_record(record: object, *, label: str, expect_kind: str | None = None) -> dict:
mapping = _ensure_dict(record, label)
_ensure_str(mapping.get("path"), f"{label}.path")
_ensure_str(mapping.get("filename"), f"{label}.filename")
_ensure_str(mapping.get("category"), f"{label}.category")
if "review_status" in mapping:
review_status = _ensure_str(mapping["review_status"], f"{label}.review_status")
if review_status not in {"pending", "approved", "rejected"}:
raise ValueError(f"{label}.review_status must be one of pending, approved, rejected")
if expect_kind in {"movie", "series"}:
_ensure_str(mapping.get("title"), f"{label}.title")
_ensure_float(mapping.get("confidence"), f"{label}.confidence")
_ensure_bool(mapping.get("needs_review"), f"{label}.needs_review")
if expect_kind == "movie":
_ensure_int(mapping.get("year"), f"{label}.year", allow_none=True)
elif expect_kind == "series":
_ensure_int(mapping.get("season"), f"{label}.season", allow_none=True)
episodes = _ensure_list(mapping.get("episodes"), f"{label}.episodes")
for idx, episode in enumerate(episodes):
_ensure_int(episode, f"{label}.episodes[{idx}]")
if "provider_metadata" in mapping:
_ensure_string_dict(mapping["provider_metadata"], f"{label}.provider_metadata")
_validate_video_metadata(mapping, label)
return mapping
def _validate_parsed_identities_json(data: object) -> ParsedIdentitiesJSON:
mapping = _ensure_dict(data, "identities JSON")
if "vlm_schema_version" in mapping and mapping["vlm_schema_version"] is not None:
_ensure_str(mapping["vlm_schema_version"], "identities JSON.vlm_schema_version")
_ensure_dict(mapping.get("metadata"), "identities JSON.metadata")
movies = _ensure_list(mapping.get("movies", []), "identities JSON.movies")
series = _ensure_list(mapping.get("series", []), "identities JSON.series")
anime = _ensure_list(mapping.get("anime", []), "identities JSON.anime")
other = _ensure_list(mapping.get("other", []), "identities JSON.other")
mapping["movies"] = [
_validate_identity_record(movie, label=f"identities JSON.movies[{idx}]", expect_kind="movie")
for idx, movie in enumerate(movies)
]
mapping["series"] = [
_validate_identity_record(item, label=f"identities JSON.series[{idx}]", expect_kind="series")
for idx, item in enumerate(series)
]
mapping["anime"] = [
_validate_identity_record(item, label=f"identities JSON.anime[{idx}]", expect_kind=None)
for idx, item in enumerate(anime)
]
mapping["other"] = [
_validate_identity_record(item, label=f"identities JSON.other[{idx}]", expect_kind=None)
for idx, item in enumerate(other)
]
return mapping # type: ignore[return-value]
def _validate_analysis_json(data: object) -> AnalysisJSON:
mapping = _ensure_dict(data, "analysis JSON")
if not isinstance(mapping.get("vlm_schema_version"), str):
mapping["vlm_schema_version"] = "1.0"
_ensure_dict(mapping.get("metadata"), "analysis JSON.metadata")
completeness = _ensure_list(mapping.get("completeness", []), "analysis JSON.completeness")
duplicates = _ensure_list(mapping.get("duplicates", []), "analysis JSON.duplicates")
for idx, item in enumerate(completeness):
completeness_item = _ensure_dict(item, f"analysis JSON.completeness[{idx}]")
_ensure_str(completeness_item.get("series_title"), f"analysis JSON.completeness[{idx}].series_title")
_ensure_int(completeness_item.get("season"), f"analysis JSON.completeness[{idx}].season")
for field_name in ("episodes_found", "episodes_missing"):
field_value = _ensure_list(completeness_item.get(field_name), f"analysis JSON.completeness[{idx}].{field_name}")
for episode_idx, episode in enumerate(field_value):
_ensure_int(episode, f"analysis JSON.completeness[{idx}].{field_name}[{episode_idx}]")
for idx, item in enumerate(duplicates):
duplicate_item = _ensure_dict(item, f"analysis JSON.duplicates[{idx}]")
identity = _ensure_dict(duplicate_item.get("identity"), f"analysis JSON.duplicates[{idx}].identity")
_ensure_str(identity.get("type"), f"analysis JSON.duplicates[{idx}].identity.type")
_ensure_str(identity.get("title"), f"analysis JSON.duplicates[{idx}].identity.title")
_ensure_int(identity.get("year"), f"analysis JSON.duplicates[{idx}].identity.year", allow_none=True)
_ensure_int(identity.get("season"), f"analysis JSON.duplicates[{idx}].identity.season", allow_none=True)
episodes = _ensure_list(identity.get("episodes", []), f"analysis JSON.duplicates[{idx}].identity.episodes")
for episode_idx, episode in enumerate(episodes):
_ensure_int(episode, f"analysis JSON.duplicates[{idx}].identity.episodes[{episode_idx}]")
files = _ensure_list(duplicate_item.get("files", []), f"analysis JSON.duplicates[{idx}].files")
for file_idx, file_path in enumerate(files):
_ensure_str(file_path, f"analysis JSON.duplicates[{idx}].files[{file_idx}]")
quality = _ensure_list(duplicate_item.get("quality_comparison", []), f"analysis JSON.duplicates[{idx}].quality_comparison")
for quality_idx, q_item in enumerate(quality):
_ensure_dict(q_item, f"analysis JSON.duplicates[{idx}].quality_comparison[{quality_idx}]")
return mapping # type: ignore[return-value]
def validate_plan_json(data: object) -> dict:
"""Validate the on-disk execution plan schema."""
mapping = _ensure_dict(data, "plan JSON")
_ensure_str(mapping.get("vlm_schema_version"), "plan JSON.vlm_schema_version")
_ensure_str(mapping.get("plan_id"), "plan JSON.plan_id")
_ensure_str(mapping.get("created_at"), "plan JSON.created_at")
_ensure_dict(mapping.get("summary"), "plan JSON.summary")
if "summary_by_reason" in mapping:
_ensure_dict(mapping["summary_by_reason"], "plan JSON.summary_by_reason")
if "human_summary" in mapping:
_ensure_str(mapping["human_summary"], "plan JSON.human_summary")
if "metadata" in mapping:
_ensure_dict(mapping["metadata"], "plan JSON.metadata")
operations = _ensure_list(mapping.get("operations", []), "plan JSON.operations")
for idx, item in enumerate(operations):
operation = _ensure_dict(item, f"plan JSON.operations[{idx}]")
_ensure_str(operation.get("operation_type"), f"plan JSON.operations[{idx}].operation_type")
_ensure_str(operation.get("source_path"), f"plan JSON.operations[{idx}].source_path")
if operation.get("destination_path") is not None:
_ensure_str(operation.get("destination_path"), f"plan JSON.operations[{idx}].destination_path")
_ensure_str(operation.get("reason"), f"plan JSON.operations[{idx}].reason")
_ensure_bool(operation.get("has_conflict"), f"plan JSON.operations[{idx}].has_conflict")
if "conflict_reason" in operation and operation["conflict_reason"] is not None:
_ensure_str(operation["conflict_reason"], f"plan JSON.operations[{idx}].conflict_reason")
return mapping
def load_analysis_json(path: Path) -> AnalysisJSON:
"""Load analysis result from JSON file (metadata, completeness, duplicates).
Caller should check file existence and handle missing/invalid keys.
"""
return load_json_file(path)
return _validate_analysis_json(load_json_file(path))
def load_identities_json(path: Path) -> dict:
def load_identities_json(path: Path) -> ParsedIdentitiesJSON:
"""Load identities from JSON file."""
return load_json_file(path)
return _validate_parsed_identities_json(load_json_file(path))
def save_identities_json(data: dict, path: Path) -> None:
def save_identities_json(data: ParsedIdentitiesJSON, path: Path) -> None:
"""Save identities dict to JSON file."""
save_json_file(data, path)
save_json_file(_validate_parsed_identities_json(data), path)
def save_analysis_json(
*,
completeness: list[dict],
duplicates: list[dict],
completeness: list[AnalysisCompletenessRecord],
duplicates: list[AnalysisDuplicateRecord],
source_identities: Path,
total_movies: int,
total_series: int,
@@ -68,7 +280,7 @@ def save_analysis_json(
) -> None:
"""Save analysis result JSON using the canonical schema."""
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
analysis_data = {
analysis_data: AnalysisJSON = {
"vlm_schema_version": "1.0",
"metadata": {
"generated": generation_timestamp,
@@ -79,10 +291,10 @@ def save_analysis_json(
"completeness": completeness,
"duplicates": duplicates,
}
save_json_file(analysis_data, output)
save_json_file(_validate_analysis_json(analysis_data), output)
def _video_file_from_record(record: dict) -> VideoFile:
def _video_file_from_record(record: MovieIdentityRecord | SeriesIdentityRecord) -> VideoFile:
"""Build a VideoFile from an identities record.
If the record contains embedded video_metadata (v2 schema), use it.
@@ -111,7 +323,16 @@ def _video_file_from_record(record: dict) -> VideoFile:
)
def _movie_identity_from_record(m: dict) -> MovieIdentity:
def _video_file_from_inventory(
record: MovieIdentityRecord | SeriesIdentityRecord,
path_to_inventory: dict[str, VideoFile],
) -> VideoFile:
"""Build a VideoFile and prefer matching inventory metadata when available."""
video_file = _video_file_from_record(record)
return path_to_inventory.get(str(video_file.path), video_file)
def _movie_identity_from_record(m: MovieIdentityRecord) -> MovieIdentity:
"""Build MovieIdentity from identities JSON record."""
is_approved = m.get("review_status") == "approved"
return MovieIdentity(
@@ -133,7 +354,7 @@ def _movie_identity_from_record(m: dict) -> MovieIdentity:
)
def _series_identity_from_record(s: dict) -> SeriesIdentity:
def _series_identity_from_record(s: SeriesIdentityRecord) -> SeriesIdentity:
"""Build SeriesIdentity from identities JSON record."""
is_approved = s.get("review_status") == "approved"
return SeriesIdentity(
@@ -157,7 +378,7 @@ def _series_identity_from_record(s: dict) -> SeriesIdentity:
def identities_to_plan_input(
data: dict,
data: ParsedIdentitiesJSON,
) -> 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]]] = []
@@ -179,71 +400,23 @@ def identities_to_plan_input(
def identities_to_analysis_input(
data: dict,
data: ParsedIdentitiesJSON,
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."""
) -> tuple[list[tuple[MovieIdentity, VideoFile]], list[tuple[SeriesIdentity, VideoFile]]]:
"""Convert identities JSON dict to explicit identity-file pairs for analysis."""
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}
movie_pairs: list[tuple[MovieIdentity, VideoFile]] = []
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)
movie_pairs.append((_movie_identity_from_record(m), _video_file_from_inventory(m, path_to_inventory)))
series_pairs: list[tuple[SeriesIdentity, VideoFile]] = []
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
series_pairs.append((_series_identity_from_record(s), _video_file_from_inventory(s, path_to_inventory)))
return movie_pairs, series_pairs
+89 -1
View File
@@ -7,7 +7,7 @@ for representing video files and their parsed identities.
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, TypedDict
@dataclass
@@ -275,3 +275,91 @@ class DuplicateGroup:
identity: MovieIdentity | SeriesIdentity
files: list[VideoFile]
quality_comparison: list[dict]
class VideoMetadataRecord(TypedDict, total=False):
"""Embedded video metadata stored in identities records."""
size_bytes: int
modified_timestamp: str
resolution: str
codec: str
duration_seconds: float
bitrate_kbps: int
class IdentityRecord(TypedDict, total=False):
"""Common record fields used in parsed/enriched JSON payloads."""
path: str
filename: str
category: str
title: str
display_title: str
confidence: float
needs_review: bool
canonical_id: str
title_zh: str
title_en: str
translation_source: str
reputation_score: float
reputation_votes: int
reputation_source: str
review_status: str
enrichment_confidence: float
provider_metadata: dict[str, str]
video_metadata: VideoMetadataRecord
note: str
class MovieIdentityRecord(IdentityRecord, total=False):
"""TypedDict for movie entries in identities.json."""
year: int | None
class SeriesIdentityRecord(IdentityRecord, total=False):
"""TypedDict for series entries in identities.json."""
season: int | None
episodes: list[int]
class ParsedIdentitiesJSON(TypedDict, total=False):
"""Canonical parsed identities payload stored between pipeline stages."""
vlm_schema_version: str
metadata: dict[str, object]
movies: list[MovieIdentityRecord]
series: list[SeriesIdentityRecord]
anime: list[IdentityRecord]
other: list[IdentityRecord]
class AnalysisCompletenessRecord(TypedDict):
series_title: str
season: int
episodes_found: list[int]
episodes_missing: list[int]
class AnalysisDuplicateIdentityRecord(TypedDict):
type: str
title: str
year: int | None
season: int | None
episodes: list[int]
class AnalysisDuplicateRecord(TypedDict):
identity: AnalysisDuplicateIdentityRecord
files: list[str]
quality_comparison: list[dict]
class AnalysisJSON(TypedDict, total=False):
vlm_schema_version: str
metadata: dict[str, object]
completeness: list[AnalysisCompletenessRecord]
duplicates: list[AnalysisDuplicateRecord]
+41 -8
View File
@@ -23,9 +23,11 @@ QUALITY_TAGS = [
r'\b10bit\b', r'\b8bit\b',
]
# Release group patterns (in brackets, but NOT years in parentheses)
# Release group patterns (in brackets or parentheses at start/end)
RELEASE_GROUP_PATTERNS = [
r'\[[\w\s\-\.]+\]', # [RARBG], [YTS], etc.
r'^\[[\w\s\-\.]+\]', # [Group] at start
r'\[[\w\s\-\.]+\]$', # [Group] at end
r'\b[\w\s\-\.]+[-_]Subs\b', # Group_Subs
]
@@ -56,6 +58,12 @@ def remove_release_groups(text: str) -> str:
result = text
for pattern in RELEASE_GROUP_PATTERNS:
result = re.sub(pattern, '', result)
# Remove trailing parenthetical groups, but keep years like (2020)
match = re.search(r'\s*(\([^)]+\))$', result)
if match and not re.fullmatch(r'\(\d{4}\)', match.group(1)):
result = result[:match.start()].rstrip()
return result
@@ -77,6 +85,22 @@ def normalize_title(title: str) -> str:
return title.strip()
def humanize_parsed_title(title: str) -> str:
"""Make parsed titles less likely to contain accidental all-caps tags.
This keeps short acronyms like "IV" intact while softening long all-caps
words that are likely part of the filename rather than intentional styling.
"""
normalized = normalize_title(title)
words = []
for word in normalized.split():
if word.isalpha() and word.isupper() and len(word) > 3:
words.append(word.capitalize())
else:
words.append(word)
return ' '.join(words).strip()
def parse_movie(
filename: str,
extensions: Optional[list[str]] = None,
@@ -127,7 +151,7 @@ def parse_movie(
# Now clean the title
title = remove_quality_tags(title)
title = remove_release_groups(title)
title = normalize_title(title)
title = humanize_parsed_title(title or match.group(1))
return MovieIdentity(
title=title,
@@ -140,7 +164,7 @@ def parse_movie(
# No year found - clean and extract title, flag for review
cleaned = remove_quality_tags(name_without_ext)
cleaned = remove_release_groups(cleaned)
title = normalize_title(cleaned)
title = humanize_parsed_title(cleaned or name_without_ext)
return MovieIdentity(
title=title,
@@ -189,6 +213,8 @@ def parse_series(
(r'(?<!\d)(\d{1,2})x(\d{1,2})(?!\d)', 0.9),
# Pattern: Season X Episode Y - Medium confidence
(r'[Ss]eason\s*(\d{1,2})\s*[Ee]pisode\s*(\d{1,2})', 0.7),
# Pattern: Hyphen Episode (Anime style: Name - 01) - Medium confidence, assume Season 1
(r'\s+-\s+(\d{1,3})(?!\d)', 0.6),
]
season = None
@@ -199,8 +225,14 @@ def parse_series(
for pattern, conf in patterns:
match = re.search(pattern, name_without_ext, re.IGNORECASE)
if match:
season = int(match.group(1))
episodes = [int(match.group(2))]
if len(match.groups()) == 2:
season = int(match.group(1))
episodes = [int(match.group(2))]
else:
# Hyphen episode only
season = 1
episodes = [int(match.group(1))]
confidence = conf
# Extract title (everything before the match)
@@ -222,12 +254,12 @@ def parse_series(
if title_part:
title_part = remove_quality_tags(title_part)
title_part = remove_release_groups(title_part)
title_part = normalize_title(title_part)
title_part = humanize_parsed_title(title_part or name_without_ext)
else:
# If no title part found, use the whole filename cleaned
title_part = remove_quality_tags(name_without_ext)
title_part = remove_release_groups(title_part)
title_part = normalize_title(title_part)
title_part = humanize_parsed_title(title_part or name_without_ext)
# Determine if review is needed
needs_review = season is None or len(episodes) == 0
@@ -247,6 +279,7 @@ def parse_series(
def group_episodes(episodes: list[SeriesIdentity]) -> dict[tuple[str, int], list[SeriesIdentity]]:
"""Group parsed episodes by normalized series title and season number.
Episodes are grouped by (normalized_title, season) tuple. Episodes with
+3 -8
View File
@@ -7,13 +7,7 @@ import re
from pathlib import Path
from vlm.models import ExecutionPlan, FileOperation
def _is_sample_path(path: Path) -> bool:
parts = [part.casefold() for part in path.parts]
if "sample" in parts:
return True
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", path.stem.casefold()))
from vlm.utils import is_sample_path
def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int | None]:
@@ -63,7 +57,7 @@ def review_plan(
if "manual review" in reason_l:
flags.append("manual_review")
counters["manual_review"] += 1
if _is_sample_path(op.source_path):
if is_sample_path(op.source_path):
flags.append("sample_source")
counters["sample_source"] += 1
season, episode = _extract_season_episode(op)
@@ -100,3 +94,4 @@ def save_review_csv(rows: list[dict[str, str]], output: Path) -> None:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
+69 -22
View File
@@ -13,7 +13,8 @@ from typing import Optional, Union
from vlm.config import Config
from vlm.duplicate_resolve import choose_keep_index
from vlm.utils import ensure_utc, is_within_root, sanitize_path_component, utc_now
from vlm.io import validate_plan_json
from vlm.utils import ensure_utc, is_sample_path, is_within_root, sanitize_path_component, utc_now
from vlm.models import (
ExecutionPlan,
FileOperation,
@@ -31,15 +32,6 @@ NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds c
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
def _is_sample_path(path: Path) -> bool:
"""Return True if path appears to be a sample clip."""
parts = [part.casefold() for part in path.parts]
if "sample" in parts:
return True
stem = path.stem.casefold()
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem))
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
"""Analyze which directories will be emptied by the plan."""
# Get all source directories that have files being moved/renamed
@@ -72,7 +64,7 @@ def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
return {
"emptied_directories": emptied_dirs,
"warning_required": len(emptied_dirs) > 0
"warning_required": len(emptied_dirs) > 0,
}
@@ -106,10 +98,11 @@ def generate_plan(
operations.append(operation)
metadata: dict = {}
validation_snapshot: dict[str, object] = {}
if analysis_data is not None:
metadata["analysis_source"] = analysis_data.get("metadata", {}).get("source_identities", "")
metadata["duplicate_groups_considered"] = len(analysis_data.get("duplicates", []))
metadata["completeness_seasons_with_gaps"] = len(analysis_data.get("completeness", []))
validation_snapshot["analysis_source"] = analysis_data.get("metadata", {}).get("source_identities", "")
validation_snapshot["duplicate_groups_considered"] = len(analysis_data.get("duplicates", []))
validation_snapshot["completeness_seasons_with_gaps"] = len(analysis_data.get("completeness", []))
if config.duplicate_keep != "manual":
path_to_index = {str(vf.path): i for i, (vf, _) in enumerate(identities)}
@@ -124,7 +117,7 @@ def generate_plan(
if identity is not None and isinstance(
identity, (MovieIdentity, SeriesIdentity)
):
if not config.plan_include_sample_files and _is_sample_path(identities[i][0].path):
if not config.plan_include_sample_files and is_sample_path(identities[i][0].path):
# Keep sample files out of duplicate keep/quarantine competition by default.
continue
items.append((identities[i][0].path, identity))
@@ -169,15 +162,16 @@ def generate_plan(
has_conflict=False,
conflict_reason=None
))
validation_snapshot["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
validation_snapshot["directory_warning"] = True
summary = _generate_summary(operations)
summary_by_reason = _generate_summary_by_reason(operations)
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
# Add directory warnings to metadata
if directory_analysis["warning_required"]:
metadata["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
metadata["directory_warning"] = True
if validation_snapshot:
validation_snapshot["captured_at"] = utc_now().isoformat()
metadata["validation_snapshot"] = validation_snapshot
return ExecutionPlan(
plan_id=str(uuid.uuid4()),
@@ -205,7 +199,7 @@ def _create_operation(
Returns:
FileOperation specifying what to do with the file
"""
if not config.plan_include_sample_files and _is_sample_path(video_file.path):
if not config.plan_include_sample_files and is_sample_path(video_file.path):
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
@@ -628,7 +622,7 @@ def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
# Write to JSON file with indentation for human readability
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(plan_dict, f, indent=2, ensure_ascii=False)
json.dump(validate_plan_json(plan_dict), f, indent=2, ensure_ascii=False)
def load_plan(input_path: Path) -> ExecutionPlan:
@@ -649,7 +643,7 @@ def load_plan(input_path: Path) -> ExecutionPlan:
KeyError: If required fields are missing from the JSON
"""
with open(input_path, 'r', encoding='utf-8') as f:
plan_dict = json.load(f)
plan_dict = validate_plan_json(json.load(f))
# Reconstruct FileOperation objects
operations = [
@@ -675,3 +669,56 @@ def load_plan(input_path: Path) -> ExecutionPlan:
human_summary=plan_dict.get("human_summary", ""),
metadata=plan_dict.get("metadata", {}),
)
def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan:
"""Update a plan's operations based on a modified review CSV.
Args:
plan: The original ExecutionPlan
csv_path: Path to the modified manual review CSV
Returns:
Updated ExecutionPlan with modified operation types
"""
import csv
# Create a copy of operations to modify
updated_ops = list(plan.operations)
modified_count = 0
with open(csv_path, "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
try:
# CSV index is 1-based
idx = int(row["index"]) - 1
if 0 <= idx < len(updated_ops):
new_op_type = row["operation_type"]
old_op_type = updated_ops[idx].operation_type
if new_op_type != old_op_type:
# Update the operation type (usually to 'no-op')
updated_ops[idx].operation_type = new_op_type
updated_ops[idx].reason = f"Modified via manual review: {updated_ops[idx].reason}"
modified_count += 1
except (ValueError, KeyError):
continue
if modified_count > 0:
# Re-generate summary and human summary for the updated plan
summary = _generate_summary(updated_ops)
summary_by_reason = _generate_summary_by_reason(updated_ops)
human_summary = _generate_human_summary(updated_ops, summary, summary_by_reason, plan.metadata)
return ExecutionPlan(
plan_id=plan.plan_id,
created_at=plan.created_at,
operations=updated_ops,
summary=summary,
summary_by_reason=summary_by_reason,
human_summary=human_summary,
metadata=plan.metadata,
)
return plan
+85
View File
@@ -0,0 +1,85 @@
"""Shared helpers for human-readable plan review (CLI preview and TUI)."""
from __future__ import annotations
from pathlib import Path
# Internal risk flag keys from plan_review.review_plan (pipe-separated in CSV).
RISK_FLAG_LABELS: dict[str, str] = {
"manual_review": "需人工判断",
"sample_source": "样片路径",
"high_season": "季号偏高",
"high_episode": "集号偏高",
"conflict": "目标冲突",
}
def risk_flags_to_labels(flags: str, *, max_len: int = 24) -> str:
"""Map pipe-separated risk flags to short Chinese labels."""
if not flags or flags.strip().lower() == "none":
return ""
parts = [p.strip() for p in flags.split("|") if p.strip()]
labels = [RISK_FLAG_LABELS.get(p, p) for p in parts]
text = " ".join(labels)
if len(text) <= max_len:
return text
return text[: max_len - 1] + ""
def _display_path(p: Path, library_root: Path) -> str:
try:
resolved = p.resolve()
root = library_root.resolve()
rel = resolved.relative_to(root)
return str(rel)
except (ValueError, OSError):
return str(p)
def format_paths_for_detail(
source_s: str,
dest_s: str,
library_root: Path,
) -> str:
"""Build multi-line before/after path text for review detail panes."""
source = Path(source_s)
dest = Path(dest_s) if dest_s.strip() else None
src_line = _display_path(source, library_root)
lines = [f"来源: {src_line}", f"完整: {source}"]
if dest is not None:
dst_line = _display_path(dest, library_root)
lines.append(f"目标: {dst_line}")
lines.append(f"完整: {dest}")
else:
lines.append("目标: (无)")
return "\n".join(lines)
def build_csv_rows(
base_rows: list[dict[str, str]],
op_by_index: dict[int, str],
) -> list[dict[str, str]]:
"""Return CSV row dicts with operation_type taken from op_by_index per 1-based index."""
result: list[dict[str, str]] = []
for r in base_rows:
idx = int(r["index"])
new_r = dict(r)
if idx in op_by_index:
new_r["operation_type"] = op_by_index[idx]
result.append(new_r)
return result
def review_row_status_symbol(
op_by_index: dict[int, str],
initial_op_by_index: dict[int, str],
index: int,
) -> str:
"""Return a single-character status marker for the review table."""
cur = op_by_index.get(index, initial_op_by_index[index])
init = initial_op_by_index[index]
if cur != init and cur == "no-op":
return ""
if cur != init and cur != "no-op":
return ""
return "·"
+388
View File
@@ -0,0 +1,388 @@
"""Textual TUI for reviewing high-risk plan operations."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Static
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
format_paths_for_detail,
review_row_status_symbol,
risk_flags_to_labels,
)
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
if isinstance(row_key, str):
return int(row_key)
val = getattr(row_key, "value", None)
if val is None:
return int(row_key)
return int(val)
@dataclass(frozen=True)
class ReviewTUIContext:
"""Inputs for the plan review TUI."""
rows: list[dict[str, str]]
counters: dict[str, int]
library_root: Path
output_csv: Path
plan_input: Path
summary_text: str
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
BINDINGS = [
Binding("enter", "continue_", "继续", show=True),
Binding("q", "quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self._ctx = ctx
def compose(self) -> ComposeResult:
stats_lines = [
"---",
"计划统计",
f" 总操作: {self._ctx.counters['total_operations']}",
f" 高危: {self._ctx.counters['high_risk_operations']}",
f" manual_review: {self._ctx.counters['manual_review']}",
f" sample_source: {self._ctx.counters['sample_source']}",
f" high_season: {self._ctx.counters['high_season']}",
f" high_episode: {self._ctx.counters['high_episode']}",
f" conflicts: {self._ctx.counters['conflicts']}",
"",
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"",
"Enter 进入审核 · q 退出",
]
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
yield ScrollableContainer(Static(body, id="summary_body"))
yield Footer()
def action_continue_(self) -> None:
self.dismiss(True)
def action_quit(self) -> None:
self.dismiss(False)
class ConfirmDiscardScreen(ModalScreen[bool]):
"""Confirm discarding unsaved edits."""
BINDINGS = [
Binding("y", "yes", show=False),
Binding("n", "no", show=False),
]
def compose(self) -> ComposeResult:
yield Container(
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
id="confirm_box",
)
def action_yes(self) -> None:
self.dismiss(True)
def action_no(self) -> None:
self.dismiss(False)
DEFAULT_CSS = """
ConfirmDiscardScreen {
align: center middle;
}
#confirm_box {
width: auto;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
"""
class ReviewMainScreen(Screen):
"""High-risk table + detail pane."""
BINDINGS = [
Binding("up", "cursor_up", show=False),
Binding("down", "cursor_down", show=False),
Binding("k", "cursor_up", show=False),
Binding("j", "cursor_down", show=False),
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
self._by_index: dict[int, dict[str, str]] = {
int(r["index"]): r for r in ctx.rows
}
self.initial_op_by_index: dict[int, str] = {
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
hdr = (
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
with Horizontal(id="body"):
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
with ScrollableContainer(id="detail_scroll"):
yield Static("", id="detail_text")
yield Static(
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
id="footer_line",
)
yield Footer()
DEFAULT_CSS = """
#header_line {
dock: top;
padding: 0 1;
background: $primary-darken-2;
color: $text;
}
#footer_line {
dock: bottom;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#body {
layout: horizontal;
height: 1fr;
}
#body.vertical-split {
layout: vertical;
height: 1fr;
}
#review_table {
width: 1fr;
min-height: 5;
}
#body.vertical-split #review_table {
height: 40%;
}
#detail_scroll {
width: 1fr;
min-height: 5;
border-left: solid $primary-darken-3;
padding: 0 1;
}
#body.vertical-split #detail_scroll {
border-left: none;
border-top: solid $primary-darken-3;
height: 1fr;
}
"""
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
table.add_column(" ", key="sym", width=3)
table.add_column("#", key="idx", width=4)
table.add_column("类型", key="op", width=11)
table.add_column("风险", key="risk", width=18)
table.add_column("文件", key="file")
for r in self.ctx.rows:
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
key=str(idx),
)
self._apply_body_layout(self.app.size)
if table.row_count > 0:
table.focus()
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
else:
self.query_one("#detail_text", Static).update(
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)。"
)
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
def _symbol_for(self, index: int) -> str:
return review_row_status_symbol(
self.op_by_index,
self.initial_op_by_index,
index,
)
def _apply_body_layout(self, app_size: Size) -> None:
body = self.query_one("#body", Horizontal)
if app_size.width < 100:
body.add_class("vertical-split")
else:
body.remove_class("vertical-split")
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
self._apply_body_layout(self.app.size)
def _current_index(self) -> int | None:
table = self._table()
if table.row_count == 0:
return None
row_index = table.cursor_coordinate.row
row = table.ordered_rows[row_index]
return _index_from_row_key(row.key)
@on(DataTable.RowHighlighted) # type: ignore[misc]
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id != "review_table":
return
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
paths = format_paths_for_detail(
r["source_path"],
r.get("destination_path", ""),
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
+ (f" · {risk_cn}" if risk_cn else "")
)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
self.query_one("#detail_text", Static).update(text)
def _refresh_row_cells(self, index: int) -> None:
table = self._table()
key = str(index)
sym = self._symbol_for(index)
table.update_cell(key, "sym", sym)
table.update_cell(key, "op", self.op_by_index[index])
def _update_dirty_header(self) -> None:
dirty = self._is_dirty()
hdr = self.query_one("#header_line", Static)
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
star = " *" if dirty else ""
hdr.update(
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
def _is_dirty(self) -> bool:
return self.op_by_index != self.initial_op_by_index
def action_cursor_up(self) -> None:
if self._table().row_count:
self._table().action_cursor_up()
def action_cursor_down(self) -> None:
if self._table().row_count:
self._table().action_cursor_down()
def action_keep_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = self.initial_op_by_index[idx]
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = "no-op"
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_undo_row(self) -> None:
self.action_keep_row()
def action_save(self) -> None:
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
save_review_csv(out_rows, self.ctx.output_csv)
self.dismiss("saved")
def action_request_quit(self) -> None:
if not self._is_dirty():
self.dismiss("aborted")
return
def after_confirm(confirmed: bool | None) -> None:
if confirmed:
self.dismiss("aborted")
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
class PlanReviewApp(App):
"""Application shell: summary screen then review screen."""
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
def on_mount(self) -> None:
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
def _after_summary(self, result: bool | None) -> None:
if not result:
self.exit(return_code=1)
return
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
def _after_main(self, result: str | None) -> None:
if result == "saved":
self.exit(return_code=0)
else:
self.exit(return_code=1)
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Block until the user finishes the TUI. Returns process exit code."""
app = PlanReviewApp(ctx)
app.run()
code = app.return_code
return 0 if code is None else code
+9
View File
@@ -65,6 +65,15 @@ def scan_library(
logger.error(f"Library root is not a directory: {root}")
return []
if include_video_metadata:
import shutil
if not shutil.which("ffprobe"):
logger.warning(
"ffprobe command not found in PATH. Video metadata extraction will be skipped. "
"Only file-level information (size, mtime) will be recorded."
)
include_video_metadata = False
video_files = []
file_count = 0
discovered_paths = _discover_video_paths(root, config.video_extensions)
+11
View File
@@ -51,6 +51,17 @@ def canonical_path_str(path: Path) -> str:
_PATH_SEPARATORS_PATTERN = re.compile(r"[\\/]+")
_SAMPLE_TOKEN_PATTERN = re.compile(r"(^|[\s._-])sample($|[\s._-])")
def is_sample_path(path: Path) -> bool:
"""Return True if path appears to be a sample clip."""
parts = [part.casefold() for part in path.parts]
if "sample" in parts:
return True
return bool(_SAMPLE_TOKEN_PATTERN.search(path.stem.casefold()))
_CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
+109
View File
@@ -232,3 +232,112 @@ def test_review_plan_show_all_overrides_preview_limit(tmp_path):
assert " - [2] " in result.output
assert " - [3] " in result.output
assert "more high-risk operations" not in result.output
def test_review_plan_tui_requires_interactive_terminal(tmp_path):
"""--tui should fail when stdin/stdout are not TTY (e.g. CliRunner)."""
config_path = tmp_path / "config.yaml"
_write_config(config_path, tmp_path / "library")
plan_path = tmp_path / "plan.json"
operations = [
{
"operation_type": "move",
"source_path": str(tmp_path / "a.mkv"),
"destination_path": str(tmp_path / "b.mkv"),
"reason": "organize",
"has_conflict": False,
"conflict_reason": None,
},
]
_write_plan(
plan_path,
operations=operations,
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
)
output_csv = tmp_path / "review.csv"
runner = CliRunner()
result = _invoke_review_plan(
runner,
config_path,
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
)
assert result.exit_code == 1
assert "TTY" in result.output or "interactive terminal" in result.output
def test_review_plan_tui_stubbed_success(tmp_path, monkeypatch):
"""With TTY check stubbed and run_plan_review_tui stubbed, CLI should succeed."""
config_path = tmp_path / "config.yaml"
_write_config(config_path, tmp_path / "library")
plan_path = tmp_path / "plan.json"
operations = [
{
"operation_type": "move",
"source_path": str(tmp_path / "a.mkv"),
"destination_path": str(tmp_path / "b.mkv"),
"reason": "organize",
"has_conflict": False,
"conflict_reason": None,
},
]
_write_plan(
plan_path,
operations=operations,
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
)
monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True)
monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 0)
output_csv = tmp_path / "review.csv"
runner = CliRunner()
result = _invoke_review_plan(
runner,
config_path,
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
)
assert result.exit_code == 0
assert "Saved manual review CSV" in result.output
assert "Plan overview:" not in result.output
def test_review_plan_tui_stubbed_abort(tmp_path, monkeypatch):
"""TUI return code 1 should surface as CLI failure."""
config_path = tmp_path / "config.yaml"
_write_config(config_path, tmp_path / "library")
plan_path = tmp_path / "plan.json"
operations = [
{
"operation_type": "move",
"source_path": str(tmp_path / "a.mkv"),
"destination_path": str(tmp_path / "b.mkv"),
"reason": "organize",
"has_conflict": False,
"conflict_reason": None,
},
]
_write_plan(
plan_path,
operations=operations,
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
)
monkeypatch.setattr("vlm.cli._review_plan_tui_streams_ok", lambda: True)
monkeypatch.setattr("vlm.review_tui.run_plan_review_tui", lambda ctx: 1)
output_csv = tmp_path / "review.csv"
runner = CliRunner()
result = _invoke_review_plan(
runner,
config_path,
["--input", str(plan_path), "--output", str(output_csv), "--tui"],
)
assert result.exit_code == 1
assert "aborted" in result.output.lower()
+6 -6
View File
@@ -72,10 +72,10 @@ class TestDuplicateQualityWithMetadata:
loaded = load_identities_json(identities_file)
# Convert to analysis input
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
# Create identity-file pairs
identity_file_pairs = list(zip(movie_identities, video_files))
identity_file_pairs = movie_pairs
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
@@ -151,10 +151,10 @@ class TestDuplicateQualityWithMetadata:
loaded = load_identities_json(identities_file)
# Convert to analysis input
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
# Create identity-file pairs
identity_file_pairs = list(zip(movie_identities, video_files))
identity_file_pairs = movie_pairs
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
@@ -231,10 +231,10 @@ class TestDuplicateQualityWithMetadata:
json.dump(data, f, indent=2)
loaded = load_identities_json(identities_file)
movie_identities, series_identities, video_files = identities_to_analysis_input(loaded)
movie_pairs, series_pairs = identities_to_analysis_input(loaded)
# Create identity-file pairs for series
identity_file_pairs = list(zip(series_identities, video_files))
identity_file_pairs = series_pairs
# Detect duplicates
duplicates = detect_duplicates(identity_file_pairs)
+70
View File
@@ -8,6 +8,7 @@ import pytest
from vlm.io import (
_video_file_from_record,
load_analysis_json,
load_identities_json,
save_identities_json,
)
@@ -260,3 +261,72 @@ class TestIdentitiesJsonRoundTrip:
assert series["video_metadata"]["size_bytes"] == 500000000
assert series["video_metadata"]["resolution"] == "1920x1080"
assert series["video_metadata"]["codec"] == "h264"
class TestJsonSchemaValidation:
"""Tests for runtime schema validation on JSON artifacts."""
def test_save_identities_rejects_invalid_movie_shape(self, tmp_path):
identities_file = tmp_path / "identities.json"
invalid_data = {
"vlm_schema_version": "2.0",
"metadata": {
"generated": "2024-02-13T12:00:00",
"source_inventory": "test_inventory.csv",
"total_files": 1,
},
"movies": [
{
"path": "/library/movie/Movie1 (2024).mkv",
"filename": "Movie1 (2024).mkv",
"category": "movie",
"title": "Movie1",
"year": 2024,
"confidence": "high",
"needs_review": False,
}
],
"series": [],
"anime": [],
"other": [],
}
with pytest.raises(ValueError, match="confidence"):
save_identities_json(invalid_data, identities_file)
def test_load_analysis_rejects_missing_required_fields(self, tmp_path):
analysis_file = tmp_path / "analysis.json"
analysis_file.write_text(
json.dumps(
{
"vlm_schema_version": "1.0",
"metadata": {"generated": "2024-01-01T00:00:00"},
"completeness": [
{
"series_title": "Show",
"season": 1,
"episodes_found": [1],
"episodes_missing": [2],
}
],
"duplicates": [
{
"identity": {
"type": "movie",
"title": "Test",
"year": 2024,
"season": None,
"episodes": [],
},
"files": ["/library/movie/Test (2024).mkv"],
"quality_comparison": [],
}
],
}
),
encoding="utf-8",
)
loaded = load_analysis_json(analysis_file)
assert loaded["metadata"]["generated"] == "2024-01-01T00:00:00"
assert loaded["duplicates"][0]["identity"]["title"] == "Test"
+65 -1
View File
@@ -1,5 +1,6 @@
"""Unit tests for plan generator."""
import json
import pytest
from datetime import datetime, timezone
from pathlib import Path
@@ -12,7 +13,7 @@ from vlm.models import (
SeriesIdentity,
VideoFile,
)
from vlm.planner import generate_plan
from vlm.planner import generate_plan, load_plan
@pytest.fixture
@@ -1255,3 +1256,66 @@ def test_series_rejected_by_review_generates_noop(config):
plan = generate_plan([(video_file, identity)], config)
assert plan.operations[0].operation_type == "no-op"
assert "rejected" in plan.operations[0].reason.lower()
def test_save_and_load_plan_validates_schema(tmp_path):
"""Saved plan artifacts should round-trip through schema validation."""
plan = ExecutionPlan(
plan_id="plan-123",
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=timezone.utc),
operations=[
FileOperation(
operation_type="move",
source_path=Path("/mnt/nas/videos/movie/source.mkv"),
destination_path=Path("/mnt/nas/videos/movie/target.mkv"),
reason="move movie",
has_conflict=False,
)
],
summary={"move": 1, "rename": 0, "noop": 0, "delete": 0},
summary_by_reason={"move movie": 1},
human_summary="计划已生成",
metadata={"analysis_source": "analysis.json"},
)
output_path = tmp_path / "plan.json"
from vlm.planner import save_plan
save_plan(plan, output_path)
loaded = load_plan(output_path)
assert loaded.plan_id == plan.plan_id
assert loaded.operations[0].source_path == plan.operations[0].source_path
assert loaded.operations[0].destination_path == plan.operations[0].destination_path
assert loaded.summary == plan.summary
assert loaded.summary_by_reason == plan.summary_by_reason
assert loaded.human_summary == plan.human_summary
assert loaded.metadata == plan.metadata
def test_load_plan_rejects_invalid_schema(tmp_path):
"""Invalid plan artifacts should fail validation before deserialization."""
invalid_path = tmp_path / "invalid-plan.json"
invalid_path.write_text(
json.dumps(
{
"vlm_schema_version": "1.0",
"plan_id": "plan-123",
"created_at": "2026-04-02T12:00:00+00:00",
"operations": [
{
"operation_type": "move",
"source_path": "/mnt/nas/videos/movie/source.mkv",
"destination_path": "/mnt/nas/videos/movie/target.mkv",
"has_conflict": False,
}
],
"summary": {"move": 1},
}
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="plan JSON.operations\\[0\\]\\.reason"):
load_plan(invalid_path)
+56
View File
@@ -0,0 +1,56 @@
"""Tests for vlm.review_display helpers."""
from pathlib import Path
from vlm.review_display import (
build_csv_rows,
format_paths_for_detail,
review_row_status_symbol,
risk_flags_to_labels,
)
def test_risk_flags_to_labels():
assert "需人工判断" in risk_flags_to_labels("manual_review")
assert "目标冲突" in risk_flags_to_labels("conflict")
assert risk_flags_to_labels("") == ""
assert risk_flags_to_labels("unknown_flag") == "unknown_flag"
def test_format_paths_for_detail_relative():
root = Path("/media/lib")
text = format_paths_for_detail(
str(root / "a" / "f.mkv"),
str(root / "b" / "f.mkv"),
root,
)
assert "来源:" in text
assert "f.mkv" in text
assert "目标:" in text
def test_build_csv_rows_merges_operation_type():
rows = [
{
"index": "1",
"operation_type": "move",
"risk_flags": "manual_review",
"source_path": "/x",
"destination_path": "/y",
"reason": "r",
}
]
merged = build_csv_rows(rows, {1: "no-op"})
assert merged[0]["operation_type"] == "no-op"
assert merged[0]["risk_flags"] == "manual_review"
def test_review_row_status_symbol():
initial = {1: "move"}
current = {1: "move"}
assert review_row_status_symbol(current, initial, 1) == "·"
current2 = {1: "no-op"}
assert review_row_status_symbol(current2, initial, 1) == ""
current3 = {1: "move"}
initial3 = {1: "no-op"}
assert review_row_status_symbol(current3, initial3, 1) == ""
Generated
+105 -1
View File
@@ -57,6 +57,56 @@ 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 = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -66,6 +116,15 @@ 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 = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -166,6 +225,19 @@ wheels = [
{ 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 = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
@@ -175,6 +247,23 @@ 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 = "textual"
version = "8.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cf/2f/d44f0f12b3ddb1f0b88f7775652e99c6b5a43fd733badf4ce064bdbfef4a/textual-8.2.3.tar.gz", hash = "sha256:beea7b86b03b03558a2224f0cc35252e60ef8b0c4353b117b2f40972902d976a", size = 1848738, upload-time = "2026-04-05T09:12:45.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/28/a81d6ce9f4804818bd1231a9a6e4d56ea84ebbe8385c49591444f0234fa2/textual-8.2.3-py3-none-any.whl", hash = "sha256:5008ac581bebf1f6fa0520404261844a231e5715fdbddd10ca73916a3af48ca2", size = 724231, upload-time = "2026-04-05T09:12:48.747Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"
@@ -238,6 +327,15 @@ 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 = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "video-library-manager"
version = "0.1.0"
@@ -251,6 +349,10 @@ dependencies = [
dev = [
{ name = "hypothesis" },
{ name = "pytest" },
{ name = "textual" },
]
tui = [
{ name = "textual" },
]
[package.metadata]
@@ -259,5 +361,7 @@ requires-dist = [
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.82.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "textual", marker = "extra == 'dev'", specifier = ">=0.47.0" },
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.47.0" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "tui"]