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()]
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user