feat: add configurable category mappings for directory recognition
Allow users to configure multiple directory names per category (movie/series/anime) to support variations like "movies", "tv", "films". This enables proper categorization of files in directories that don't match the hardcoded singular forms, solving the issue where 635 files in "/mnt/Downloads/movies/" were incorrectly categorized as "other".
Configuration example:
categories:
movie: [movie, movies, films]
series: [series, tv, shows]
anime: [anime]
Changes include comprehensive validation, backward-compatible defaults, case-insensitive matching, and full test coverage (395 tests passing).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
aa0dc8ec47
commit
259e7506d7
+161
-6
@@ -29,12 +29,36 @@ class TestConfig:
|
||||
movie_template="movies/{title}-{year}/",
|
||||
log_level="DEBUG"
|
||||
)
|
||||
|
||||
|
||||
assert config.library_root == Path("/custom/path")
|
||||
assert config.video_extensions == [".mp4", ".avi"]
|
||||
assert config.movie_template == "movies/{title}-{year}/"
|
||||
assert config.log_level == "DEBUG"
|
||||
|
||||
def test_config_default_categories(self):
|
||||
"""Test Config has default categories."""
|
||||
config = Config(library_root=Path("/test"))
|
||||
|
||||
assert config.categories == {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
def test_config_custom_categories(self):
|
||||
"""Test Config with custom category mappings."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies", "films"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
|
||||
assert "movies" in config.categories["movie"]
|
||||
assert "tv" in config.categories["series"]
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Test load_config function."""
|
||||
@@ -127,18 +151,34 @@ class TestLoadConfig:
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos'
|
||||
}
|
||||
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
|
||||
# Should use default values for missing fields
|
||||
assert config.library_root == Path('/mnt/nas/videos')
|
||||
assert ".mp4" in config.video_extensions
|
||||
assert config.movie_template == "movie/{title} ({year})/"
|
||||
assert config.log_level == "INFO"
|
||||
|
||||
def test_load_config_with_custom_categories(self, tmp_path):
|
||||
"""Test loading config with custom categories."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
library_root: /test/library
|
||||
categories:
|
||||
movie: [movie, movies, films]
|
||||
series: [series, tv, shows]
|
||||
anime: [anime]
|
||||
""")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.categories["movie"] == ["movie", "movies", "films"]
|
||||
assert config.categories["series"] == ["series", "tv", "shows"]
|
||||
|
||||
|
||||
class TestCreateDefaultConfig:
|
||||
"""Test create_default_config function."""
|
||||
@@ -308,12 +348,127 @@ class TestValidateConfig:
|
||||
movie_template="",
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
|
||||
def test_validate_empty_categories(self):
|
||||
"""Test validating config with empty categories."""
|
||||
config = Config(library_root=Path("/test"), categories={})
|
||||
errors = validate_config(config)
|
||||
assert any("categories" in e and "empty" in e for e in errors)
|
||||
|
||||
def test_validate_missing_required_category(self):
|
||||
"""Test validating config with missing required categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={"movie": ["movie"]} # Missing series, anime
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("series" in e or "anime" in e for e in errors)
|
||||
|
||||
def test_validate_duplicate_directory_names(self):
|
||||
"""Test validating config with duplicate directory names."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "videos"],
|
||||
"series": ["series", "videos"], # Duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e and "videos" in e for e in errors)
|
||||
|
||||
def test_validate_case_insensitive_duplicates(self):
|
||||
"""Test validating config with case-insensitive duplicates."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["Movie"],
|
||||
"series": ["movie"], # Case-insensitive duplicate
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("Duplicate" in e for e in errors)
|
||||
|
||||
def test_validate_valid_custom_categories(self):
|
||||
"""Test validating config with valid custom categories."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series", "tv"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == []
|
||||
|
||||
def test_validate_categories_not_dict(self):
|
||||
"""Test validating config with categories not a dict."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories=["movie", "series"] # Wrong type
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a dictionary" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_not_list(self):
|
||||
"""Test validating config with category value not a list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": "movie", # Should be a list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must be a list" in e for e in errors)
|
||||
|
||||
def test_validate_empty_category_list(self):
|
||||
"""Test validating config with empty category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": [], # Empty list
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("cannot be empty" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_with_non_string(self):
|
||||
"""Test validating config with non-string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", 123], # Non-string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("must contain strings" in e for e in errors)
|
||||
|
||||
def test_validate_category_list_with_empty_string(self):
|
||||
"""Test validating config with empty string in category list."""
|
||||
config = Config(
|
||||
library_root=Path("/test"),
|
||||
categories={
|
||||
"movie": ["movie", ""], # Empty string
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert any("empty directory name" in e for e in errors)
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
"""Integration tests for configuration workflow."""
|
||||
|
||||
+219
-24
@@ -106,6 +106,46 @@ class TestScanLibrary:
|
||||
assert len(result) == 2
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"video.mp4", "video.mkv"}
|
||||
|
||||
def test_scan_uses_find_output_and_filters_hidden_paths(self, tmp_path):
|
||||
"""Test scan_library filters hidden paths from find output."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
hidden_dir.mkdir()
|
||||
|
||||
visible_file = movie_dir / "visible.mp4"
|
||||
hidden_file = hidden_dir / "hidden.mp4"
|
||||
visible_file.touch()
|
||||
hidden_file.touch()
|
||||
|
||||
fake_stdout = f"{visible_file}\0{hidden_file}\0".encode()
|
||||
|
||||
with patch('subprocess.Popen') as mock_popen:
|
||||
process = MagicMock()
|
||||
process.communicate.return_value = (fake_stdout, b"")
|
||||
process.returncode = 0
|
||||
mock_popen.return_value = process
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == visible_file
|
||||
|
||||
def test_scan_falls_back_when_find_is_unavailable(self, tmp_path):
|
||||
"""Test scan_library falls back to recursive scanning if find is unavailable."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "fallback.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.Popen', side_effect=FileNotFoundError):
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == video_file
|
||||
|
||||
def test_scan_records_metadata(self, tmp_path):
|
||||
"""Test scanning records file metadata correctly."""
|
||||
@@ -200,86 +240,241 @@ class TestScanLibrary:
|
||||
# Should find both files (no permission errors in test environment)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_scan_reports_progress_callback(self, tmp_path):
|
||||
"""Test scanning reports progress updates for discovered files."""
|
||||
config = Config(library_root=tmp_path)
|
||||
fake_paths = [tmp_path / "a.mp4", tmp_path / "b.mp4"]
|
||||
|
||||
fake_video = VideoFile(
|
||||
path=fake_paths[0],
|
||||
filename="a.mp4",
|
||||
size_bytes=1,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="movie",
|
||||
)
|
||||
|
||||
progress_events: list[tuple[int, int]] = []
|
||||
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=fake_paths), patch(
|
||||
"vlm.scanner._create_video_file",
|
||||
side_effect=[fake_video, None]
|
||||
):
|
||||
result = scan_library(
|
||||
tmp_path,
|
||||
config,
|
||||
progress_callback=lambda current, total: progress_events.append((current, total))
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert progress_events == [(0, 2), (1, 2), (2, 2)]
|
||||
|
||||
def test_scan_reports_progress_for_empty_discovery(self, tmp_path):
|
||||
"""Test scanning reports zero progress when no files are discovered."""
|
||||
config = Config(library_root=tmp_path)
|
||||
progress_events: list[tuple[int, int]] = []
|
||||
|
||||
with patch("vlm.scanner._discover_video_paths", return_value=[]):
|
||||
result = scan_library(
|
||||
tmp_path,
|
||||
config,
|
||||
progress_callback=lambda current, total: progress_events.append((current, total))
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert progress_events == [(0, 0)]
|
||||
|
||||
|
||||
class TestCategorizeFile:
|
||||
"""Tests for the categorize_file function."""
|
||||
|
||||
|
||||
def test_categorize_movie(self, tmp_path):
|
||||
"""Test categorizing a file in movie directory."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
|
||||
def test_categorize_series(self, tmp_path):
|
||||
"""Test categorizing a file in series directory."""
|
||||
series_dir = tmp_path / "series"
|
||||
series_dir.mkdir()
|
||||
video_file = series_dir / "test.mkv"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "series"
|
||||
|
||||
|
||||
def test_categorize_anime(self, tmp_path):
|
||||
"""Test categorizing a file in anime directory."""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
video_file = anime_dir / "test.avi"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "anime"
|
||||
|
||||
|
||||
def test_categorize_other(self, tmp_path):
|
||||
"""Test categorizing a file in other directory."""
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
video_file = other_dir / "test.mov"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
|
||||
def test_categorize_nested_file(self, tmp_path):
|
||||
"""Test categorizing a file in nested subdirectory."""
|
||||
movie_dir = tmp_path / "movie" / "subdir" / "nested"
|
||||
movie_dir.mkdir(parents=True)
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
|
||||
def test_categorize_case_insensitive(self, tmp_path):
|
||||
"""Test categorization is case-insensitive."""
|
||||
movie_dir = tmp_path / "Movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
|
||||
def test_categorize_file_in_root(self, tmp_path):
|
||||
"""Test categorizing a file directly in library root."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
|
||||
def test_categorize_unknown_directory(self, tmp_path):
|
||||
"""Test categorizing a file in unknown directory."""
|
||||
unknown_dir = tmp_path / "random"
|
||||
unknown_dir.mkdir()
|
||||
video_file = unknown_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_plural_movies(self, tmp_path):
|
||||
"""Test recognizing plural 'movies' directory."""
|
||||
movies_dir = tmp_path / "movies"
|
||||
movies_dir.mkdir()
|
||||
video_file = movies_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_tv_directory(self, tmp_path):
|
||||
"""Test recognizing 'tv' as series category."""
|
||||
tv_dir = tmp_path / "tv"
|
||||
tv_dir.mkdir()
|
||||
video_file = tv_dir / "show.mkv"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series", "tv", "shows"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "series"
|
||||
|
||||
def test_categorize_custom_case_insensitive(self, tmp_path):
|
||||
"""Test case-insensitive matching with custom mappings."""
|
||||
movies_dir = tmp_path / "MOVIES"
|
||||
movies_dir.mkdir()
|
||||
video_file = movies_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie", "movies"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_unmapped_returns_other(self, tmp_path):
|
||||
"""Test unmapped directory returns 'other'."""
|
||||
downloads_dir = tmp_path / "downloads"
|
||||
downloads_dir.mkdir()
|
||||
video_file = downloads_dir / "file.mp4"
|
||||
video_file.touch()
|
||||
|
||||
categories_config = {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
|
||||
category = categorize_file(video_file, tmp_path, categories_config)
|
||||
assert category == "other"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user