chore: snapshot current project updates

This commit is contained in:
windyboy
2026-02-13 13:36:39 +08:00
parent 5931f16a71
commit 0be802eac5
74 changed files with 594899 additions and 66082 deletions
+74
View File
@@ -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
+88
View File
@@ -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
View File
@@ -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."""
+11
View File
@@ -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)
+101
View File
@@ -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
+69
View File
@@ -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
+6 -5
View File
@@ -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
View File
@@ -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."""
+103
View File
@@ -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
+237
View File
@@ -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"]
+29
View File
@@ -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."""
+29
View File
@@ -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."""
+43
View File
@@ -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