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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
||||
)
|
||||
# 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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user