refactor: DLO-16/17/18/20 — CLI simplification, config Pydantic, planner split, type system unification
DLO-16: Reduce cli.py from 1073 to 83 lines by registering Click commands from commands/*.py modules DLO-17: Migrate Config to Pydantic BaseModel for validation DLO-18: Split planner.py (826 lines) into orchestration, path rendering, and duplicate handling modules DLO-20: Unify type system — convert 14 dataclasses to Pydantic BaseModel, keep TypedDicts as JSON schema hints Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
8a60aaf9a9
commit
fe03a31dd4
+162
-129
@@ -18,6 +18,42 @@ from vlm.reports import (
|
||||
generate_summary_report,
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
# Custom strategies for generating test data
|
||||
|
||||
@st.composite
|
||||
@@ -27,10 +63,10 @@ def series_identity_strategy(draw, title=None, season=None):
|
||||
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
|
||||
if season is None:
|
||||
season = draw(st.integers(min_value=1, max_value=20))
|
||||
|
||||
|
||||
# Generate 1-3 episode numbers
|
||||
episode_count = draw(st.integers(min_value=1, max_value=3))
|
||||
episodes = draw(st.lists(
|
||||
@@ -39,12 +75,13 @@ def series_identity_strategy(draw, title=None, season=None):
|
||||
max_size=episode_count,
|
||||
unique=True
|
||||
))
|
||||
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv"
|
||||
|
||||
return SeriesIdentity(title, season, sorted(episodes), confidence, needs_review, original_filename)
|
||||
|
||||
return _series(title, season, sorted(episodes),
|
||||
confidence=confidence,
|
||||
original_filename=original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
@@ -54,15 +91,15 @@ def movie_identity_strategy(draw, title=None, year=None):
|
||||
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
|
||||
if year is None:
|
||||
year = draw(st.integers(min_value=1900, max_value=2030))
|
||||
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.{year}.mkv"
|
||||
|
||||
return MovieIdentity(title, year, confidence, needs_review, original_filename)
|
||||
|
||||
return _movie(title, year, confidence=confidence,
|
||||
original_filename=original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
@@ -72,11 +109,10 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
filename = draw(st.text(min_size=5, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_'
|
||||
))) + ".mkv"
|
||||
|
||||
path = Path(f"/{category}/{filename}")
|
||||
|
||||
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
|
||||
modified_timestamp = datetime.now(timezone.utc)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Optional metadata
|
||||
has_metadata = draw(st.booleans())
|
||||
if has_metadata:
|
||||
@@ -84,10 +120,17 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
codec = draw(st.sampled_from(["h264", "h265", "vp9", "av1"]))
|
||||
duration_seconds = draw(st.floats(min_value=300, max_value=10800))
|
||||
bitrate_kbps = draw(st.integers(min_value=500, max_value=20000))
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category,
|
||||
resolution, codec, duration_seconds, bitrate_kbps)
|
||||
return _video(
|
||||
filename, size_bytes, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
resolution=resolution, codec=codec,
|
||||
duration_seconds=duration_seconds, bitrate_kbps=bitrate_kbps,
|
||||
)
|
||||
else:
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category)
|
||||
return _video(
|
||||
filename, size_bytes, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
)
|
||||
|
||||
|
||||
# Property 10: Gap detection
|
||||
@@ -109,38 +152,39 @@ def video_file_strategy(draw, filename=None, category="movie"):
|
||||
def test_property_10_gap_detection(title, season, episodes_data):
|
||||
"""Property 10: For any set of episodes within season, analysis SHALL detect
|
||||
missing episode numbers in range [min, max].
|
||||
|
||||
|
||||
Validates: Requirements 4.1, 4.2
|
||||
"""
|
||||
# Sort episodes and ensure there's at least one gap
|
||||
sorted_episodes = sorted(episodes_data)
|
||||
|
||||
|
||||
# Create episodes, intentionally removing one to create a gap
|
||||
if len(sorted_episodes) >= 3:
|
||||
# Remove a middle episode to guarantee a gap
|
||||
gap_index = len(sorted_episodes) // 2
|
||||
removed_episode = sorted_episodes[gap_index]
|
||||
episodes_with_gap = sorted_episodes[:gap_index] + sorted_episodes[gap_index + 1:]
|
||||
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = [
|
||||
SeriesIdentity(title, season, [ep], 0.9, False, f"{title}.S{season:02d}E{ep:02d}.mkv")
|
||||
_series(title, season, [ep],
|
||||
original_filename=f"{title}.S{season:02d}E{ep:02d}.mkv")
|
||||
for ep in episodes_with_gap
|
||||
]
|
||||
|
||||
|
||||
# Analyze completeness
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
|
||||
# Should detect the gap
|
||||
if len(result) > 0:
|
||||
assert result[0].series_title == title
|
||||
assert result[0].season == season
|
||||
|
||||
|
||||
# The missing episode should be in the detected gaps
|
||||
min_ep = min(episodes_with_gap)
|
||||
max_ep = max(episodes_with_gap)
|
||||
expected_missing = set(range(min_ep, max_ep + 1)) - set(episodes_with_gap)
|
||||
|
||||
|
||||
assert set(result[0].episodes_missing) == expected_missing
|
||||
assert removed_episode in result[0].episodes_missing
|
||||
|
||||
@@ -158,7 +202,7 @@ def test_property_10_gap_detection(title, season, episodes_data):
|
||||
def test_property_11_multi_season_independence(title, season1_episodes, season2_episodes):
|
||||
"""Property 11: For any series with multiple seasons, gap detection of one
|
||||
season SHALL not affect others.
|
||||
|
||||
|
||||
Validates: Requirements 4.4
|
||||
"""
|
||||
# Create episodes for season 1 with a gap
|
||||
@@ -170,29 +214,31 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
|
||||
else:
|
||||
s1_with_gap = s1_sorted
|
||||
s1_missing = None
|
||||
|
||||
|
||||
# Create episodes for season 2 (complete, no gaps)
|
||||
s2_sorted = sorted(season2_episodes)
|
||||
s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1))
|
||||
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = []
|
||||
for ep in s1_with_gap:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 1, [ep], 0.9, False, f"{title}.S01E{ep:02d}.mkv")
|
||||
_series(title, 1, [ep],
|
||||
original_filename=f"{title}.S01E{ep:02d}.mkv")
|
||||
)
|
||||
for ep in s2_complete:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 2, [ep], 0.9, False, f"{title}.S02E{ep:02d}.mkv")
|
||||
_series(title, 2, [ep],
|
||||
original_filename=f"{title}.S02E{ep:02d}.mkv")
|
||||
)
|
||||
|
||||
|
||||
# Analyze completeness
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
|
||||
# Season 2 should not appear in results (it's complete)
|
||||
season2_results = [r for r in result if r.season == 2]
|
||||
assert len(season2_results) == 0
|
||||
|
||||
|
||||
# Season 1 should appear if there's a gap
|
||||
if s1_missing is not None:
|
||||
season1_results = [r for r in result if r.season == 1]
|
||||
@@ -213,33 +259,31 @@ def test_property_11_multi_season_independence(title, season1_episodes, season2_
|
||||
def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
|
||||
"""Property 12: For any set of movies with identical normalized titles and years,
|
||||
all SHALL be grouped as duplicates.
|
||||
|
||||
|
||||
Validates: Requirements 5.1
|
||||
"""
|
||||
# Create multiple movie identities with same title and year
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
identities.append(_movie(title, year, original_filename=filename))
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
assert result[0].identity.year == year
|
||||
@@ -259,33 +303,33 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
|
||||
def test_property_13_duplicate_detection_series(title, season, episode, duplicate_count):
|
||||
"""Property 13: For any set of series files with identical normalized titles,
|
||||
seasons, and episodes, all SHALL be grouped as duplicates.
|
||||
|
||||
|
||||
Validates: Requirements 5.2
|
||||
"""
|
||||
# Create multiple series identities with same title, season, and episode
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
|
||||
identities.append(SeriesIdentity(title, season, [episode], 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/series/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
identities.append(
|
||||
_series(title, season, [episode], original_filename=filename)
|
||||
)
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000, "series",
|
||||
modified_timestamp=now, path=Path(f"/series/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
assert result[0].identity.season == season
|
||||
@@ -305,52 +349,45 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
|
||||
def test_property_14_duplicate_quality_comparison(title, year, file_count):
|
||||
"""Property 14: For any duplicate group, comparison data SHALL include
|
||||
available metadata for each file.
|
||||
|
||||
|
||||
Validates: Requirements 5.3
|
||||
"""
|
||||
# Create movie identities and files with varying metadata
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
|
||||
identities.append(_movie(title, year, original_filename=filename))
|
||||
|
||||
# Some files have full metadata, some don't
|
||||
if i % 2 == 0:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000,
|
||||
))
|
||||
else:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
files.append(_video(
|
||||
filename, 1_000_000_000 + i * 100_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should have quality comparison data
|
||||
assert len(result) == 1
|
||||
assert len(result[0].quality_comparison) == file_count
|
||||
|
||||
|
||||
# Each comparison entry should have at least filename and size
|
||||
for comparison in result[0].quality_comparison:
|
||||
assert 'filename' in comparison
|
||||
assert 'size_bytes' in comparison
|
||||
assert 'path' in comparison
|
||||
|
||||
|
||||
# Files with metadata should have those fields
|
||||
if comparison['filename'].endswith('.0.mkv') or comparison['filename'].endswith('.2.mkv'):
|
||||
assert 'resolution' in comparison
|
||||
@@ -369,33 +406,30 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
|
||||
def test_property_42_completeness_report(series_count, format):
|
||||
"""Property 42: For any set of analyzed series, completeness report SHALL
|
||||
include all series with detected gaps.
|
||||
|
||||
|
||||
Validates: Requirements 11.2
|
||||
"""
|
||||
# Create series with gaps
|
||||
analysis_results = []
|
||||
|
||||
|
||||
for i in range(series_count):
|
||||
title = f"Series {i}"
|
||||
season = 1
|
||||
episodes_found = [1, 2, 4, 5] # Gap at episode 3
|
||||
episodes_missing = [3]
|
||||
|
||||
|
||||
analysis_results.append(SeasonCompleteness(
|
||||
series_title=title,
|
||||
season=season,
|
||||
episodes_found=episodes_found,
|
||||
episodes_missing=episodes_missing
|
||||
season=1,
|
||||
episodes_found=[1, 2, 4, 5], # Gap at episode 3
|
||||
episodes_missing=[3]
|
||||
))
|
||||
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_completeness_report(analysis_results, format, library_root)
|
||||
|
||||
|
||||
# Report should include all series
|
||||
for i in range(series_count):
|
||||
assert f"Series {i}" in report
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
@@ -410,30 +444,28 @@ def test_property_42_completeness_report(series_count, format):
|
||||
def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
"""Property 43: For any set of detected duplicates, duplicate report SHALL
|
||||
group files by identity with comparison data.
|
||||
|
||||
|
||||
Validates: Requirements 11.3
|
||||
"""
|
||||
# Create duplicate groups
|
||||
duplicate_groups = []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(duplicate_count):
|
||||
title = f"Movie {i}"
|
||||
year = 2020 + i
|
||||
|
||||
|
||||
# Create 2 files for each duplicate group
|
||||
files = []
|
||||
quality_comparison = []
|
||||
|
||||
|
||||
for j in range(2):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
|
||||
file = VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + j * 500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
file = _video(
|
||||
filename, 1_000_000_000 + j * 500_000_000,
|
||||
modified_timestamp=now, path=Path(f"/movies/{filename}"),
|
||||
resolution="1920x1080" if j == 0 else "1280x720",
|
||||
codec="h264"
|
||||
codec="h264",
|
||||
)
|
||||
files.append(file)
|
||||
quality_comparison.append({
|
||||
@@ -443,21 +475,24 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
'resolution': file.resolution,
|
||||
'codec': file.codec
|
||||
})
|
||||
|
||||
identity = MovieIdentity(title, year, 0.9, False, files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(identity, files, quality_comparison))
|
||||
|
||||
|
||||
identity = _movie(title, year, original_filename=files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(
|
||||
identity=identity, files=files,
|
||||
quality_comparison=quality_comparison,
|
||||
))
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_duplicate_report(duplicate_groups, format, library_root)
|
||||
|
||||
|
||||
# Report should include all duplicate groups
|
||||
for i in range(duplicate_count):
|
||||
assert f"Movie {i}" in report
|
||||
|
||||
|
||||
# Report should include comparison data (file sizes, resolutions)
|
||||
assert "1920x1080" in report or "resolution" in report.lower()
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
@@ -476,41 +511,39 @@ def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
def test_property_44_summary_report_accuracy(file_count, categories):
|
||||
"""Property 44: For any scanned library, summary report SHALL contain
|
||||
accurate counts and sizes.
|
||||
|
||||
|
||||
Validates: Requirements 11.4
|
||||
"""
|
||||
# Create video files
|
||||
files = []
|
||||
total_size = 0
|
||||
category_counts = {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for i in range(file_count):
|
||||
category = categories[i % len(categories)]
|
||||
size = 1000000000 + i * 100000000
|
||||
size = 1_000_000_000 + i * 100_000_000
|
||||
filename = f"file_{i}.mkv"
|
||||
|
||||
files.append(VideoFile(
|
||||
Path(f"/{category}/{filename}"),
|
||||
filename,
|
||||
size,
|
||||
datetime.now(timezone.utc),
|
||||
category
|
||||
|
||||
files.append(_video(
|
||||
filename, size, category,
|
||||
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
|
||||
))
|
||||
|
||||
|
||||
total_size += size
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
|
||||
# Generate summary report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Report should include total file count
|
||||
assert f"Total Files: {file_count}" in report
|
||||
|
||||
|
||||
# Report should include category breakdown
|
||||
for category, count in category_counts.items():
|
||||
assert category.capitalize() in report
|
||||
assert f"Files: {count}" in report
|
||||
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
Reference in New Issue
Block a user