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
|
||||
|
||||
Reference in New Issue
Block a user