Enhance project structure and add new files for enrichment and analysis
- Updated AGENTS.md to reflect changes in CLI commands and module organization, including the addition of an enrichment step and new functional modules. - Introduced analysis.json, identities.json, inventory.csv, and plan.json to support enriched metadata and execution planning. - Added CODE_IMPROVEMENTS.md to document identified code issues and proposed solutions for future enhancements. - Updated README.md to include new enrichment features and configuration options. - Removed unused dependency on ffmpeg-python from pyproject.toml. These changes improve the overall functionality and maintainability of the Video Library Manager project.
This commit is contained in:
+10
-10
@@ -216,7 +216,7 @@ class TestDuplicateDetection:
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should find one duplicate group (The Matrix)
|
||||
assert len(result) == 1
|
||||
@@ -260,7 +260,7 @@ class TestDuplicateDetection:
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should find one duplicate group (S01E01)
|
||||
assert len(result) == 1
|
||||
@@ -282,7 +282,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -298,7 +298,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should not detect duplicates for files needing review
|
||||
assert len(result) == 0
|
||||
@@ -315,7 +315,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -331,7 +331,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -367,7 +367,7 @@ class TestDuplicateDetection:
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 1
|
||||
comparison = result[0].quality_comparison
|
||||
@@ -400,7 +400,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should find duplicates for both E01 and E02
|
||||
assert len(result) == 2
|
||||
@@ -417,7 +417,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -433,7 +433,7 @@ class TestDuplicateDetection:
|
||||
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
@@ -277,7 +277,7 @@ def test_property_13_duplicate_detection_series(title, season, episode, duplicat
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
@@ -338,7 +338,7 @@ def test_property_14_duplicate_quality_comparison(title, year, file_count):
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
result = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Should have quality comparison data
|
||||
assert len(result) == 1
|
||||
|
||||
@@ -134,3 +134,48 @@ enrichment:
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "mutually exclusive" in result.output
|
||||
|
||||
|
||||
def test_enrich_prints_text_progress_when_not_tty(tmp_path):
|
||||
"""Non-TTY execution should emit textual progress updates."""
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir(parents=True)
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(
|
||||
f"""
|
||||
library_root: {library_root}
|
||||
categories:
|
||||
movie: [movie, movies]
|
||||
series: [series, tv, shows]
|
||||
anime: [anime]
|
||||
enrichment:
|
||||
cache_db: {tmp_path / 'cache.db'}
|
||||
""".strip()
|
||||
)
|
||||
|
||||
identities_file = tmp_path / "identities.json"
|
||||
identities_file.write_text(json.dumps({
|
||||
"metadata": {},
|
||||
"movies": [
|
||||
{
|
||||
"path": "/library/movie/Test.2024.mkv",
|
||||
"filename": "Test.2024.mkv",
|
||||
"category": "movie",
|
||||
"title": "Test",
|
||||
"year": 2024,
|
||||
"confidence": 0.9,
|
||||
"needs_review": False,
|
||||
}
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}))
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--config", str(config_file), "enrich", "--input", str(identities_file)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Progress: 1/1 (100%)" in result.output
|
||||
assert "Skip reasons: no_key=1" in result.output
|
||||
|
||||
@@ -27,8 +27,8 @@ categories:
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("vlm.scanner.scan_library", return_value=[]) as mock_scan, patch(
|
||||
"vlm.scanner.save_inventory_csv"
|
||||
with patch("vlm.commands.scan.scan_library", return_value=[]) as mock_scan, patch(
|
||||
"vlm.commands.scan.save_inventory_csv"
|
||||
) as mock_save:
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -69,9 +69,9 @@ categories:
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("vlm.scanner.load_inventory_csv") as mock_load_cache, patch(
|
||||
"vlm.scanner.scan_library", return_value=[]
|
||||
) as mock_scan, patch("vlm.scanner.save_inventory_csv") as mock_save:
|
||||
with patch("vlm.commands.scan.load_inventory_csv") as mock_load_cache, patch(
|
||||
"vlm.commands.scan.scan_library", return_value=[]
|
||||
) as mock_scan, patch("vlm.commands.scan.save_inventory_csv") as mock_save:
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
|
||||
@@ -180,6 +180,48 @@ categories:
|
||||
assert config.categories["movie"] == ["movie", "movies", "films"]
|
||||
assert config.categories["series"] == ["series", "tv", "shows"]
|
||||
|
||||
def test_load_config_with_tmdb_settings(self, tmp_path):
|
||||
"""Test loading TMDB auth and query preferences from config."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
library_root: /test/library
|
||||
enrichment:
|
||||
api_keys:
|
||||
tmdb_bearer: bearer-token
|
||||
tmdb:
|
||||
language: zh-TW
|
||||
region: TW
|
||||
include_adult: false
|
||||
""")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.tmdb_bearer_token == "bearer-token"
|
||||
assert config.tmdb_language == "zh-TW"
|
||||
assert config.tmdb_region == "TW"
|
||||
assert config.tmdb_include_adult is False
|
||||
|
||||
def test_load_config_with_enrich_alias(self, tmp_path):
|
||||
"""Test loading enrichment settings from `enrich` alias."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
library_root: /test/library
|
||||
enrich:
|
||||
enabled: false
|
||||
providers: [tmdb]
|
||||
api_keys:
|
||||
tmdb_bearer: alias-bearer-token
|
||||
tmdb:
|
||||
language: en-US
|
||||
""")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.enrichment_enabled is False
|
||||
assert config.enrichment_providers == ["tmdb"]
|
||||
assert config.tmdb_bearer_token == "alias-bearer-token"
|
||||
assert config.tmdb_language == "en-US"
|
||||
|
||||
|
||||
class TestCreateDefaultConfig:
|
||||
"""Test create_default_config function."""
|
||||
@@ -211,6 +253,8 @@ class TestCreateDefaultConfig:
|
||||
assert 'templates' in data
|
||||
assert 'log_level' in data
|
||||
assert 'quarantine_dir' in data
|
||||
assert 'enrichment' in data
|
||||
assert 'enrich' in data
|
||||
|
||||
def test_create_default_config_creates_parent_dirs(self, tmp_path):
|
||||
"""Test that create_default_config creates parent directories."""
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from vlm.config import Config
|
||||
from vlm.enrichment import _build_display_title, _build_providers, enrich_identities_data
|
||||
from vlm.providers.base import ProviderResult
|
||||
from vlm.providers.tmdb import TMDBAuthError
|
||||
|
||||
|
||||
class DummyProvider:
|
||||
@@ -12,9 +13,11 @@ class DummyProvider:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.last_request_count = 0
|
||||
|
||||
def enrich(self, *, title: str, media_type: str, year=None):
|
||||
self.calls += 1
|
||||
self.last_request_count = 1
|
||||
return ProviderResult(
|
||||
provider="dummy",
|
||||
canonical_id=f"dummy:{title}",
|
||||
@@ -83,6 +86,7 @@ def test_enrich_flags_low_reputation_for_review(tmp_path, monkeypatch):
|
||||
class LowScoreProvider(DummyProvider):
|
||||
def enrich(self, *, title: str, media_type: str, year=None):
|
||||
self.calls += 1
|
||||
self.last_request_count = 1
|
||||
return ProviderResult(
|
||||
provider="dummy",
|
||||
canonical_id=f"dummy:{title}",
|
||||
@@ -192,9 +196,11 @@ def test_refresh_all_clears_stale_enrichment_fields(tmp_path, monkeypatch):
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.last_request_count = 0
|
||||
|
||||
def enrich(self, *, title: str, media_type: str, year=None):
|
||||
self.calls += 1
|
||||
self.last_request_count = 1
|
||||
if self.calls == 1:
|
||||
return ProviderResult(
|
||||
provider="dummy",
|
||||
@@ -264,3 +270,84 @@ def test_build_display_title_deduplicates_fallback_title(tmp_path):
|
||||
payload = {"title_zh": None, "title_en": None}
|
||||
|
||||
assert _build_display_title(record, payload, config) == "Interstellar"
|
||||
|
||||
|
||||
def test_enrich_without_provider_keys_does_not_count_api_calls(tmp_path):
|
||||
"""Missing provider keys should not inflate API call metrics."""
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["tmdb"],
|
||||
translation_fallback_machine=True,
|
||||
tmdb_api_key=None,
|
||||
openai_api_key=None,
|
||||
)
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
"movies": [
|
||||
{
|
||||
"path": "/library/movie/Test.2020.mkv",
|
||||
"filename": "Test.2020.mkv",
|
||||
"category": "movie",
|
||||
"title": "Test",
|
||||
"year": 2020,
|
||||
"confidence": 0.9,
|
||||
"needs_review": False,
|
||||
}
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
_, stats = enrich_identities_data(identities, config, refresh_mode="refresh_all")
|
||||
|
||||
assert stats["api_calls"] == 0
|
||||
assert stats["enriched"] == 0
|
||||
assert stats["skip_reasons"] == {"no_key": 1}
|
||||
|
||||
|
||||
def test_enrich_auth_failure_stops_immediately(tmp_path, monkeypatch):
|
||||
"""Provider authentication errors should stop the run immediately."""
|
||||
|
||||
class AuthFailProvider:
|
||||
name = "tmdb"
|
||||
last_request_count = 1
|
||||
|
||||
def enrich(self, *, title: str, media_type: str, year=None):
|
||||
raise TMDBAuthError("TMDB authentication failed (401/403)")
|
||||
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
enrichment_cache_db=tmp_path / "cache.db",
|
||||
enrichment_providers=["tmdb"],
|
||||
tmdb_api_key="fake-key",
|
||||
translation_fallback_machine=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vlm.enrichment._build_providers",
|
||||
lambda _config, request_timeout, retries: [AuthFailProvider()],
|
||||
)
|
||||
|
||||
identities = {
|
||||
"metadata": {},
|
||||
"movies": [
|
||||
{
|
||||
"path": "/library/movie/Test.2020.mkv",
|
||||
"filename": "Test.2020.mkv",
|
||||
"category": "movie",
|
||||
"title": "Test",
|
||||
"year": 2020,
|
||||
"confidence": 0.9,
|
||||
"needs_review": False,
|
||||
}
|
||||
],
|
||||
"series": [],
|
||||
"anime": [],
|
||||
"other": [],
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="authentication failed"):
|
||||
enrich_identities_data(identities, config, refresh_mode="refresh_all")
|
||||
|
||||
@@ -83,7 +83,7 @@ class TestReportsIntegration:
|
||||
]
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(identities, files)
|
||||
duplicates = detect_duplicates(list(zip(identities, files)))
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from vlm.state import (
|
||||
load_state,
|
||||
@@ -33,7 +33,8 @@ class TestLoadSaveState:
|
||||
|
||||
assert loaded.states == {}
|
||||
assert loaded.version == '1.0'
|
||||
assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0)
|
||||
# load_state normalizes naive ISO timestamps to UTC
|
||||
assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_save_and_load_with_states(self, tmp_path):
|
||||
"""Test saving and loading state store with file states."""
|
||||
@@ -74,13 +75,14 @@ class TestLoadSaveState:
|
||||
assert state1.file_path == file1
|
||||
assert state1.status == "reviewed"
|
||||
assert state1.reason == "Checked manually"
|
||||
assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0)
|
||||
# load_state normalizes naive ISO timestamps to UTC
|
||||
assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
state2 = loaded.states[str(file2)]
|
||||
assert state2.file_path == file2
|
||||
assert state2.status == "ignored"
|
||||
assert state2.reason is None
|
||||
assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0)
|
||||
assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_save_creates_parent_directory(self, tmp_path):
|
||||
"""Test that save_state creates parent directories if needed."""
|
||||
|
||||
Reference in New Issue
Block a user