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:
windyboy
2026-02-09 22:40:48 +08:00
co-authored by Claude Sonnet 4.5
parent aa0dc8ec47
commit 259e7506d7
6 changed files with 1380 additions and 85 deletions
+219 -24
View File
@@ -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"