From cf001563b3c9d2ade1a824e141cc95d5073ee623 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 19:24:09 +0200 Subject: [PATCH] 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. --- src/config/settings.py | 18 ++++++++ src/core/SerieScanner.py | 37 ++++++++++++++-- src/core/SeriesApp.py | 5 ++- src/server/models/config.py | 9 ++++ src/server/services/folder_rename_service.py | 45 +++++++++++++++++++- tests/unit/test_folder_rename_service.py | 31 +++++++++++--- tests/unit/test_serie_scanner.py | 22 +++++++++- 7 files changed, 155 insertions(+), 12 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 14ee877..8892dba 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -169,5 +169,23 @@ class Settings(BaseSettings): ] 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() diff --git a/src/core/SerieScanner.py b/src/core/SerieScanner.py index b4d3d12..2380cb8 100644 --- a/src/core/SerieScanner.py +++ b/src/core/SerieScanner.py @@ -24,10 +24,9 @@ from src.config.settings import settings from src.core.entities.series import Serie from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException 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.service import AnimeSeriesService, EpisodeService -from src.core.utils.key_utils import generate_key_from_folder logger = logging.getLogger(__name__) error_logger = logging.getLogger("error") @@ -58,6 +57,11 @@ class SerieScanner: # With DB lookup fallback: scanner = SerieScanner("/path/to/anime", loader, 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__( @@ -65,6 +69,7 @@ class SerieScanner: basePath: str, loader: Loader, db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None, + scan_key_overrides: Optional[dict[str, str]] = None, ) -> None: """ Initialize the SerieScanner. @@ -77,6 +82,10 @@ class SerieScanner: ``key`` file nor a ``data`` file is found in the folder. This allows the database to supply the series key for 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: ValueError: If basePath is invalid or doesn't exist @@ -96,6 +105,7 @@ class SerieScanner: self.keyDict: dict[str, Serie] = {} self.loader: Loader = loader 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.events = Events() @@ -619,7 +629,8 @@ class SerieScanner: 2. If found, return cached Serie object 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 - 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: folder_name: Filesystem folder name @@ -692,7 +703,25 @@ class SerieScanner: ) 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 try: generated_key = generate_key_from_folder(folder_name) diff --git a/src/core/SeriesApp.py b/src/core/SeriesApp.py index 89cc146..dfe365c 100644 --- a/src/core/SeriesApp.py +++ b/src/core/SeriesApp.py @@ -166,7 +166,10 @@ class SeriesApp: self.loaders = Loaders() self.loader = self.loaders.GetLoader(key="aniworld.to") 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 # from database by the service layer during application setup diff --git a/src/server/models/config.py b/src/server/models/config.py index 610c463..1120cf7 100644 --- a/src/server/models/config.py +++ b/src/server/models/config.py @@ -199,6 +199,12 @@ class AppConfig(BaseModel): logging: LoggingConfig = Field(default_factory=LoggingConfig) backup: BackupConfig = Field(default_factory=BackupConfig) 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( default_factory=dict, description="Arbitrary other settings" ) @@ -237,6 +243,7 @@ class ConfigUpdate(BaseModel): logging: Optional[LoggingConfig] = None backup: Optional[BackupConfig] = None nfo: Optional[NFOConfig] = None + scan_key_overrides: Optional[Dict[str, str]] = None other: Optional[Dict[str, object]] = None def apply_to(self, current: AppConfig) -> AppConfig: @@ -253,6 +260,8 @@ class ConfigUpdate(BaseModel): data["backup"] = self.backup.model_dump() if self.nfo is not None: 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: merged = dict(current.other or {}) merged.update(self.other) diff --git a/src/server/services/folder_rename_service.py b/src/server/services/folder_rename_service.py index baf851e..c24901f 100644 --- a/src/server/services/folder_rename_service.py +++ b/src/server/services/folder_rename_service.py @@ -596,7 +596,50 @@ async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, current_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 # Check path length limits diff --git a/tests/unit/test_folder_rename_service.py b/tests/unit/test_folder_rename_service.py index 8a5af8b..ad17a40 100644 --- a/tests/unit/test_folder_rename_service.py +++ b/tests/unit/test_folder_rename_service.py @@ -455,7 +455,8 @@ class TestValidateAndRenameSeriesFolders: assert series_dir.is_dir() @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.mkdir() series_dir = anime_dir / "Attack on Titan" @@ -464,7 +465,13 @@ class TestValidateAndRenameSeriesFolders: "Attack on Titan2013" ) # 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( "src.server.services.folder_rename_service.settings.anime_directory", @@ -472,14 +479,28 @@ class TestValidateAndRenameSeriesFolders: ), patch( "src.server.services.folder_rename_service._is_series_being_downloaded", 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() + # 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["renamed"] == 0 + assert stats["renamed"] == 1 assert stats["skipped"] == 0 - assert stats["errors"] == 1 - assert series_dir.is_dir() + assert stats["errors"] == 0 @pytest.mark.asyncio async def test_counts_multiple_folders(self, tmp_path: Path) -> None: diff --git a/tests/unit/test_serie_scanner.py b/tests/unit/test_serie_scanner.py index 56c1530..e4023f4 100644 --- a/tests/unit/test_serie_scanner.py +++ b/tests/unit/test_serie_scanner.py @@ -547,11 +547,31 @@ class TestReadDataFromFile: scanner = SerieScanner(tmpdir, mock_loader) 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 isinstance(result, Serie) 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: """Test reinit method."""