refactor: add folder rename configuration and service

Add configurable folder rename patterns via settings with anime_folder_rename_regex and custom_pattern options. Integrate into SerieScanner and SeriesApp for consistent episode organization.
This commit is contained in:
2026-05-29 19:24:09 +02:00
parent 38c12638a4
commit cf001563b3
7 changed files with 155 additions and 12 deletions

View File

@@ -169,5 +169,23 @@ class Settings(BaseSettings):
] ]
return [origin.strip() for origin in raw.split(",") if origin.strip()] return [origin.strip() for origin in raw.split(",") if origin.strip()]
@property
def scan_key_overrides(self) -> dict[str, str]:
"""Return scan key overrides from config.json.
Maps folder names to provider keys for cases where auto-generated
keys from folder names are incorrect.
Returns:
Dict mapping folder names to provider keys.
"""
from src.server.services.config_service import ConfigService
try:
config_service = ConfigService()
config = config_service.load_config()
return config.scan_key_overrides or {}
except Exception:
return {}
settings = Settings() settings = Settings()

View File

@@ -24,10 +24,9 @@ from src.config.settings import settings
from src.core.entities.series import Serie from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.providers.base_provider import Loader from src.core.providers.base_provider import Loader
from src.core.utils.key_utils import generate_key_from_folder
from src.server.database.connection import get_sync_session from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService, EpisodeService from src.server.database.service import AnimeSeriesService, EpisodeService
from src.core.utils.key_utils import generate_key_from_folder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
error_logger = logging.getLogger("error") error_logger = logging.getLogger("error")
@@ -58,6 +57,11 @@ class SerieScanner:
# With DB lookup fallback: # With DB lookup fallback:
scanner = SerieScanner("/path/to/anime", loader, scanner = SerieScanner("/path/to/anime", loader,
db_lookup=lambda folder: my_db.get_by_folder(folder)) db_lookup=lambda folder: my_db.get_by_folder(folder))
# With scan key overrides:
overrides = {"Folder Name": "correct-provider-key"}
scanner = SerieScanner("/path/to/anime", loader,
scan_key_overrides=overrides)
""" """
def __init__( def __init__(
@@ -65,6 +69,7 @@ class SerieScanner:
basePath: str, basePath: str,
loader: Loader, loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None, db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
scan_key_overrides: Optional[dict[str, str]] = None,
) -> None: ) -> None:
""" """
Initialize the SerieScanner. Initialize the SerieScanner.
@@ -77,6 +82,10 @@ class SerieScanner:
``key`` file nor a ``data`` file is found in the folder. ``key`` file nor a ``data`` file is found in the folder.
This allows the database to supply the series key for This allows the database to supply the series key for
folders that have never had a local key file. folders that have never had a local key file.
scan_key_overrides: Optional dict mapping folder names to provider
keys. When a folder name is found in this dict, the override
key is used instead of auto-generating from folder name.
Format: {"Folder Name": "actual-provider-key"}
Raises: Raises:
ValueError: If basePath is invalid or doesn't exist ValueError: If basePath is invalid or doesn't exist
@@ -96,6 +105,7 @@ class SerieScanner:
self.keyDict: dict[str, Serie] = {} self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
self._current_operation_id: Optional[str] = None self._current_operation_id: Optional[str] = None
self.events = Events() self.events = Events()
@@ -619,7 +629,8 @@ class SerieScanner:
2. If found, return cached Serie object 2. If found, return cached Serie object
3. If not in DB, fall back to provider search via _db_lookup callback 3. If not in DB, fall back to provider search via _db_lookup callback
4. If still not found, try reading 'data' file for legacy deployments 4. If still not found, try reading 'data' file for legacy deployments
5. Generate key from folder name as last resort 5. Check user-provided key overrides in scan_key_overrides
6. Generate key from folder name as last resort
Args: Args:
folder_name: Filesystem folder name folder_name: Filesystem folder name
@@ -692,7 +703,25 @@ class SerieScanner:
) )
return Serie.load_from_file(serie_file) return Serie.load_from_file(serie_file)
# Step 4: Generate key from folder name as last resort # Step 4: Check for user-provided key overrides before generating
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
override_key = self._scan_key_overrides[folder_name]
year_from_folder = self._extract_year_from_folder_name(folder_name)
logger.info(
"Using scan key override for folder '%s' -> key='%s'",
folder_name,
override_key
)
return Serie(
key=override_key,
name="", # Name will be fetched from provider if needed
site="aniworld.to",
folder=folder_name,
episodeDict=dict(),
year=year_from_folder
)
# Step 5: Generate key from folder name as last resort
# This handles edge cases like non-Latin characters or special symbols # This handles edge cases like non-Latin characters or special symbols
try: try:
generated_key = generate_key_from_folder(folder_name) generated_key = generate_key_from_folder(folder_name)

View File

@@ -166,7 +166,10 @@ class SeriesApp:
self.loaders = Loaders() self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to") self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner( self.serie_scanner = SerieScanner(
directory_to_search, self.loader, db_lookup=db_lookup directory_to_search,
self.loader,
db_lookup=db_lookup,
scan_key_overrides=settings.scan_key_overrides,
) )
# Skip automatic loading from data files - series will be loaded # Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup # from database by the service layer during application setup

View File

