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
+67 -5
View File
@@ -19,6 +19,7 @@ class Config:
series_filename_template: Filename template for series (e.g., "S{season:02d}E{episode:02d}{ext}")
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
quarantine_dir: Quarantine directory name relative to category root (e.g., ".quarantine")
categories: Mapping of category names to directory name lists for file categorization
"""
library_root: Path
video_extensions: list[str] = field(default_factory=lambda: [
@@ -30,6 +31,11 @@ class Config:
series_filename_template: str = "S{season:02d}E{episode:02d}{ext}"
log_level: str = "INFO"
quarantine_dir: str = ".quarantine"
categories: dict[str, list[str]] = field(default_factory=lambda: {
"movie": ["movie"],
"series": ["series"],
"anime": ["anime"]
})
def load_config(path: Path) -> Config:
@@ -79,7 +85,14 @@ def load_config(path: Path) -> Config:
# Extract other settings
quarantine_dir = data.get('quarantine_dir', '.quarantine')
log_level = data.get('log_level', 'INFO')
# Extract categories configuration
categories = data.get('categories', {
"movie": ["movie"],
"series": ["series"],
"anime": ["anime"]
})
return Config(
library_root=library_root,
video_extensions=video_extensions,
@@ -88,7 +101,8 @@ def load_config(path: Path) -> Config:
movie_filename_template=movie_filename_template,
series_filename_template=series_filename_template,
log_level=log_level,
quarantine_dir=quarantine_dir
quarantine_dir=quarantine_dir,
categories=categories
)
@@ -110,7 +124,12 @@ def create_default_config(path: Path) -> Config:
movie_filename_template="{title} ({year}){ext}",
series_filename_template="S{season:02d}E{episode:02d}{ext}",
log_level="INFO",
quarantine_dir=".quarantine"
quarantine_dir=".quarantine",
categories={
"movie": ["movie"],
"series": ["series"],
"anime": ["anime"]
}
)
# Create YAML content
@@ -124,7 +143,8 @@ def create_default_config(path: Path) -> Config:
'series_filename': default_config.series_filename_template
},
'quarantine_dir': default_config.quarantine_dir,
'log_level': default_config.log_level
'log_level': default_config.log_level,
'categories': default_config.categories
}
# Ensure parent directory exists
@@ -204,5 +224,47 @@ def validate_config(config: Config) -> list[str]:
errors.append("quarantine_dir must be a string")
elif config.quarantine_dir.startswith('/') or config.quarantine_dir.startswith('\\'):
errors.append("quarantine_dir must be relative to category root, not absolute")
# Validate categories
if not config.categories:
errors.append("categories cannot be empty")
elif not isinstance(config.categories, dict):
errors.append("categories must be a dictionary")
else:
# Check required category keys exist
required_categories = {"movie", "series", "anime"}
missing = required_categories - set(config.categories.keys())
if missing:
errors.append(f"categories must include keys: {sorted(missing)}")
# Validate each category's directory list and check for duplicates
seen_dirs = {}
for category, dir_list in config.categories.items():
if not isinstance(dir_list, list):
errors.append(f"categories['{category}'] must be a list")
continue
if not dir_list:
errors.append(f"categories['{category}'] cannot be empty")
continue
for dir_name in dir_list:
if not isinstance(dir_name, str):
errors.append(f"categories['{category}'] must contain strings")
break
if not dir_name.strip():
errors.append(f"categories['{category}'] contains empty directory name")
break
# Check for duplicates (case-insensitive)
dir_lower = dir_name.lower()
if dir_lower in seen_dirs:
errors.append(
f"Duplicate directory name '{dir_name}' in categories "
f"'{category}' and '{seen_dirs[dir_lower]}'"
)
else:
seen_dirs[dir_lower] = category
return errors