chore: snapshot current project updates
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Regression tests for CLI JSON error handling."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from vlm.cli import main
|
||||
|
||||
|
||||
def _write_config(path: Path, library_root: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
f"""
|
||||
library_root: {library_root}
|
||||
video_extensions:
|
||||
- .mp4
|
||||
- .mkv
|
||||
templates:
|
||||
movie_dir: "movie/{{title}} ({{year}})/"
|
||||
series_dir: "series/{{title}}/Season {{season:02d}}/"
|
||||
movie_filename: "{{title}} ({{year}}){{ext}}"
|
||||
series_filename: "S{{season:02d}}E{{episode:02d}}{{ext}}"
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_invalid_json_returns_user_friendly_error(tmp_path):
|
||||
config_path = tmp_path / ".vlm" / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
invalid_json = tmp_path / "identities.json"
|
||||
invalid_json.write_text("{ this is invalid json")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"--config",
|
||||
str(config_path),
|
||||
"analyze",
|
||||
"--input",
|
||||
str(invalid_json),
|
||||
"--output",
|
||||
str(tmp_path / "analysis.json"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to parse JSON file" in result.output
|
||||
assert "NameError" not in result.output
|
||||
|
||||
|
||||
def test_plan_invalid_json_returns_user_friendly_error(tmp_path):
|
||||
config_path = tmp_path / ".vlm" / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
invalid_json = tmp_path / "identities.json"
|
||||
invalid_json.write_text("{ this is invalid json")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"--config",
|
||||
str(config_path),
|
||||
"plan",
|
||||
"--input",
|
||||
str(invalid_json),
|
||||
"--output",
|
||||
str(tmp_path / "plan.json"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to parse JSON file" in result.output
|
||||
assert "NameError" not in result.output
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for `vlm review-plan` CLI command."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from vlm.cli import main
|
||||
|
||||
|
||||
def _write_config(path: Path, library_root: Path) -> None:
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"library_root: {library_root}",
|
||||
"video_extensions:",
|
||||
" - .mkv",
|
||||
"templates:",
|
||||
' movie_dir: "movie/{title} ({year})/"',
|
||||
' series_dir: "series/{title}/Season {season:02d}/"',
|
||||
' movie_filename: "{title} ({year}){ext}"',
|
||||
' series_filename: "S{season:02d}E{episode:02d}{ext}"',
|
||||
'quarantine_dir: ".quarantine"',
|
||||
'log_level: "INFO"',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_review_plan_generates_csv_and_summary(tmp_path):
|
||||
"""review-plan should export flagged operations and summary counters."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, tmp_path / "library")
|
||||
|
||||
plan_path = tmp_path / "plan.json"
|
||||
plan_data = {
|
||||
"plan_id": "test-plan",
|
||||
"created_at": "2026-02-13T00:00:00+00:00",
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": "move",
|
||||
"source_path": str(tmp_path / "Show.Sample.S01E01.mkv"),
|
||||
"destination_path": str(tmp_path / "library/series/Show/Season 01/S01E01.mkv"),
|
||||
"reason": "Organize series: Show S01E01",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
},
|
||||
{
|
||||
"operation_type": "no-op",
|
||||
"source_path": str(tmp_path / "Show.S20E50.mkv"),
|
||||
"destination_path": None,
|
||||
"reason": "Series needs manual review (season exceeds configured threshold)",
|
||||
"has_conflict": False,
|
||||
"conflict_reason": None,
|
||||
},
|
||||
],
|
||||
"summary": {"total": 2, "move": 1, "rename": 0, "quarantine": 0, "no-op": 1},
|
||||
"summary_by_reason": {},
|
||||
"human_summary": "",
|
||||
"metadata": {},
|
||||
}
|
||||
plan_path.write_text(json.dumps(plan_data), encoding="utf-8")
|
||||
|
||||
output_csv = tmp_path / "review.csv"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--config", str(config_path),
|
||||
"review-plan",
|
||||
"--input", str(plan_path),
|
||||
"--output", str(output_csv),
|
||||
"--season-threshold", "20",
|
||||
"--episode-threshold", "40",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Plan review summary:" in result.output
|
||||
assert "High-risk operations: 2" in result.output
|
||||
assert output_csv.exists()
|
||||
|
||||
with open(output_csv, "r", encoding="utf-8", newline="") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
assert len(rows) == 2
|
||||
assert any("sample_source" in row["risk_flags"] for row in rows)
|
||||
assert any("manual_review" in row["risk_flags"] for row in rows)
|
||||
+54
-1
@@ -21,6 +21,9 @@ class TestConfig:
|
||||
assert config.log_level == "INFO"
|
||||
assert config.quarantine_dir == ".quarantine"
|
||||
assert config.enrichment_providers == ["tmdb"]
|
||||
assert config.plan_max_season == 15
|
||||
assert config.plan_max_episode == 100
|
||||
assert config.plan_include_sample_files is False
|
||||
|
||||
def test_config_creation_with_custom_values(self):
|
||||
"""Test creating Config with custom values."""
|
||||
@@ -77,7 +80,13 @@ class TestLoadConfig:
|
||||
'series_filename': 'S{season:02d}E{episode:02d}{ext}'
|
||||
},
|
||||
'quarantine_dir': '.quarantine',
|
||||
'log_level': 'DEBUG'
|
||||
'log_level': 'DEBUG',
|
||||
'plan': {
|
||||
'duplicate_keep': 'by_quality',
|
||||
'max_season': 12,
|
||||
'max_episode': 80,
|
||||
'include_sample_files': True,
|
||||
}
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
@@ -91,6 +100,10 @@ class TestLoadConfig:
|
||||
assert config.series_template == 'series/{title}/Season {season:02d}/'
|
||||
assert config.log_level == 'DEBUG'
|
||||
assert config.quarantine_dir == '.quarantine'
|
||||
assert config.duplicate_keep == 'by_quality'
|
||||
assert config.plan_max_season == 12
|
||||
assert config.plan_max_episode == 80
|
||||
assert config.plan_include_sample_files is True
|
||||
|
||||
def test_load_config_with_home_directory(self, tmp_path):
|
||||
"""Test loading config with ~ in library_root."""
|
||||
@@ -107,6 +120,22 @@ class TestLoadConfig:
|
||||
|
||||
# Should expand ~ to home directory
|
||||
assert config.library_root == Path.home() / "Videos"
|
||||
|
||||
def test_load_config_with_reputation_quality_time_strategy(self, tmp_path):
|
||||
"""Test loading config with by_reputation_quality_time strategy."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'plan': {
|
||||
'duplicate_keep': 'by_reputation_quality_time'
|
||||
}
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
assert config.duplicate_keep == 'by_reputation_quality_time'
|
||||
|
||||
def test_load_config_missing_file(self, tmp_path):
|
||||
"""Test loading non-existent configuration file."""
|
||||
@@ -255,6 +284,10 @@ class TestCreateDefaultConfig:
|
||||
assert 'quarantine_dir' in data
|
||||
assert 'enrichment' in data
|
||||
assert 'enrich' in data
|
||||
assert 'plan' in data
|
||||
assert 'max_season' in data['plan']
|
||||
assert 'max_episode' in data['plan']
|
||||
assert 'include_sample_files' in data['plan']
|
||||
|
||||
def test_create_default_config_creates_parent_dirs(self, tmp_path):
|
||||
"""Test that create_default_config creates parent directories."""
|
||||
@@ -399,6 +432,15 @@ class TestValidateConfig:
|
||||
# Should have multiple errors
|
||||
assert len(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"
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
|
||||
def test_validate_empty_categories(self):
|
||||
"""Test validating config with empty categories."""
|
||||
config = Config(library_root=Path("/test"), categories={})
|
||||
@@ -523,6 +565,17 @@ class TestValidateConfig:
|
||||
errors = validate_config(config)
|
||||
assert any("empty directory name" in e for e in errors)
|
||||
|
||||
def test_validate_invalid_plan_thresholds(self):
|
||||
"""Plan season/episode thresholds must be positive integers."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
plan_max_season=0,
|
||||
plan_max_episode=-1,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
"""Integration tests for configuration workflow."""
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Tests for concurrency-related config validation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.config import Config, validate_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)
|
||||
@@ -92,6 +92,18 @@ class TestByQuality:
|
||||
idx = choose_keep_index(items, "by_quality", quality_comparison=qc)
|
||||
assert idx == 0
|
||||
|
||||
def test_by_quality_deprioritizes_sample(self):
|
||||
"""Sample clip should lose to normal release when quality is equal."""
|
||||
p1 = Path("/movies/Test.2020.Sample.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 == 1
|
||||
|
||||
|
||||
class TestOtherStrategies:
|
||||
"""Tests for by_reputation, first_seen, manual."""
|
||||
@@ -117,3 +129,92 @@ class TestOtherStrategies:
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation")
|
||||
assert idx == 1
|
||||
|
||||
def test_by_reputation_deprioritizes_sample(self):
|
||||
"""Sample clip should not be selected even with identical reputation."""
|
||||
a = _mi()
|
||||
a.reputation_score = 8.0
|
||||
a.reputation_votes = 300
|
||||
b = _mi()
|
||||
b.reputation_score = 8.0
|
||||
b.reputation_votes = 300
|
||||
items = [
|
||||
(Path("/a/Test.Sample.mkv"), a),
|
||||
(Path("/a/Test.Final.mkv"), b),
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation")
|
||||
assert idx == 1
|
||||
|
||||
def test_by_reputation_falls_back_to_quality_when_reputation_missing(self):
|
||||
"""When reputation is missing, BluRay should beat WEB-DL."""
|
||||
a = _mi()
|
||||
a.reputation_score = None
|
||||
a.reputation_votes = None
|
||||
b = _mi()
|
||||
b.reputation_score = None
|
||||
b.reputation_votes = None
|
||||
p_web = Path("/a/Test.2020.2160p.WEB-DL.HEVC.mkv")
|
||||
p_bluray = Path("/a/Test.2020.1080p.BluRay.x265.mkv")
|
||||
items = [(p_web, a), (p_bluray, b)]
|
||||
qc = [
|
||||
{"path": str(p_web), "resolution": "3840x2160", "codec": "hevc", "size_bytes": 24000000000},
|
||||
{"path": str(p_bluray), "resolution": "1920x1080", "codec": "hevc", "size_bytes": 8000000000},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation", quality_comparison=qc)
|
||||
assert idx == 1
|
||||
|
||||
def test_by_reputation_quality_time_prefers_quality_after_reputation(self):
|
||||
"""When reputation ties, quality should decide before modified time."""
|
||||
a = _mi()
|
||||
a.reputation_score = 8.0
|
||||
a.reputation_votes = 300
|
||||
b = _mi()
|
||||
b.reputation_score = 8.0
|
||||
b.reputation_votes = 300
|
||||
p1 = Path("/a/Test.2020.720p.WEB-DL.mkv")
|
||||
p2 = Path("/a/Test.2020.1080p.BluRay.mkv")
|
||||
items = [(p1, a), (p2, b)]
|
||||
qc = [
|
||||
{
|
||||
"path": str(p1),
|
||||
"resolution": "1280x720",
|
||||
"size_bytes": 1000000,
|
||||
"modified_timestamp": "2026-02-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"path": str(p2),
|
||||
"resolution": "1920x1080",
|
||||
"size_bytes": 2000000,
|
||||
"modified_timestamp": "2025-01-01T00:00:00+00:00",
|
||||
},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation_quality_time", quality_comparison=qc)
|
||||
assert idx == 1
|
||||
|
||||
def test_by_reputation_quality_time_prefers_newer_when_rep_and_quality_tie(self):
|
||||
"""When reputation and quality tie, newer modified timestamp wins."""
|
||||
a = _mi()
|
||||
a.reputation_score = 8.0
|
||||
a.reputation_votes = 300
|
||||
b = _mi()
|
||||
b.reputation_score = 8.0
|
||||
b.reputation_votes = 300
|
||||
p1 = Path("/a/Test.2020.1080p.BluRay.mkv")
|
||||
p2 = Path("/a/Test.2020.1080p.BluRay.mkv")
|
||||
items = [(p1, a), (p2, b)]
|
||||
qc = [
|
||||
{
|
||||
"path": str(p1),
|
||||
"resolution": "1920x1080",
|
||||
"size_bytes": 2000000,
|
||||
"modified_timestamp": "2025-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"path": str(p2),
|
||||
"resolution": "1920x1080",
|
||||
"size_bytes": 2000000,
|
||||
"modified_timestamp": "2026-01-01T00:00:00+00:00",
|
||||
},
|
||||
]
|
||||
idx = choose_keep_index(items, "by_reputation_quality_time", quality_comparison=qc)
|
||||
assert idx == 1
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Unit tests for enrichment pipeline."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from vlm.config import Config
|
||||
@@ -351,3 +354,69 @@ def test_enrich_auth_failure_stops_immediately(tmp_path, monkeypatch):
|
||||
|
||||
with pytest.raises(RuntimeError, match="authentication failed"):
|
||||
enrich_identities_data(identities, config, refresh_mode="refresh_all")
|
||||
|
||||
|
||||
def test_enrich_respects_max_concurrency_for_uncached_records(tmp_path, monkeypatch):
|
||||
"""Multiple uncached records should be enriched by multiple worker threads."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["dummy"],
|
||||
translation_fallback_machine=False,
|
||||
enrichment_max_concurrency=4,
|
||||
)
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
"movies": [
|
||||
{
|
||||
"path": f"/library/movie/Test.{i}.mkv",
|
||||
"filename": f"Test.{i}.mkv",
|
||||
"category": "movie",
|
||||
"title": f"Test {i}",
|
||||
"year": 2020,
|
||||
"confidence": 0.9,
|
||||
"needs_review": False,
|
||||
}
|
||||
for i in range(8)
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
thread_ids: set[int] = set()
|
||||
lock = threading.Lock()
|
||||
|
||||
def _fake_enrich(record, media_type, config_obj, request_timeout, retries):
|
||||
time.sleep(0.01)
|
||||
with lock:
|
||||
thread_ids.add(threading.get_ident())
|
||||
title = record.get("title", "X")
|
||||
payload = {
|
||||
"canonical_id": f"id:{title}",
|
||||
"title_zh": title,
|
||||
"title_en": title,
|
||||
"translation_source": "dummy",
|
||||
"reputation_score": None,
|
||||
"reputation_votes": None,
|
||||
"reputation_source": None,
|
||||
"provider_metadata": {},
|
||||
"review_status": "pending",
|
||||
"enrichment_confidence": 0.8,
|
||||
"needs_review": False,
|
||||
"display_title": title,
|
||||
"enriched": True,
|
||||
}
|
||||
return payload, 1, [], ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._enrich_record_with_fresh_providers",
|
||||
_fake_enrich,
|
||||
)
|
||||
|
||||
_, stats = enrich_identities_data(identities, config, refresh_mode="refresh_all")
|
||||
|
||||
assert stats["enriched"] == 8
|
||||
assert stats["cache_hits"] == 0
|
||||
assert len(thread_ids) > 1
|
||||
|
||||
@@ -171,8 +171,9 @@ class TestDryRunSimulation:
|
||||
def test_dry_run_logs_operations(self, execution_engine, sample_plan, caplog):
|
||||
"""Test that dry-run mode logs what would happen."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
execution_engine.execute_plan(sample_plan, mode="dry-run")
|
||||
verbose_engine = ExecutionEngine(logger=execution_engine.logger, verbose_operations=True)
|
||||
|
||||
verbose_engine.execute_plan(sample_plan, mode="dry-run")
|
||||
|
||||
# Check that dry-run operations are logged
|
||||
assert "[DRY-RUN]" in caplog.text
|
||||
@@ -242,8 +243,8 @@ class TestDryRunSimulation:
|
||||
assert len(results) == 1
|
||||
assert not results[0].success
|
||||
assert "Conflict" in results[0].error_message
|
||||
assert summary["failed"] == 1
|
||||
assert summary["skipped"] == 1 # Conflicts are also counted as skipped
|
||||
assert summary["failed"] == 0
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
|
||||
class TestExecuteMode:
|
||||
@@ -365,7 +366,7 @@ class TestExecuteMode:
|
||||
# Operation should be skipped
|
||||
assert not results[0].success
|
||||
assert "Conflict" in results[0].error_message
|
||||
assert summary["failed"] == 1
|
||||
assert summary["failed"] == 0
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
# Source file should still exist
|
||||
|
||||
+16
-9
@@ -103,13 +103,13 @@ class TestMovieParser:
|
||||
def test_parse_movie_with_dots_in_title(self):
|
||||
"""Test parsing movie with dots in title."""
|
||||
result = parse_movie("The.Lord.of.the.Rings.2001.mkv")
|
||||
assert result.title == "The Lord Of The Rings"
|
||||
assert result.title == "The Lord of the Rings"
|
||||
assert result.year == 2001
|
||||
|
||||
def test_parse_movie_with_underscores(self):
|
||||
"""Test parsing movie with underscores in title."""
|
||||
result = parse_movie("Star_Wars_Episode_IV (1977).mp4")
|
||||
assert result.title == "Star Wars Episode Iv"
|
||||
assert result.title == "Star Wars Episode IV"
|
||||
assert result.year == 1977
|
||||
|
||||
def test_parse_movie_preserves_original_filename(self):
|
||||
@@ -134,10 +134,10 @@ class TestNormalizeTitle:
|
||||
"""Test that extra whitespace is removed."""
|
||||
assert normalize_title("The Matrix Reloaded") == "The Matrix Reloaded"
|
||||
|
||||
def test_normalize_applies_title_case(self):
|
||||
"""Test that title case is applied."""
|
||||
assert normalize_title("the matrix") == "The Matrix"
|
||||
assert normalize_title("THE MATRIX") == "The Matrix"
|
||||
def test_normalize_preserves_case(self):
|
||||
"""Test that normalization preserves original letter case."""
|
||||
assert normalize_title("the matrix") == "the matrix"
|
||||
assert normalize_title("THE MATRIX") == "THE MATRIX"
|
||||
|
||||
def test_normalize_idempotence(self):
|
||||
"""Test that normalizing multiple times produces same result."""
|
||||
@@ -206,7 +206,7 @@ class TestSeriesParser:
|
||||
def test_parse_series_sxxeyy_lowercase(self):
|
||||
"""Test parsing series with lowercase sxxeyy format."""
|
||||
result = parse_series("Game of Thrones s02e05.mkv")
|
||||
assert result.title == "Game Of Thrones"
|
||||
assert result.title == "Game of Thrones"
|
||||
assert result.season == 2
|
||||
assert result.episodes == [5]
|
||||
assert result.confidence == 0.9
|
||||
@@ -348,6 +348,13 @@ class TestSeriesParser:
|
||||
assert result.episodes == [15]
|
||||
assert result.confidence == 0.9
|
||||
|
||||
def test_parse_series_resolution_not_treated_as_season_episode(self):
|
||||
"""1920x1080 should not be parsed as 20x10."""
|
||||
result = parse_series("Some.Show.1920x1080.BluRay.mkv")
|
||||
assert result.season is None
|
||||
assert result.episodes == []
|
||||
assert result.needs_review is True
|
||||
|
||||
|
||||
class TestEpisodeGrouping:
|
||||
"""Tests for episode grouping functionality."""
|
||||
@@ -369,14 +376,14 @@ class TestEpisodeGrouping:
|
||||
assert len(groups) == 3
|
||||
assert ("Breaking Bad", 1) in groups
|
||||
assert ("Breaking Bad", 2) in groups
|
||||
assert ("Game Of Thrones", 1) in groups
|
||||
assert ("Game of Thrones", 1) in groups
|
||||
|
||||
# Breaking Bad S01 should have 2 episodes
|
||||
assert len(groups[("Breaking Bad", 1)]) == 2
|
||||
# Breaking Bad S02 should have 1 episode
|
||||
assert len(groups[("Breaking Bad", 2)]) == 1
|
||||
# Game of Thrones S01 should have 1 episode
|
||||
assert len(groups[("Game Of Thrones", 1)]) == 1
|
||||
assert len(groups[("Game of Thrones", 1)]) == 1
|
||||
|
||||
def test_group_episodes_excludes_none_season(self):
|
||||
"""Test that episodes with season=None are excluded from grouping."""
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Security tests for path generation and execution boundaries."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.executor import ExecutionEngine
|
||||
from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, VideoFile
|
||||
from vlm.planner import generate_plan
|
||||
|
||||
|
||||
def test_generate_plan_rejects_destination_outside_library_root(tmp_path):
|
||||
config = Config(
|
||||
library_root=tmp_path / "library",
|
||||
movie_template="../../escape/{title}/",
|
||||
)
|
||||
config.library_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source = config.library_root / "movie" / "Test.mkv"
|
||||
source.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.write_text("x")
|
||||
|
||||
vf = VideoFile(
|
||||
path=source,
|
||||
filename=source.name,
|
||||
size_bytes=1,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="Test",
|
||||
year=2020,
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename=source.name,
|
||||
)
|
||||
|
||||
plan = generate_plan([(vf, identity)], config)
|
||||
op = plan.operations[0]
|
||||
assert op.operation_type == "no-op"
|
||||
assert "Unsafe destination outside library root" in op.reason
|
||||
|
||||
|
||||
def test_generate_plan_sanitizes_title_components(tmp_path):
|
||||
config = Config(library_root=tmp_path / "library")
|
||||
config.library_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source = config.library_root / "movie" / "Raw.mkv"
|
||||
source.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.write_text("x")
|
||||
|
||||
vf = VideoFile(
|
||||
path=source,
|
||||
filename=source.name,
|
||||
size_bytes=1,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="A/../../B\\C",
|
||||
year=2020,
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename=source.name,
|
||||
)
|
||||
|
||||
plan = generate_plan([(vf, identity)], config)
|
||||
op = plan.operations[0]
|
||||
assert op.destination_path is not None
|
||||
assert ".." not in str(op.destination_path)
|
||||
assert op.destination_path.is_absolute()
|
||||
assert str(config.library_root.resolve()) in str(op.destination_path.resolve())
|
||||
|
||||
|
||||
def test_executor_blocks_unsafe_destination_even_with_manual_plan(tmp_path):
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir(parents=True, exist_ok=True)
|
||||
source = library_root / "movie" / "Sample.mkv"
|
||||
source.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.write_text("sample")
|
||||
|
||||
operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source,
|
||||
destination_path=tmp_path / "outside" / "Sample.mkv",
|
||||
reason="unsafe test",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
operations=[operation],
|
||||
summary={"total": 1, "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
|
||||
)
|
||||
|
||||
engine = ExecutionEngine(config=Config(library_root=library_root))
|
||||
results, summary, _ = engine.execute_plan(plan, mode="execute", confirmed=True)
|
||||
|
||||
assert not results[0].success
|
||||
assert "outside library root" in (results[0].error_message or "")
|
||||
assert summary["failed"] == 1
|
||||
@@ -117,6 +117,102 @@ def test_generate_plan_for_series_with_season_and_episode(config):
|
||||
assert not operation.has_conflict
|
||||
|
||||
|
||||
def test_generate_plan_blocks_series_with_high_season(config):
|
||||
"""Series season above threshold should be no-op for manual review."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.Name.S20E01.mkv"),
|
||||
filename="Show.Name.S20E01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=20,
|
||||
episodes=[1],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.S20E01.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert "season exceeds configured threshold" in operation.reason
|
||||
|
||||
|
||||
def test_generate_plan_blocks_series_with_high_episode(config):
|
||||
"""Series episode above threshold should be no-op for manual review."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.Name.S01E120.mkv"),
|
||||
filename="Show.Name.S01E120.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=1,
|
||||
episodes=[120],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.S01E120.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert "episode exceeds configured threshold" in operation.reason
|
||||
|
||||
|
||||
def test_generate_plan_sample_is_noop_by_default(config):
|
||||
"""Sample files should be excluded from organize actions by default."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"),
|
||||
filename="Show.Name.Sample.S01E01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=1,
|
||||
episodes=[1],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.Sample.S01E01.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert "Sample file excluded" in operation.reason
|
||||
|
||||
|
||||
def test_generate_plan_sample_can_be_included_via_config(config):
|
||||
"""When include_sample_files is enabled, sample files can be organized."""
|
||||
config.plan_include_sample_files = True
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.Name.Sample.S01E01.mkv"),
|
||||
filename="Show.Name.Sample.S01E01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=1,
|
||||
episodes=[1],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.Sample.S01E01.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "move"
|
||||
|
||||
|
||||
def test_generate_plan_for_series_without_season(config):
|
||||
"""Test plan generation for a series without season (needs review)."""
|
||||
video_file = VideoFile(
|
||||
@@ -423,6 +519,147 @@ def test_generate_plan_with_analysis_by_quality(config):
|
||||
assert "画质" in plan.operations[0].reason
|
||||
|
||||
|
||||
def test_generate_plan_with_analysis_by_reputation_quality_time_reason(config):
|
||||
"""New strategy should quarantine duplicates with explicit reason text."""
|
||||
config.duplicate_keep = "by_reputation_quality_time"
|
||||
p_old = Path("/mnt/nas/videos/movie/Test.2020.720p.WEB-DL.mkv")
|
||||
p_new = Path("/mnt/nas/videos/movie/Test.2020.1080p.BluRay.mkv")
|
||||
vf_old = VideoFile(
|
||||
path=p_old,
|
||||
filename="Test.2020.720p.WEB-DL.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
vf_new = VideoFile(
|
||||
path=p_new,
|
||||
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_old, identity), (vf_new, identity)]
|
||||
analysis_data = {
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "Test", "year": 2020},
|
||||
"files": [str(p_old), str(p_new)],
|
||||
"quality_comparison": [
|
||||
{"path": str(p_old), "resolution": "1280x720", "size_bytes": 1000000},
|
||||
{"path": str(p_new), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
plan = generate_plan(identities, config, analysis_data=analysis_data)
|
||||
assert plan.summary["quarantine"] == 1
|
||||
assert "外部评分>画质>时间" in plan.operations[0].reason
|
||||
|
||||
|
||||
def test_generate_plan_with_analysis_by_reputation_missing_scores_uses_fallback_reason(config):
|
||||
"""by_reputation should clearly state quality fallback when reputation is missing."""
|
||||
config.duplicate_keep = "by_reputation"
|
||||
p_web = Path("/mnt/nas/videos/movie/Test.2020.2160p.WEB-DL.mkv")
|
||||
p_bluray = Path("/mnt/nas/videos/movie/Test.2020.1080p.BluRay.mkv")
|
||||
vf_web = VideoFile(
|
||||
path=p_web,
|
||||
filename="Test.2020.2160p.WEB-DL.mkv",
|
||||
size_bytes=3000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
vf_bluray = VideoFile(
|
||||
path=p_bluray,
|
||||
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_web, identity), (vf_bluray, identity)]
|
||||
analysis_data = {
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "Test", "year": 2020},
|
||||
"files": [str(p_web), str(p_bluray)],
|
||||
"quality_comparison": [
|
||||
{"path": str(p_web), "resolution": "3840x2160", "size_bytes": 3000000},
|
||||
{"path": str(p_bluray), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
plan = generate_plan(identities, config, analysis_data=analysis_data)
|
||||
assert plan.summary["quarantine"] == 1
|
||||
assert "评分缺失/并列" in plan.operations[0].reason
|
||||
assert "删除建议(仅隔离建议" in plan.human_summary
|
||||
assert "评分依据不足" in plan.human_summary
|
||||
|
||||
|
||||
def test_generate_plan_human_summary_marks_disc_files_as_high_risk(config):
|
||||
"""Disc/part files should be listed with a conservative risk note in summary."""
|
||||
config.duplicate_keep = "by_reputation"
|
||||
p_disc1 = Path("/mnt/nas/videos/movie/The.Best.of.Youth.DISC1.mkv")
|
||||
p_disc2 = Path("/mnt/nas/videos/movie/The.Best.of.Youth.DISC2.mkv")
|
||||
vf1 = VideoFile(
|
||||
path=p_disc1,
|
||||
filename="The.Best.of.Youth.DISC1.mkv",
|
||||
size_bytes=2000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
vf2 = VideoFile(
|
||||
path=p_disc2,
|
||||
filename="The.Best.of.Youth.DISC2.mkv",
|
||||
size_bytes=1800000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie",
|
||||
)
|
||||
identity = MovieIdentity(
|
||||
title="The Best of Youth",
|
||||
year=2003,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="The.Best.of.Youth.DISC1.mkv",
|
||||
)
|
||||
plan = generate_plan(
|
||||
[(vf1, identity), (vf2, identity)],
|
||||
config,
|
||||
analysis_data={
|
||||
"metadata": {"source_identities": "identities.json"},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {"type": "movie", "title": "The Best of Youth", "year": 2003},
|
||||
"files": [str(p_disc1), str(p_disc2)],
|
||||
"quality_comparison": [
|
||||
{"path": str(p_disc1), "resolution": "1920x1080", "size_bytes": 2000000},
|
||||
{"path": str(p_disc2), "resolution": "1920x1080", "size_bytes": 1800000},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert "疑似多碟/分段文件" in plan.human_summary
|
||||
|
||||
|
||||
def test_generate_plan_with_different_extensions(config):
|
||||
"""Test plan generation preserves file extensions."""
|
||||
extensions = [".mp4", ".mkv", ".avi"]
|
||||
|
||||
@@ -596,6 +596,35 @@ class TestDuplicateReport:
|
||||
small_pos = report.find("Small Movie")
|
||||
assert large_pos < small_pos
|
||||
|
||||
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")
|
||||
files1 = [
|
||||
VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(), "movie"),
|
||||
]
|
||||
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")
|
||||
files2 = [
|
||||
VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(), "movie"),
|
||||
]
|
||||
quality2 = [
|
||||
{"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000},
|
||||
{"filename": "Huge.2.mkv", "path": "/movies/Huge.2.mkv", "size_bytes": 2800000000},
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2),
|
||||
]
|
||||
report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos"))
|
||||
assert report.find("Huge") < report.find("Tiny")
|
||||
|
||||
|
||||
class TestSummaryReport:
|
||||
"""Test summary report generation."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for the inventory scanner module."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
import csv
|
||||
@@ -286,6 +287,34 @@ class TestScanLibrary:
|
||||
assert result == []
|
||||
assert progress_events == [(0, 0)]
|
||||
|
||||
def test_scan_metadata_parallelism_uses_multiple_threads(self, tmp_path):
|
||||
"""Metadata scan should use worker threads when concurrency > 1."""
|
||||
config = Config(library_root=tmp_path, enrichment_max_concurrency=4)
|
||||
fake_paths = [tmp_path / f"f{i}.mp4" for i in range(8)]
|
||||
thread_ids: set[int] = set()
|
||||
lock = threading.Lock()
|
||||
|
||||
def _fake_create(file_path, library_root, categories_config, include_video_metadata=True, metadata_cache=None):
|
||||
time.sleep(0.01)
|
||||
with lock:
|
||||
thread_ids.add(threading.get_ident())
|
||||
return VideoFile(
|
||||
path=file_path,
|
||||
filename=file_path.name,
|
||||
size_bytes=1,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=_fake_create,
|
||||
):
|
||||
result = scan_library(tmp_path, config, include_video_metadata=True)
|
||||
|
||||
assert len(result) == len(fake_paths)
|
||||
assert len(thread_ids) > 1
|
||||
|
||||
|
||||
class TestCategorizeFile:
|
||||
"""Tests for the categorize_file function."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for State Store operations."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -387,3 +388,45 @@ class TestStateManager:
|
||||
results = manager.query_by_status(status)
|
||||
expected_count = sum(1 for _, s in files if s == status)
|
||||
assert len(results) == expected_count
|
||||
|
||||
def test_set_file_state_uses_canonical_key_for_symlink_paths(self, tmp_path):
|
||||
"""Setting state through symlink and real path should deduplicate keys."""
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
real_file = real_dir / "movie.mkv"
|
||||
real_file.write_text("x")
|
||||
|
||||
link_dir = tmp_path / "link"
|
||||
os.symlink(real_dir, link_dir)
|
||||
symlink_file = link_dir / "movie.mkv"
|
||||
|
||||
manager = StateManager(tmp_path / "state.json")
|
||||
manager.set_file_state(symlink_file, "reviewed", "via symlink")
|
||||
manager.set_file_state(real_file, "ignored", "via real path")
|
||||
|
||||
assert len(manager.store.states) == 1
|
||||
state = manager.get_file_state(real_file)
|
||||
assert state is not None
|
||||
assert state.status == "ignored"
|
||||
|
||||
|
||||
def test_save_state_uses_atomic_replace(tmp_path, monkeypatch):
|
||||
"""save_state should atomically replace the destination file."""
|
||||
state_path = tmp_path / "state.json"
|
||||
replaced = {"called": False}
|
||||
|
||||
original_replace = os.replace
|
||||
|
||||
def _replace(src, dst):
|
||||
replaced["called"] = True
|
||||
return original_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr("vlm.state.os.replace", _replace)
|
||||
|
||||
manager = StateManager(state_path)
|
||||
manager.set_file_state(Path("/videos/movie.mp4"), "reviewed", "atomic")
|
||||
manager.save()
|
||||
|
||||
assert replaced["called"] is True
|
||||
loaded = load_state(state_path)
|
||||
assert len(loaded.states) == 1
|
||||
|
||||
Reference in New Issue
Block a user