@@ -199,6 +199,12 @@ class AppConfig(BaseModel):
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig) backup: BackupConfig = Field(default_factory=BackupConfig)
nfo: NFOConfig = Field(default_factory=NFOConfig) nfo: NFOConfig = Field(default_factory=NFOConfig)
scan_key_overrides: Dict[str, str] = Field(
default_factory=dict,
description="Map of folder names to provider keys for scan overrides. "
"Used when auto-generated keys from folder names are incorrect. "
"Format: {\"Folder Name\": \"actual-provider-key\"}"
)
other: Dict[str, object] = Field( other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings" default_factory=dict, description="Arbitrary other settings"
) )
@@ -237,6 +243,7 @@ class ConfigUpdate(BaseModel):
logging: Optional[LoggingConfig] = None logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None nfo: Optional[NFOConfig] = None
scan_key_overrides: Optional[Dict[str, str]] = None
other: Optional[Dict[str, object]] = None other: Optional[Dict[str, object]] = None
def apply_to(self, current: AppConfig) -> AppConfig: def apply_to(self, current: AppConfig) -> AppConfig:
@@ -253,6 +260,8 @@ class ConfigUpdate(BaseModel):
data["backup"] = self.backup.model_dump() data["backup"] = self.backup.model_dump()
if self.nfo is not None: if self.nfo is not None:
data["nfo"] = self.nfo.model_dump() data["nfo"] = self.nfo.model_dump()
if self.scan_key_overrides is not None:
data["scan_key_overrides"] = self.scan_key_overrides
if self.other is not None: if self.other is not None:
merged = dict(current.other or {}) merged = dict(current.other or {})
merged.update(self.other) merged.update(self.other)

View File

@@ -596,7 +596,50 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str,
current_name, current_name,
expected_name, expected_name,
) )
stats["errors"] += 1 # Target folder exists — remove source folder and delete its DB record
# (target folder's DB record survives, source folder's record must be removed
# to avoid orphaning episodes/downloads)
try:
import shutil
shutil.rmtree(series_dir)
logger.info(
"Removed source folder '%s' — series already exists at target",
current_name,
)
# Delete source DB record (cascades to episodes and download items)
async with get_db_session() as db:
source_series = await AnimeSeriesService.get_by_key(db, current_name)
if source_series is None:
# Fallback: find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == current_name:
source_series = s
break
if source_series is not None:
await AnimeSeriesService.delete(db, source_series.id)
logger.info(
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
current_name,
source_series.id,
expected_name,
)
else:
logger.info(
"No DB record found for source folder '%s' — folder removed only",
current_name,
)
stats["renamed"] += 1
except OSError as exc:
logger.error(
"Failed to remove source folder '%s': %s",
current_name,
exc,
)
stats["errors"] += 1
continue continue
# Check path length limits # Check path length limits

View File

@@ -455,7 +455,8 @@ class TestValidateAndRenameSeriesFolders:
assert series_dir.is_dir() assert series_dir.is_dir()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_errors_when_target_exists(self, tmp_path: Path) -> None: async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None:
"""When target folder exists, source folder should be removed and its DB record deleted."""
anime_dir = tmp_path / "anime" anime_dir = tmp_path / "anime"
anime_dir.mkdir() anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan" series_dir = anime_dir / "Attack on Titan"
@@ -464,7 +465,13 @@ class TestValidateAndRenameSeriesFolders:
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>" "<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
) )
# Pre-create the target folder to simulate a duplicate # Pre-create the target folder to simulate a duplicate
(anime_dir / "Attack on Titan (2013)").mkdir() target_dir = anime_dir / "Attack on Titan (2013)"
target_dir.mkdir()
mock_db = AsyncMock()
mock_session = AsyncMock()
mock_db.__aenter__.return_value = mock_session
mock_db.__aexit__.return_value = None
with patch( with patch(
"src.server.services.folder_rename_service.settings.anime_directory", "src.server.services.folder_rename_service.settings.anime_directory",
@@ -472,14 +479,28 @@ class TestValidateAndRenameSeriesFolders:
), patch( ), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded", "src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False, return_value=False,
), patch(
"src.server.services.folder_rename_service.get_db_session",
return_value=mock_db,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.services.folder_rename_service.AnimeSeriesService.get_all",
new_callable=AsyncMock,
return_value=[],
): ):
stats = await validate_and_rename_series_folders() stats = await validate_and_rename_series_folders()
# Source folder removed, target survives
assert not series_dir.exists()
assert target_dir.is_dir()
# Duplicate resolved: counts as renamed (source removed, target kept)
assert stats["scanned"] == 1 assert stats["scanned"] == 1
assert stats["renamed"] == 0 assert stats["renamed"] == 1
assert stats["skipped"] == 0 assert stats["skipped"] == 0
assert stats["errors"] == 1 assert stats["errors"] == 0
assert series_dir.is_dir()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_counts_multiple_folders(self, tmp_path: Path) -> None: async def test_counts_multiple_folders(self, tmp_path: Path) -> None:

View File

@@ -547,11 +547,31 @@ class TestReadDataFromFile:
scanner = SerieScanner(tmpdir, mock_loader) scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Empty") result = scanner._SerieScanner__read_data_from_file("Empty")
# Step 4 generates key from folder name when no files exist # Step 5 (was Step 4) generates key from folder name when no files exist
assert result is not None assert result is not None
assert isinstance(result, Serie) assert isinstance(result, Serie)
assert result.key == "empty" assert result.key == "empty"
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
"""Should use override key when folder name matches override dict."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
os.makedirs(anime_folder)
overrides = {
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
}
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
result = scanner._SerieScanner__read_data_from_file(
"Anyway, I'm Falling in Love with You (2025)"
)
# Override key should be used instead of generated key
assert result is not None
assert isinstance(result, Serie)
assert result.key == "anyway-im-falling-in-love-with-you-2025"
class TestReinit: class TestReinit:
"""Test reinit method.""" """Test reinit method."""