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
+319
-277
@@ -10,214 +10,269 @@ from vlm.analysis import analyze_series_completeness, compare_quality, detect_du
|
||||
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesCompletenessAnalysis:
|
||||
"""Test series completeness analysis functionality."""
|
||||
|
||||
|
||||
def test_detect_single_gap(self):
|
||||
"""Test detection of a single missing episode."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", episodes=[4],
|
||||
original_filename="Show.Name.S01E04.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Show Name"
|
||||
assert result[0].season == 1
|
||||
assert result[0].episodes_found == [1, 2, 4, 5]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
|
||||
def test_detect_multiple_gaps(self):
|
||||
"""Test detection of multiple missing episodes."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [7], 0.9, False, "Show.Name.S01E07.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[7],
|
||||
original_filename="Show.Name.S01E07.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 3, 5, 7]
|
||||
assert result[0].episodes_missing == [2, 4, 6]
|
||||
|
||||
|
||||
def test_no_gaps_returns_empty(self):
|
||||
"""Test that complete seasons are not included in results."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_multi_season_independence(self):
|
||||
"""Test that gap detection for one season doesn't affect others."""
|
||||
episodes = [
|
||||
# Season 1 - has gap at episode 2
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", season=1, episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", season=1, episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
# Season 2 - complete
|
||||
SeriesIdentity("Show Name", 2, [1], 0.9, False, "Show.Name.S02E01.mkv"),
|
||||
SeriesIdentity("Show Name", 2, [2], 0.9, False, "Show.Name.S02E02.mkv"),
|
||||
_series("Show Name", season=2, episodes=[1],
|
||||
original_filename="Show.Name.S02E01.mkv"),
|
||||
_series("Show Name", season=2, episodes=[2],
|
||||
original_filename="Show.Name.S02E02.mkv"),
|
||||
# Season 3 - has gap at episode 5
|
||||
SeriesIdentity("Show Name", 3, [4], 0.9, False, "Show.Name.S03E04.mkv"),
|
||||
SeriesIdentity("Show Name", 3, [6], 0.9, False, "Show.Name.S03E06.mkv"),
|
||||
_series("Show Name", season=3, episodes=[4],
|
||||
original_filename="Show.Name.S03E04.mkv"),
|
||||
_series("Show Name", season=3, episodes=[6],
|
||||
original_filename="Show.Name.S03E06.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Should have 2 results (seasons 1 and 3 with gaps)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# Find season 1 result
|
||||
season1 = next(r for r in result if r.season == 1)
|
||||
assert season1.episodes_found == [1, 3]
|
||||
assert season1.episodes_missing == [2]
|
||||
|
||||
|
||||
# Find season 3 result
|
||||
season3 = next(r for r in result if r.season == 3)
|
||||
assert season3.episodes_found == [4, 6]
|
||||
assert season3.episodes_missing == [5]
|
||||
|
||||
|
||||
def test_multi_episode_files(self):
|
||||
"""Test handling of multi-episode files."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1, 2], 0.9, False, "Show.Name.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
_series("Show Name", episodes=[1, 2],
|
||||
original_filename="Show.Name.S01E01-E02.mkv"),
|
||||
_series("Show Name", episodes=[4],
|
||||
original_filename="Show.Name.S01E04.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 2, 4]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
|
||||
def test_different_series_separate_analysis(self):
|
||||
"""Test that different series are analyzed separately."""
|
||||
episodes = [
|
||||
# Series A - has gap
|
||||
SeriesIdentity("Series A", 1, [1], 0.9, False, "Series.A.S01E01.mkv"),
|
||||
SeriesIdentity("Series A", 1, [3], 0.9, False, "Series.A.S01E03.mkv"),
|
||||
_series("Series A", episodes=[1],
|
||||
original_filename="Series.A.S01E01.mkv"),
|
||||
_series("Series A", episodes=[3],
|
||||
original_filename="Series.A.S01E03.mkv"),
|
||||
# Series B - complete
|
||||
SeriesIdentity("Series B", 1, [1], 0.9, False, "Series.B.S01E01.mkv"),
|
||||
SeriesIdentity("Series B", 1, [2], 0.9, False, "Series.B.S01E02.mkv"),
|
||||
_series("Series B", episodes=[1],
|
||||
original_filename="Series.B.S01E01.mkv"),
|
||||
_series("Series B", episodes=[2],
|
||||
original_filename="Series.B.S01E02.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Only Series A should be in results
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Series A"
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
|
||||
def test_skip_episodes_without_season(self):
|
||||
"""Test that episodes with season=None are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", None, [1], 0.3, True, "Show.Name.Episode.1.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[2],
|
||||
original_filename="Show.Name.S01E02.mkv"),
|
||||
_series("Show Name", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Should only analyze season 1, which is complete
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_episodes_with_empty_episode_list(self):
|
||||
"""Test that episodes with empty episode list are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[3],
|
||||
original_filename="Show.Name.S01E03.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.S01.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Should detect gap at episode 2
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
|
||||
def test_non_sequential_start(self):
|
||||
"""Test gap detection when episodes don't start at 1."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [6], 0.9, False, "Show.Name.S01E06.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [8], 0.9, False, "Show.Name.S01E08.mkv"),
|
||||
_series("Show Name", episodes=[5],
|
||||
original_filename="Show.Name.S01E05.mkv"),
|
||||
_series("Show Name", episodes=[6],
|
||||
original_filename="Show.Name.S01E06.mkv"),
|
||||
_series("Show Name", episodes=[8],
|
||||
original_filename="Show.Name.S01E08.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Should detect gap at episode 7 in range [5, 8]
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [5, 6, 8]
|
||||
assert result[0].episodes_missing == [7]
|
||||
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test handling of empty episode list."""
|
||||
result = analyze_series_completeness([])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_single_episode_no_gap(self):
|
||||
"""Test that a single episode has no gaps."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
_series("Show Name", episodes=[1],
|
||||
original_filename="Show.Name.S01E01.mkv"),
|
||||
]
|
||||
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Single episode has no gaps
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
"""Test duplicate detection functionality."""
|
||||
|
||||
|
||||
def test_detect_movie_duplicates(self):
|
||||
"""Test detection of duplicate movies with identical title and year."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find one duplicate group (The Matrix)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, MovieIdentity)
|
||||
@@ -225,43 +280,32 @@ class TestDuplicateDetection:
|
||||
assert result[0].identity.year == 1999
|
||||
assert len(result[0].files) == 2
|
||||
assert len(result[0].quality_comparison) == 2
|
||||
|
||||
|
||||
def test_detect_series_duplicates(self):
|
||||
"""Test detection of duplicate series episodes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.1080p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.720p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.1080p.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.720p.mkv"),
|
||||
_series("Breaking Bad", episodes=[2],
|
||||
original_filename="Breaking.Bad.S01E02.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series",
|
||||
resolution="1920x1080"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series",
|
||||
resolution="1280x720"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E02.mkv"),
|
||||
"Breaking.Bad.S01E02.mkv",
|
||||
1200000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1920x1080"),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000,
|
||||
"series", modified_timestamp=now,
|
||||
resolution="1280x720"),
|
||||
_video("Breaking.Bad.S01E02.mkv", 1_200_000_000,
|
||||
"series", modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find one duplicate group (S01E01)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, SeriesIdentity)
|
||||
@@ -269,110 +313,123 @@ class TestDuplicateDetection:
|
||||
assert result[0].identity.season == 1
|
||||
assert 1 in result[0].identity.episodes
|
||||
assert len(result[0].files) == 2
|
||||
|
||||
|
||||
def test_no_duplicates(self):
|
||||
"""Test that unique files are not flagged as duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Movie A", 2020, 0.9, False, "Movie.A.2020.mkv"),
|
||||
MovieIdentity("Movie B", 2021, 0.9, False, "Movie.B.2021.mkv"),
|
||||
_movie("Movie A", 2020),
|
||||
_movie("Movie B", 2021),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Movie.A.2020.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Movie.B.2021.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_movies_without_year(self):
|
||||
"""Test that movies without year are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.mkv"),
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.2.mkv"),
|
||||
_movie("Unknown Movie", year=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Movie.mkv"),
|
||||
_movie("Unknown Movie", year=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Movie.2.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Unknown.Movie.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Unknown.Movie.2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should not detect duplicates for files needing review
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_series_without_season(self):
|
||||
"""Test that series without season are excluded from duplicate detection."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.E01.mkv"),
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.Episode.1.mkv"),
|
||||
_series("Unknown Show", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Show.E01.mkv"),
|
||||
_series("Unknown Show", season=None, confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Unknown.Show.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Unknown.Show.E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Unknown.Show.Episode.1.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_skip_series_with_empty_episodes(self):
|
||||
"""Test that series with empty episode list are excluded."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.Season.1.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.S01.mkv"),
|
||||
_series("Show Name", episodes=[], confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Show.Name.Season.1.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.Name.S01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.Name.Season.1.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_quality_comparison_includes_all_metadata(self):
|
||||
"""Test that quality comparison includes all available metadata."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.1080p.mkv"),
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.720p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.1080p.mkv"),
|
||||
_movie("Test Movie", 2020,
|
||||
original_filename="Test.Movie.2020.720p.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.1080p.mkv"),
|
||||
"Test.Movie.2020.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.720p.mkv"),
|
||||
"Test.Movie.2020.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
_video("Test.Movie.2020.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("Test.Movie.2020.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
comparison = result[0].quality_comparison
|
||||
assert len(comparison) == 2
|
||||
|
||||
|
||||
# Check first file comparison data
|
||||
assert comparison[0]['filename'] == "Test.Movie.2020.1080p.mkv"
|
||||
assert comparison[0]['size_bytes'] == 2000000000
|
||||
@@ -380,96 +437,98 @@ class TestDuplicateDetection:
|
||||
assert comparison[0]['codec'] == "h264"
|
||||
assert comparison[0]['duration_seconds'] == 7200.0
|
||||
assert comparison[0]['bitrate_kbps'] == 5000
|
||||
|
||||
|
||||
# Check second file comparison data
|
||||
assert comparison[1]['filename'] == "Test.Movie.2020.720p.mkv"
|
||||
assert comparison[1]['size_bytes'] == 1000000000
|
||||
assert comparison[1]['resolution'] == "1280x720"
|
||||
|
||||
|
||||
def test_multi_episode_file_duplicates(self):
|
||||
"""Test duplicate detection for multi-episode files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1, 2], 0.9, False, "Show.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 1, [2], 0.9, False, "Show.S01E02.mkv"),
|
||||
_series("Show", episodes=[1, 2],
|
||||
original_filename="Show.S01E01-E02.mkv"),
|
||||
_series("Show", episodes=[1],
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", episodes=[2],
|
||||
original_filename="Show.S01E02.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.S01E01-E02.mkv", 2_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Should find duplicates for both E01 and E02
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_different_years_not_duplicates(self):
|
||||
"""Test that same title with different years are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Thing", 1982, 0.9, False, "The.Thing.1982.mkv"),
|
||||
MovieIdentity("The Thing", 2011, 0.9, False, "The.Thing.2011.mkv"),
|
||||
_movie("The Thing", 1982),
|
||||
_movie("The Thing", 2011),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("The.Thing.1982.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("The.Thing.2011.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_different_seasons_not_duplicates(self):
|
||||
"""Test that same series/episode in different seasons are not duplicates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 2, [1], 0.9, False, "Show.S02E01.mkv"),
|
||||
_series("Show", season=1,
|
||||
original_filename="Show.S01E01.mkv"),
|
||||
_series("Show", season=2,
|
||||
original_filename="Show.S02E01.mkv"),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S02E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestQualityComparison:
|
||||
"""Test quality comparison functionality."""
|
||||
|
||||
|
||||
def test_compare_quality_with_all_metadata(self):
|
||||
"""Test quality comparison with all metadata available."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h265",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h265", duration_seconds=7200.0,
|
||||
bitrate_kbps=2500),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
@@ -477,36 +536,24 @@ class TestQualityComparison:
|
||||
assert result[0]['codec'] == "h264"
|
||||
assert result[0]['duration_seconds'] == 7200.0
|
||||
assert result[0]['bitrate_kbps'] == 5000
|
||||
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert result[1]['resolution'] == "1280x720"
|
||||
assert result[1]['codec'] == "h265"
|
||||
|
||||
|
||||
def test_compare_quality_with_partial_metadata(self):
|
||||
"""Test quality comparison when some metadata is missing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080"
|
||||
# codec, duration, bitrate not available
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
# No optional metadata
|
||||
),
|
||||
_video("file1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080"),
|
||||
_video("file2.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
@@ -514,33 +561,28 @@ class TestQualityComparison:
|
||||
assert 'codec' not in result[0]
|
||||
assert 'duration_seconds' not in result[0]
|
||||
assert 'bitrate_kbps' not in result[0]
|
||||
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert 'resolution' not in result[1]
|
||||
assert 'codec' not in result[1]
|
||||
|
||||
|
||||
def test_compare_quality_empty_list(self):
|
||||
"""Test quality comparison with empty file list."""
|
||||
result = compare_quality([])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_compare_quality_single_file(self):
|
||||
"""Test quality comparison with single file."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file.mkv"),
|
||||
"file.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
_video("file.mkv", 1_500_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['filename'] == "file.mkv"
|
||||
assert result[0]['size_bytes'] == 1500000000
|
||||
|
||||
+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
|
||||
|
||||
+188
-234
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vlm.config import Config, create_default_config, load_config, validate_config
|
||||
|
||||
@@ -334,277 +335,235 @@ class TestCreateDefaultConfig:
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Test validate_config function."""
|
||||
|
||||
"""Test validation — with Pydantic, invalid values raise ValidationError at construction."""
|
||||
|
||||
def test_validate_valid_config(self):
|
||||
"""Test validating a valid configuration."""
|
||||
config = Config(library_root=Path("/mnt/nas/videos"))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert errors == []
|
||||
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_empty_library_root(self):
|
||||
"""Test validating config with empty library_root."""
|
||||
config = Config(library_root=Path(""))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("library_root" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError, match="library_root"):
|
||||
Config(library_root=Path(""))
|
||||
|
||||
def test_validate_empty_video_extensions(self):
|
||||
"""Test validating config with empty video_extensions."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=[]
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("video_extensions" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError, match="video_extensions"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), video_extensions=[])
|
||||
|
||||
def test_validate_invalid_video_extension_format(self):
|
||||
"""Test validating config with invalid video extension format."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=["mp4", ".mkv"] # Missing dot on first one
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
|
||||
def test_validate_empty_templates(self):
|
||||
"""Test validating config with empty templates."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
movie_template="",
|
||||
series_template=""
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) >= 2
|
||||
assert any("movie_template" in err for err in errors)
|
||||
assert any("series_template" in err for err in errors)
|
||||
|
||||
def test_validate_invalid_log_level(self):
|
||||
"""Test validating config with invalid log level."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("log_level" in err for err in errors)
|
||||
|
||||
def test_validate_valid_log_levels(self):
|
||||
"""Test validating config with all valid log levels."""
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
for level in valid_levels:
|
||||
config = Config(
|
||||
with pytest.raises(ValidationError, match="must start with"):
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level=level
|
||||
video_extensions=["mp4", ".mkv"],
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == [], f"Log level {level} should be valid"
|
||||
|
||||
|
||||
def test_validate_empty_templates(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
movie_template="",
|
||||
series_template="",
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
fields = {e["loc"][0] for e in errors}
|
||||
assert "movie_template" in fields
|
||||
assert "series_template" in fields
|
||||
|
||||
def test_validate_invalid_log_level(self):
|
||||
with pytest.raises(ValidationError, match="log_level"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), log_level="INVALID")
|
||||
|
||||
def test_validate_valid_log_levels(self):
|
||||
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
config = Config(library_root=Path("/mnt/nas/videos"), log_level=level)
|
||||
assert validate_config(config) == [], f"Log level {level} should be valid"
|
||||
|
||||
def test_validate_absolute_quarantine_dir(self):
|
||||
"""Test validating config with absolute quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must be relative" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError, match="must be relative"):
|
||||
Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path",
|
||||
)
|
||||
|
||||
def test_validate_empty_quarantine_dir(self):
|
||||
"""Test validating config with empty quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir=""
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("quarantine_dir" in err for err in errors)
|
||||
with pytest.raises(ValidationError, match="quarantine_dir"):
|
||||
Config(library_root=Path("/mnt/nas/videos"), quarantine_dir="")
|
||||
|
||||
def test_validate_workspace_dir_type(self):
|
||||
"""workspace_dir must be a Path object."""
|
||||
def test_workspace_dir_coerces_from_string(self):
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
workspace_dir="artifacts", # type: ignore[arg-type]
|
||||
workspace_dir="artifacts",
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("workspace_dir must be a Path object" in err for err in errors)
|
||||
|
||||
assert config.workspace_dir == Path("artifacts")
|
||||
|
||||
def test_validate_multiple_errors(self):
|
||||
"""Test validating config with multiple errors."""
|
||||
config = Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID",
|
||||
)
|
||||
assert len(exc_info.value.errors()) >= 4
|
||||
|
||||
def test_validate_duplicate_keep_reputation_quality_time(self):
|
||||
"""Test validating config with by_reputation_quality_time strategy."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
duplicate_keep="by_reputation_quality_time"
|
||||
duplicate_keep="by_reputation_quality_time",
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_empty_categories(self):
|
||||
"""Test validating config with empty categories."""
|
||||
config = Config(library_root=Path("/test"), categories={})
|
||||
errors = validate_config(config)
|
||||
assert any("categories" in e and "empty" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(library_root=Path("/test"), categories={})
|
||||
|
||||
def test_validate_missing_required_category(self):
|
||||
"""Test validating config with missing required categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]} # Missing series, anime
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("series" in e or "anime" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="categories"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]},
|
||||
)
|
||||
|
||||
def test_validate_duplicate_directory_names(self):
|
||||
"""Test validating config with duplicate directory names."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"], # Duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e and "videos" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="Duplicate.*videos"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_case_insensitive_duplicates(self):
|
||||
"""Test validating config with case-insensitive duplicates."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"], # Case-insensitive duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="Duplicate"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_valid_custom_categories(self):
|
||||
"""Test validating config with valid custom categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
assert validate_config(config) == []
|
||||
|
||||
def test_validate_categories_not_dict(self):
|
||||
"""Test validating config with categories not a dict."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"] # Wrong type
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a dictionary" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"],
|
||||
)
|
||||
|
||||
def test_validate_category_list_not_list(self):
|
||||
"""Test validating config with category value not a list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie", # Should be a list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a list" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie",
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_empty_category_list(self):
|
||||
"""Test validating config with empty category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [], # Empty list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("cannot be empty" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="cannot be empty"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_rejects_unsupported_enrichment_provider(self):
|
||||
"""Test validating config with unsupported enrichment provider."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("unsupported providers" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="unsupported providers"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
enrichment_providers=["tmdb", "douban"],
|
||||
)
|
||||
|
||||
def test_validate_category_list_with_non_string(self):
|
||||
"""Test validating config with non-string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123], # Non-string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must contain strings" in e for e in errors)
|
||||
with pytest.raises(ValidationError):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_category_list_with_empty_string(self):
|
||||
"""Test validating config with empty string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""], # Empty string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("empty directory name" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="empty directory name"):
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_validate_invalid_plan_thresholds(self):
|
||||
"""Plan season/episode thresholds must be positive integers."""
|
||||
config = Config(
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Config(
|
||||
library_root=Path("/test"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
)
|
||||
fields = {e["loc"][0] for e in exc_info.value.errors()}
|
||||
assert "plan_max_season" in fields
|
||||
assert "plan_max_episode" in fields
|
||||
|
||||
def test_validate_config_with_model_construct_bypass(self):
|
||||
"""validate_config catches errors bypassed via model_construct."""
|
||||
config = Config.model_construct(
|
||||
library_root=Path("/test"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
video_extensions=[],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
series_template="series/{title}/Season {season:02d}/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine",
|
||||
workspace_dir=Path("artifacts"),
|
||||
categories={"movie": ["movie"], "series": ["series"], "anime": ["anime"]},
|
||||
enrichment_enabled=True,
|
||||
enrichment_incremental=True,
|
||||
enrichment_refresh_mode="manual",
|
||||
enrichment_providers=["tmdb"],
|
||||
enrichment_cache_db=Path.home() / ".vlm" / "enrichment_cache.db",
|
||||
enrichment_max_concurrency=6,
|
||||
enrichment_min_match_score=0.75,
|
||||
translation_mode="bidirectional",
|
||||
translation_fallback_machine=True,
|
||||
tmdb_api_key=None,
|
||||
tmdb_bearer_token=None,
|
||||
tmdb_language="zh-CN",
|
||||
tmdb_region=None,
|
||||
tmdb_include_adult=False,
|
||||
openai_api_key=None,
|
||||
reputation_min_votes=50,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_policy="flag_for_review",
|
||||
naming_title_format="{title_zh} {title_en}",
|
||||
duplicate_keep="by_reputation",
|
||||
plan_max_season=15,
|
||||
plan_max_episode=100,
|
||||
plan_include_sample_files=False,
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("plan_max_season" in e for e in errors)
|
||||
assert any("plan_max_episode" in e for e in errors)
|
||||
assert any("video_extensions" in e for e in errors)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
@@ -654,26 +613,21 @@ class TestConfigIntegration:
|
||||
assert loaded_config.library_root == default_config.library_root
|
||||
|
||||
def test_validation_workflow(self, tmp_path):
|
||||
"""Test workflow: load config -> validate -> report errors."""
|
||||
"""Test workflow: load config with invalid values raises ValidationError."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
# Create config with some invalid values
|
||||
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
||||
'log_level': 'INVALID'
|
||||
}
|
||||
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Load config
|
||||
config = load_config(config_file)
|
||||
|
||||
# Validate
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have errors
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
assert any("log_level" in err for err in errors)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
load_config(config_file)
|
||||
|
||||
messages = [e["msg"] for e in exc_info.value.errors()]
|
||||
assert any("must start with" in m for m in messages)
|
||||
assert any("log_level" in m for m in messages)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.config import Config, validate_config
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vlm.config import Config
|
||||
|
||||
|
||||
def test_validate_rejects_non_positive_enrichment_concurrency():
|
||||
config = Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
errors = validate_config(config)
|
||||
assert any("enrichment_max_concurrency must be >= 1" in e for e in errors)
|
||||
with pytest.raises(ValidationError, match="enrichment_max_concurrency"):
|
||||
Config(library_root=Path("/test"), enrichment_max_concurrency=0)
|
||||
|
||||
@@ -38,9 +38,10 @@ def test_enrich_incremental_cache_hit(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -104,11 +105,12 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
reputation_low_score_threshold=6.0,
|
||||
reputation_min_votes=50,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = LowScoreProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -144,9 +146,10 @@ def test_enrich_refresh_all_bypasses_cache(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = DummyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -184,8 +187,9 @@ def test_build_providers_rejects_unknown_provider(tmp_path):
|
||||
"""Unknown providers should fail fast with a clear error."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_providers=["tmdb", "tmdb_typo"],
|
||||
enrichment_providers=["tmdb"],
|
||||
)
|
||||
config.enrichment_providers = ["tmdb", "tmdb_typo"]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported enrichment providers"):
|
||||
_build_providers(config, request_timeout=3, retries=1, rate_limiter=RequestRateLimiter(0.25))
|
||||
@@ -220,9 +224,10 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
provider = FlakyProvider()
|
||||
monkeypatch.setattr(
|
||||
@@ -361,10 +366,11 @@ def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypa
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=False,
|
||||
enrichment_max_concurrency=4,
|
||||
)
|
||||
config.enrichment_providers = ["dummy"]
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
|
||||
+275
-266
@@ -24,51 +24,73 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestInventoryReport:
|
||||
"""Test inventory report generation."""
|
||||
|
||||
|
||||
def test_generate_csv_report(self):
|
||||
"""Test generating CSV format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Show.S01E01.mkv"),
|
||||
"Show.S01E01.mkv",
|
||||
1000000000,
|
||||
datetime(2023, 2, 20, 14, 45, 0),
|
||||
"series",
|
||||
resolution="1280x720",
|
||||
codec="h265"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264",
|
||||
duration_seconds=7200.0, bitrate_kbps=5000),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=datetime(2023, 2, 20, 14, 45, 0),
|
||||
resolution="1280x720", codec="h265"),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Check metadata comments
|
||||
assert "# Generated:" in report
|
||||
assert f"# Library Root: {library_root}" in report
|
||||
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
# Skip comment lines
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
|
||||
# Check we have 2 data rows
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
# Check first file
|
||||
assert rows[0]['filename'] == 'Movie1.mkv'
|
||||
assert rows[0]['size_bytes'] == '2000000000'
|
||||
@@ -77,7 +99,7 @@ class TestInventoryReport:
|
||||
assert rows[0]['codec'] == 'h264'
|
||||
assert rows[0]['duration_seconds'] == '7200.0'
|
||||
assert rows[0]['bitrate_kbps'] == '5000'
|
||||
|
||||
|
||||
# Check second file
|
||||
assert rows[1]['filename'] == 'Show.S01E01.mkv'
|
||||
assert rows[1]['category'] == 'series'
|
||||
@@ -86,44 +108,33 @@ class TestInventoryReport:
|
||||
# Optional fields not present should be empty
|
||||
assert rows[1]['duration_seconds'] == ''
|
||||
assert rows[1]['bitrate_kbps'] == ''
|
||||
|
||||
|
||||
def test_generate_json_report(self):
|
||||
"""Test generating JSON format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/anime/Anime1.mkv"),
|
||||
"Anime1.mkv",
|
||||
800000000,
|
||||
datetime(2023, 3, 10, 8, 15, 0),
|
||||
"anime"
|
||||
),
|
||||
_video("Movie1.mkv", 2_000_000_000,
|
||||
modified_timestamp=datetime(2023, 1, 15, 10, 30, 0),
|
||||
resolution="1920x1080", codec="h264"),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=datetime(2023, 3, 10, 8, 15, 0)),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["file_count"] == 2
|
||||
|
||||
|
||||
# Check files
|
||||
assert "files" in data
|
||||
assert len(data["files"]) == 2
|
||||
|
||||
|
||||
# Check first file
|
||||
file1 = data["files"][0]
|
||||
assert file1["filename"] == "Movie1.mkv"
|
||||
@@ -131,7 +142,7 @@ class TestInventoryReport:
|
||||
assert file1["category"] == "movie"
|
||||
assert file1["resolution"] == "1920x1080"
|
||||
assert file1["codec"] == "h264"
|
||||
|
||||
|
||||
# Check second file
|
||||
file2 = data["files"][1]
|
||||
assert file2["filename"] == "Anime1.mkv"
|
||||
@@ -139,89 +150,79 @@ class TestInventoryReport:
|
||||
# Optional fields should be null
|
||||
assert file2["resolution"] is None
|
||||
assert file2["codec"] is None
|
||||
|
||||
|
||||
def test_generate_csv_report_empty(self):
|
||||
"""Test generating CSV report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Should have metadata and header
|
||||
assert "# Generated:" in report
|
||||
assert "path,filename,size_bytes" in report
|
||||
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
|
||||
# No data rows
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["file_count"] == 0
|
||||
assert len(data["files"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_inventory_report(files, "xml", library_root)
|
||||
|
||||
|
||||
def test_csv_schema_columns(self):
|
||||
"""Test that CSV has all required columns in correct order."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=datetime.now(timezone.utc)),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Parse CSV header
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
header = csv_lines[0].strip() # Strip to remove any line ending characters
|
||||
|
||||
|
||||
# Check column order
|
||||
expected_columns = [
|
||||
'path', 'filename', 'size_bytes', 'modified_timestamp', 'category',
|
||||
'resolution', 'codec', 'duration_seconds', 'bitrate_kbps'
|
||||
]
|
||||
assert header == ','.join(expected_columns)
|
||||
|
||||
|
||||
def test_timestamp_formatting(self):
|
||||
"""Test that timestamps are formatted as ISO 8601."""
|
||||
naive_local = datetime(2023, 6, 15, 14, 30, 45)
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
|
||||
# Check timestamp format
|
||||
expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
assert expected_utc in report
|
||||
@@ -238,13 +239,8 @@ class TestInventoryReport:
|
||||
expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
naive_local,
|
||||
"movie"
|
||||
)
|
||||
_video("test.mkv", 1000,
|
||||
modified_timestamp=naive_local),
|
||||
]
|
||||
report = generate_inventory_report(files, "json", Path("/test"))
|
||||
data = json.loads(report)
|
||||
@@ -259,104 +255,128 @@ class TestInventoryReport:
|
||||
|
||||
class TestCompletenessReport:
|
||||
"""Test completeness report generation."""
|
||||
|
||||
|
||||
def test_generate_text_report_with_gaps(self):
|
||||
"""Test generating text format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("Breaking Bad", 2, [1, 3, 5], [2, 4]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=1,
|
||||
episodes_found=[1, 2, 4, 5], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=2,
|
||||
episodes_found=[1, 3, 5], episodes_missing=[2, 4],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="The Wire", season=1,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Series with gaps: 3" in report
|
||||
|
||||
|
||||
# Check series content
|
||||
assert "Breaking Bad" in report
|
||||
assert "The Wire" in report
|
||||
assert "Season 01:" in report
|
||||
assert "Season 02:" in report
|
||||
|
||||
|
||||
# Check episode information
|
||||
assert "Episodes found:" in report
|
||||
assert "Episodes missing:" in report
|
||||
|
||||
|
||||
def test_generate_json_report_with_gaps(self):
|
||||
"""Test generating JSON format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
SeasonCompleteness(
|
||||
series_title="Breaking Bad", season=1,
|
||||
episodes_found=[1, 2, 4, 5], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="The Wire", season=1,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
|
||||
|
||||
# Check series data
|
||||
assert "series" in data
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
|
||||
# Check Breaking Bad
|
||||
breaking_bad = next(s for s in data["series"] if s["title"] == "Breaking Bad")
|
||||
assert len(breaking_bad["seasons"]) == 1
|
||||
assert breaking_bad["seasons"][0]["season"] == 1
|
||||
assert breaking_bad["seasons"][0]["episodes_found"] == [1, 2, 4, 5]
|
||||
assert breaking_bad["seasons"][0]["episodes_missing"] == [3]
|
||||
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "No series with episode gaps detected." in report
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["series_count"] == 0
|
||||
assert len(data["series"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_completeness_report(analysis, "xml", library_root)
|
||||
|
||||
|
||||
def test_multiple_seasons_same_series(self):
|
||||
"""Test report with multiple seasons of same series."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Show Name", 1, [1, 3], [2]),
|
||||
SeasonCompleteness("Show Name", 2, [1, 2, 4], [3]),
|
||||
SeasonCompleteness("Show Name", 3, [5, 7], [6]),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=1,
|
||||
episodes_found=[1, 3], episodes_missing=[2],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=2,
|
||||
episodes_found=[1, 2, 4], episodes_missing=[3],
|
||||
),
|
||||
SeasonCompleteness(
|
||||
series_title="Show Name", season=3,
|
||||
episodes_found=[5, 7], episodes_missing=[6],
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Should group all seasons under same series
|
||||
assert report.count("Show Name") == 1 # Series title appears once
|
||||
assert "Season 01:" in report
|
||||
@@ -366,36 +386,23 @@ class TestCompletenessReport:
|
||||
|
||||
class TestDuplicateReport:
|
||||
"""Test duplicate report generation."""
|
||||
|
||||
|
||||
def test_generate_text_report_with_duplicates(self):
|
||||
"""Test generating text format duplicate report."""
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080",
|
||||
codec="h264", duration_seconds=7200.0,
|
||||
bitrate_kbps=5000),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720",
|
||||
codec="h264"),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{
|
||||
'filename': 'The.Matrix.1999.1080p.mkv',
|
||||
@@ -414,184 +421,175 @@ class TestDuplicateReport:
|
||||
'codec': 'h264'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identities[0], files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Duplicate groups: 1" in report
|
||||
|
||||
|
||||
# Check duplicate group content
|
||||
assert "The Matrix (1999)" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
|
||||
# Check file details
|
||||
assert "The.Matrix.1999.1080p.mkv" in report
|
||||
assert "The.Matrix.1999.720p.mkv" in report
|
||||
assert "1920x1080" in report
|
||||
assert "1280x720" in report
|
||||
assert "h264" in report
|
||||
|
||||
|
||||
def test_generate_json_report_with_duplicates(self):
|
||||
"""Test generating JSON format duplicate report."""
|
||||
identity = MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.1080p.mkv")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _movie("Inception", 2010,
|
||||
original_filename="Inception.2010.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.1080p.mkv"),
|
||||
"Inception.2010.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.720p.mkv"),
|
||||
"Inception.2010.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("Inception.2010.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Inception.2010.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{'filename': 'Inception.2010.1080p.mkv', 'path': '/movies/Inception.2010.1080p.mkv', 'size_bytes': 2000000000},
|
||||
{'filename': 'Inception.2010.720p.mkv', 'path': '/movies/Inception.2010.720p.mkv', 'size_bytes': 1000000000}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
|
||||
|
||||
# Check duplicates
|
||||
assert "duplicates" in data
|
||||
assert len(data["duplicates"]) == 1
|
||||
|
||||
|
||||
dup = data["duplicates"][0]
|
||||
assert dup["identity"]["type"] == "movie"
|
||||
assert dup["identity"]["title"] == "Inception"
|
||||
assert dup["identity"]["year"] == 2010
|
||||
assert dup["file_count"] == 2
|
||||
assert len(dup["files"]) == 2
|
||||
|
||||
|
||||
def test_generate_text_report_series_duplicates(self):
|
||||
"""Test generating text report with series duplicates."""
|
||||
identity = SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
identity = _series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(timezone.utc),
|
||||
"series"
|
||||
),
|
||||
_video("Breaking.Bad.S01E01.1080p.mkv", 1_500_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Breaking.Bad.S01E01.720p.mkv", 800_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
quality_comparison = [
|
||||
{'filename': 'Breaking.Bad.S01E01.1080p.mkv', 'path': '/series/Breaking.Bad.S01E01.1080p.mkv', 'size_bytes': 1500000000},
|
||||
{'filename': 'Breaking.Bad.S01E01.720p.mkv', 'path': '/series/Breaking.Bad.S01E01.720p.mkv', 'size_bytes': 800000000}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
DuplicateGroup(identity=identity, files=files, quality_comparison=quality_comparison)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Check series format
|
||||
assert "Breaking Bad - S01E1" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "No duplicate files detected." in report
|
||||
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["duplicate_groups"] == 0
|
||||
assert len(data["duplicates"]) == 0
|
||||
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_duplicate_report(duplicates, "csv", library_root)
|
||||
|
||||
|
||||
def test_sorted_by_file_size(self):
|
||||
"""Test that duplicate groups are sorted by largest file size."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Create two duplicate groups with different sizes
|
||||
identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv")
|
||||
identity1 = _movie("Small Movie", 2020,
|
||||
original_filename="Small.Movie.mkv")
|
||||
files1 = [
|
||||
VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Small.Movie.1.mkv", 500_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Small.Movie.2.mkv", 600_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000},
|
||||
{'filename': 'Small.Movie.2.mkv', 'path': '/movies/Small.Movie.2.mkv', 'size_bytes': 600000000}
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv")
|
||||
|
||||
identity2 = _movie("Large Movie", 2021,
|
||||
original_filename="Large.Movie.mkv")
|
||||
files2 = [
|
||||
VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Large.Movie.1.mkv", 2_000_000_000,
|
||||
modified_timestamp=now),
|
||||
_video("Large.Movie.2.mkv", 1_800_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000},
|
||||
{'filename': 'Large.Movie.2.mkv', 'path': '/movies/Large.Movie.2.mkv', 'size_bytes': 1800000000}
|
||||
]
|
||||
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2)
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2)
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Large Movie should appear before Small Movie
|
||||
large_pos = report.find("Large Movie")
|
||||
small_pos = report.find("Small Movie")
|
||||
@@ -599,20 +597,21 @@ class TestDuplicateReport:
|
||||
|
||||
def test_sorted_by_quality_size_when_file_sizes_missing(self):
|
||||
"""Sort order should use quality_comparison sizes when VideoFile sizes are zero."""
|
||||
identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv")
|
||||
now = datetime.now(timezone.utc)
|
||||
identity1 = _movie("Tiny", 2020, original_filename="Tiny.mkv")
|
||||
files1 = [
|
||||
VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
_video("Tiny.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Tiny.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality1 = [
|
||||
{"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000},
|
||||
{"filename": "Tiny.2.mkv", "path": "/movies/Tiny.2.mkv", "size_bytes": 500000000},
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv")
|
||||
identity2 = _movie("Huge", 2021, original_filename="Huge.mkv")
|
||||
files2 = [
|
||||
VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(timezone.utc), "movie"),
|
||||
_video("Huge.1.mkv", 0, modified_timestamp=now),
|
||||
_video("Huge.2.mkv", 0, modified_timestamp=now),
|
||||
]
|
||||
quality2 = [
|
||||
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
|
||||
@@ -620,8 +619,8 @@ class TestDuplicateReport:
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2),
|
||||
DuplicateGroup(identity=identity1, files=files1, quality_comparison=quality1),
|
||||
DuplicateGroup(identity=identity2, files=files2, quality_comparison=quality2),
|
||||
]
|
||||
report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos"))
|
||||
assert report.find("Huge") < report.find("Tiny")
|
||||
@@ -629,63 +628,73 @@ class TestDuplicateReport:
|
||||
|
||||
class TestSummaryReport:
|
||||
"""Test summary report generation."""
|
||||
|
||||
|
||||
def test_generate_summary_report(self):
|
||||
"""Test generating summary report with various files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
|
||||
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "other"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=now),
|
||||
_video("Random.mkv", 500_000_000, "other",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Check report structure
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
|
||||
|
||||
# Check totals
|
||||
assert "Total Files: 6" in report
|
||||
assert "Total Size:" in report
|
||||
|
||||
|
||||
# Check category breakdown
|
||||
assert "Category Breakdown:" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Other:" in report
|
||||
|
||||
|
||||
# Check category counts
|
||||
assert "Files: 2" in report # Movies
|
||||
|
||||
|
||||
def test_generate_summary_report_empty(self):
|
||||
"""Test generating summary report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Total Files: 0" in report
|
||||
assert "Total Size: 0.00 B" in report
|
||||
|
||||
|
||||
def test_generate_summary_report_single_category(self):
|
||||
"""Test generating summary report with files in single category."""
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
_video("Movie1.mkv", 1_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
assert "Total Files: 2" in report
|
||||
assert "Movie:" in report
|
||||
assert "Files: 2" in report
|
||||
@@ -693,64 +702,64 @@ class TestSummaryReport:
|
||||
|
||||
class TestFormatHelpers:
|
||||
"""Test formatting helper functions."""
|
||||
|
||||
|
||||
def test_format_episode_list_single(self):
|
||||
"""Test formatting single episode."""
|
||||
assert _format_episode_list([5]) == "5"
|
||||
|
||||
|
||||
def test_format_episode_list_range(self):
|
||||
"""Test formatting consecutive episode range."""
|
||||
assert _format_episode_list([1, 2, 3, 4, 5]) == "1-5"
|
||||
|
||||
|
||||
def test_format_episode_list_mixed(self):
|
||||
"""Test formatting mixed ranges and singles."""
|
||||
assert _format_episode_list([1, 2, 3, 5, 6, 8]) == "1-3, 5-6, 8"
|
||||
|
||||
|
||||
def test_format_episode_list_non_sequential(self):
|
||||
"""Test formatting non-sequential episodes."""
|
||||
assert _format_episode_list([1, 3, 5, 7]) == "1, 3, 5, 7"
|
||||
|
||||
|
||||
def test_format_episode_list_empty(self):
|
||||
"""Test formatting empty episode list."""
|
||||
assert _format_episode_list([]) == "none"
|
||||
|
||||
|
||||
def test_format_episode_list_unsorted(self):
|
||||
"""Test formatting unsorted episode list."""
|
||||
assert _format_episode_list([5, 1, 3, 2, 4]) == "1-5"
|
||||
|
||||
|
||||
def test_format_size_bytes(self):
|
||||
"""Test formatting bytes."""
|
||||
assert _format_size(512) == "512.00 B"
|
||||
|
||||
|
||||
def test_format_size_kilobytes(self):
|
||||
"""Test formatting kilobytes."""
|
||||
assert _format_size(1024) == "1.00 KB"
|
||||
assert _format_size(2048) == "2.00 KB"
|
||||
|
||||
|
||||
def test_format_size_megabytes(self):
|
||||
"""Test formatting megabytes."""
|
||||
assert _format_size(1048576) == "1.00 MB"
|
||||
assert _format_size(5242880) == "5.00 MB"
|
||||
|
||||
|
||||
def test_format_size_gigabytes(self):
|
||||
"""Test formatting gigabytes."""
|
||||
assert _format_size(1073741824) == "1.00 GB"
|
||||
assert _format_size(2147483648) == "2.00 GB"
|
||||
|
||||
|
||||
def test_format_size_terabytes(self):
|
||||
"""Test formatting terabytes."""
|
||||
assert _format_size(1099511627776) == "1.00 TB"
|
||||
|
||||
|
||||
def test_format_duration_seconds(self):
|
||||
"""Test formatting seconds only."""
|
||||
assert _format_duration(30) == "30s"
|
||||
assert _format_duration(0) == "0s"
|
||||
|
||||
|
||||
def test_format_duration_minutes(self):
|
||||
"""Test formatting minutes and seconds."""
|
||||
assert _format_duration(90) == "1m 30s"
|
||||
assert _format_duration(120) == "2m"
|
||||
|
||||
|
||||
def test_format_duration_hours(self):
|
||||
"""Test formatting hours, minutes, and seconds."""
|
||||
assert _format_duration(3665) == "1h 1m 5s"
|
||||
|
||||
@@ -16,134 +16,164 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def _movie(title="Movie", year=2020, **kw):
|
||||
return MovieIdentity(
|
||||
title=title, year=year, confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _series(title="Show", season=1, episodes=None, **kw):
|
||||
if episodes is None:
|
||||
episodes = [1]
|
||||
return SeriesIdentity(
|
||||
title=title, season=season, episodes=episodes,
|
||||
confidence=kw.pop("confidence", 0.9),
|
||||
needs_review=kw.pop("needs_review", False),
|
||||
original_filename=kw.pop(
|
||||
"original_filename",
|
||||
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
|
||||
if season is not None
|
||||
else f"{title.replace(' ', '.')}.E01.mkv",
|
||||
),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _video(filename="file.mkv", size=1000, category="movie", **kw):
|
||||
return VideoFile(
|
||||
path=kw.pop("path", Path(f"/tmp/{filename}")),
|
||||
filename=filename, size_bytes=size,
|
||||
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
|
||||
category=category, **kw,
|
||||
)
|
||||
|
||||
|
||||
class TestReportsIntegration:
|
||||
"""Test report generation integrated with analysis engine."""
|
||||
|
||||
|
||||
def test_completeness_workflow(self):
|
||||
"""Test complete workflow from series analysis to completeness report."""
|
||||
# Create test episodes with gaps
|
||||
episodes = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [4], 0.9, False, "Breaking.Bad.S01E04.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [1], 0.9, False, "The.Wire.S01E01.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [3], 0.9, False, "The.Wire.S01E03.mkv"),
|
||||
_series("Breaking Bad", episodes=[1],
|
||||
original_filename="Breaking.Bad.S01E01.mkv"),
|
||||
_series("Breaking Bad", episodes=[2],
|
||||
original_filename="Breaking.Bad.S01E02.mkv"),
|
||||
_series("Breaking Bad", episodes=[4],
|
||||
original_filename="Breaking.Bad.S01E04.mkv"),
|
||||
_series("The Wire", episodes=[1],
|
||||
original_filename="The.Wire.S01E01.mkv"),
|
||||
_series("The Wire", episodes=[3],
|
||||
original_filename="The.Wire.S01E03.mkv"),
|
||||
]
|
||||
|
||||
|
||||
# Analyze completeness
|
||||
analysis = analyze_series_completeness(episodes)
|
||||
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Breaking Bad" in text_report
|
||||
assert "The Wire" in text_report
|
||||
assert "Episodes missing:" in text_report
|
||||
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_completeness_report(analysis, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
|
||||
def test_duplicate_workflow(self):
|
||||
"""Test complete workflow from duplicate detection to duplicate report."""
|
||||
# Create test identities and files
|
||||
now = datetime.now(timezone.utc)
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.1080p.mkv"),
|
||||
_movie("The Matrix", 1999,
|
||||
original_filename="The.Matrix.1999.720p.mkv"),
|
||||
_movie("Inception", 2010),
|
||||
]
|
||||
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(timezone.utc),
|
||||
"movie"
|
||||
),
|
||||
_video("The.Matrix.1999.1080p.mkv", 2_000_000_000,
|
||||
modified_timestamp=now, resolution="1920x1080", codec="h264"),
|
||||
_video("The.Matrix.1999.720p.mkv", 1_000_000_000,
|
||||
modified_timestamp=now, resolution="1280x720", codec="h264"),
|
||||
_video("Inception.2010.mkv", 1_500_000_000,
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "The Matrix (1999)" in text_report
|
||||
assert "1920x1080" in text_report
|
||||
assert "1280x720" in text_report
|
||||
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert len(data["duplicates"]) == 1
|
||||
assert data["duplicates"][0]["file_count"] == 2
|
||||
|
||||
|
||||
def test_summary_workflow(self):
|
||||
"""Test summary report generation with mixed file types."""
|
||||
# Create test files
|
||||
now = datetime.now(timezone.utc)
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"),
|
||||
_video("Movie1.mkv", 2_000_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Movie2.mkv", 1_500_000_000, "movie",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E01.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Show.S01E02.mkv", 1_000_000_000, "series",
|
||||
modified_timestamp=now),
|
||||
_video("Anime1.mkv", 800_000_000, "anime",
|
||||
modified_timestamp=now),
|
||||
]
|
||||
|
||||
|
||||
# Generate summary report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Total Files: 5" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Category Breakdown:" in report
|
||||
|
||||
|
||||
def test_all_reports_include_metadata(self):
|
||||
"""Test that all reports include generation timestamp and library root."""
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
|
||||
# Test completeness report
|
||||
completeness_report = generate_completeness_report([], "text", library_root)
|
||||
assert "Generated:" in completeness_report
|
||||
assert str(library_root) in completeness_report
|
||||
|
||||
|
||||
# Test duplicate report
|
||||
duplicate_report = generate_duplicate_report([], "text", library_root)
|
||||
assert "Generated:" in duplicate_report
|
||||
assert str(library_root) in duplicate_report
|
||||
|
||||
|
||||
# Test summary report
|
||||
summary_report = generate_summary_report([], library_root)
|
||||
assert "Generated:" in summary_report
|
||||
|
||||
+8
-78
@@ -110,8 +110,8 @@ class TestScanLibrary:
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"video.mp4", "video.mkv"}
|
||||
|
||||
def test_scan_uses_find_output_and_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from find output."""
|
||||
def test_scan_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from discovery."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
@@ -122,82 +122,12 @@ class TestScanLibrary:
|
||||
visible_file.touch()
|
||||
hidden_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"")
|
||||
process.returncode = 0
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_keeps_partial_find_results_when_find_exits_nonzero(self, tmp_path):
|
||||
"""Non-zero find exits should keep partial stdout and log the contract."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
visible_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
assert any("using 1 partial scan result" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
def test_scan_returns_empty_when_find_exits_nonzero_without_stdout(self, tmp_path):
|
||||
"""Non-zero find exits without stdout should produce an empty result deterministically."""
|
||||
(tmp_path / "movie").mkdir()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen, patch("vlm.scanner.logger.warning") as mock_warning:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (b"", b"Permission denied")
|
||||
process.returncode = 1
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config, include_video_metadata=False)
|
||||
|
||||
warning_messages = [
|
||||
call.args[0] % call.args[1:] if len(call.args) > 1 else call.args[0]
|
||||
for call in mock_warning.call_args_list
|
||||
]
|
||||
assert result == []
|
||||
assert any("produced no scan results" in message for message in warning_messages)
|
||||
assert any("Permission denied" in message for message in warning_messages)
|
||||
|
||||
def test_scan_falls_back_when_find_is_unavailable(self, tmp_path):
|
||||
"""Test scan_library falls back to recursive scanning if find is unavailable."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "fallback.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.Popen', side_effect=FileNotFoundError):
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == video_file
|
||||
|
||||
def test_scan_records_metadata(self, tmp_path):
|
||||
"""Test scanning records file metadata correctly."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
@@ -356,7 +286,7 @@ class TestScanLibrary:
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=_fake_create,
|
||||
):
|
||||
), patch("shutil.which", return_value="/usr/bin/ffprobe"):
|
||||
result = scan_library(tmp_path, config, include_video_metadata=True)
|
||||
|
||||
assert len(result) == len(fake_paths)
|
||||
@@ -736,13 +666,13 @@ class TestExtractMetadata:
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
with patch('shutil.which', return_value='/usr/bin/ffprobe'), patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
@@ -823,7 +753,7 @@ class TestExtractMetadata:
|
||||
metadata_cache = {str(vf.path): vf for vf in cached_entries}
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("shutil.which", return_value="/usr/bin/ffprobe"), patch("subprocess.run") as mock_run:
|
||||
result = scan_library(tmp_path, config, metadata_cache=metadata_cache)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
Reference in New Issue
Block a user