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
+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"
|
||||
|
||||
Reference in New Issue
Block a user