Implement duplicate resolution strategy by quality and enhance related documentation
- Updated `duplicate_resolve.py` to introduce a new strategy for keeping files based on quality, considering resolution, source, codec, and size. - Enhanced `planner.py` to utilize the new quality-based strategy during plan generation, updating quarantine reasons accordingly. - Modified `README.md` to document the new `plan.duplicate_keep` options, including `by_quality`, and provided detailed descriptions of each strategy. - Added unit tests in `test_duplicate_resolve.py` to validate the new quality-based resolution logic. - Updated `analysis.json` and `plan.json` with new timestamps and IDs to reflect recent changes. These updates improve the Video Library Manager's ability to handle duplicate files more effectively, ensuring users retain the highest quality versions.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Unit tests for duplicate resolution strategies."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.models import MovieIdentity
|
||||
from vlm.duplicate_resolve import choose_keep_index
|
||||
|
||||
|
||||
def _mi(title: str = "Test", year: int | None = 2020) -> MovieIdentity:
|
||||
return MovieIdentity(
|
||||
title=title,
|
||||
year=year,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="test.mkv",
|
||||
)
|
||||
|
||||
|
||||
class TestByQuality:
|
||||
"""Tests for by_quality strategy."""
|
||||
|
||||
def test_by_quality_prefers_higher_resolution(self):
|
||||
"""1080p vs 720p -> keep 1080p."""
|
||||
p1 = Path("/movies/Test.2020.720p.BluRay.mkv")
|
||||
p2 = Path("/movies/Test.2020.1080p.BluRay.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "resolution": "1280x720", "size_bytes": 1000000},
|
||||
{"path": str(p2), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 1 # 1080p
|
||||
|
||||
def test_by_quality_prefers_bluray_over_webdl(self):
|
||||
"""Same resolution: BluRay > WEB-DL."""
|
||||
p1 = Path("/movies/Test.2020.1080p.WEB-DL.mkv")
|
||||
p2 = Path("/movies/Test.2020.1080p.BluRay.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
{"path": str(p2), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 1 # BluRay
|
||||
|
||||
def test_by_quality_prefers_hevc_over_h264(self):
|
||||
"""Same resolution/source: x265 > x264."""
|
||||
p1 = Path("/movies/Test.2020.1080p.BluRay.x264.mkv")
|
||||
p2 = Path("/movies/Test.2020.1080p.BluRay.x265.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "resolution": "1920x1080", "codec": "h264", "size_bytes": 1500000},
|
||||
{"path": str(p2), "resolution": "1920x1080", "codec": "hevc", "size_bytes": 1200000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 1 # x265
|
||||
|
||||
def test_by_quality_uses_size_as_tiebreaker(self):
|
||||
"""Same resolution/source/codec: larger file wins."""
|
||||
p1 = Path("/movies/Test.2020.1080p.BluRay.x264.mkv")
|
||||
p2 = Path("/movies/Test.2020.1080p.BluRay.x264.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "resolution": "1920x1080", "size_bytes": 1000000},
|
||||
{"path": str(p2), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 1 # larger
|
||||
|
||||
def test_by_quality_fallback_to_filename(self):
|
||||
"""No resolution/codec in qc -> parse from filename."""
|
||||
p1 = Path("/movies/Test.2020.720p.WEB-DL.mkv")
|
||||
p2 = Path("/movies/Test.2020.2160p.BluRay.x265.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "size_bytes": 0},
|
||||
{"path": str(p2), "size_bytes": 0},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 1 # 2160p from filename
|
||||
|
||||
def test_by_quality_returns_first_when_all_equal(self):
|
||||
"""All equal -> keep first (index 0)."""
|
||||
p1 = Path("/movies/Test.2020.1080p.BluRay.mkv")
|
||||
p2 = Path("/movies/Test.2020.1080p.BluRay.mkv")
|
||||
items = [(p1, _mi()), (p2, _mi())]
|
||||
qc = [
|
||||
{"path": str(p1), "resolution": "1920x1080", "size_bytes": 1000000},
|
||||
{"path": str(p2), "resolution": "1920x1080", "size_bytes": 1000000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 0
|
||||
|
||||
|
||||
class TestOtherStrategies:
|
||||
"""Tests for by_reputation, first_seen, manual."""
|
||||
|
||||
def test_manual_returns_none(self):
|
||||
items = [(Path("/a.mkv"), _mi()), (Path("/b.mkv"), _mi())]
|
||||
assert choose_keep_index(items, "manual") is None
|
||||
|
||||
def test_first_seen_returns_zero(self):
|
||||
items = [(Path("/a.mkv"), _mi()), (Path("/b.mkv"), _mi())]
|
||||
assert choose_keep_index(items, "first_seen") == 0
|
||||
|
||||
def test_by_reputation_prefers_higher_score(self):
|
||||
mi_low = _mi()
|
||||
mi_low.reputation_score = 6.0
|
||||
mi_low.reputation_votes = 100
|
||||
mi_high = _mi()
|
||||
mi_high.reputation_score = 8.5
|
||||
mi_high.reputation_votes = 500
|
||||
items = [
|
||||
(Path("/a.mkv"), mi_low),
|
||||
(Path("/b.mkv"), mi_high),
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation")
|
||||
assert idx == 1
|
||||
@@ -375,6 +375,54 @@ def test_generate_plan_summary(config):
|
||||
assert plan.summary["quarantine"] == 0
|
||||
|
||||
|
||||
def test_generate_plan_with_analysis_by_quality(config):
|
||||
"""With analysis duplicates and by_quality, higher-quality file is kept."""
|
||||
config.duplicate_keep = "by_quality"
|
||||
p_720 = Path("/mnt/nas/videos/movie/Test.2020.720p.WEB-DL.mkv")
|
||||
p_1080 = Path("/mnt/nas/videos/movie/Test.2020.1080p.BluRay.mkv")
|
||||
vf_720 = VideoFile(
|
||||
path=p_720,
|
||||
filename="Test.2020.720p.WEB-DL.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
vf_1080 = VideoFile(
|
||||
path=p_1080,
|
||||
filename="Test.2020.1080p.BluRay.mkv",
|
||||
size_bytes=2000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Test",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Test.2020.mkv",
|
||||
)
|
||||
identities = [(vf_720, identity), (vf_1080, identity)]
|
||||
analysis_data = {
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "Test", "year": 2020},
|
||||
"files": [str(p_720), str(p_1080)],
|
||||
"quality_comparison": [
|
||||
{"path": str(p_720), "resolution": "1280x720", "size_bytes": 1000000},
|
||||
{"path": str(p_1080), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
plan = generate_plan(identities, config, analysis_data=analysis_data)
|
||||
assert plan.summary["quarantine"] == 1
|
||||
assert plan.operations[0].operation_type == "quarantine" # 720p quarantined
|
||||
assert plan.operations[1].operation_type == "move" # 1080p kept
|
||||
assert "画质" in plan.operations[0].reason
|
||||
|
||||
|
||||
def test_generate_plan_with_different_extensions(config):
|
||||
"""Test plan generation preserves file extensions."""
|
||||
extensions = [".mp4", ".mkv", ".avi"]
|
||||
|
||||
Reference in New Issue
Block a user