Compare commits

...

6 Commits

Author SHA1 Message Date
76b849fc91 chore: bump version 2026-05-30 12:02:48 +02:00
00b26c8cbc fix: validate generated keys before creating Serie objects
- Add is_valid_key check in SerieScanner._read_data_from_file() to prevent
  passing invalid keys to Serie constructor (caused ValueError)
- Improve error message for key generation failures
- Add warning log before removing duplicate source folders in rename service
2026-05-30 11:42:19 +02:00
a6f2399aca chore: bump version 2026-05-29 19:25:30 +02:00
cf001563b3 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.
2026-05-29 19:24:09 +02:00
38c12638a4 fix HLS stream warning by disabling native downloader and retrying with ffmpeg
- Set hls_prefer_native: False to skip yt-dlp's native HLS downloader which emits
  'Live HLS streams are not supported' warning
- Add retry logic that catches HLS-related exceptions and retries with
  downloader=ffmpeg and hls_use_mpegts=True
2026-05-29 18:53:47 +02:00
765e43c684 fix(key_utils): drop apostrophes in generate_key_from_folder 2026-05-29 18:20:20 +02:00
11 changed files with 216 additions and 18 deletions

View File

@@ -1 +1 @@
v1.2.0
v1.2.2

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.2.0",
"version": "1.2.2",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -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()

View File

@@ -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, is_valid_key
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,11 +703,39 @@ 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)
year_from_folder = self._extract_year_from_folder_name(folder_name)
# Validate that the generated key is usable
if not generated_key or not is_valid_key(generated_key):
logger.warning(
"Serie key is invalid for folder '%s' (key='%s') - skipping",
folder_name,
generated_key
)
return None
logger.info(
"Generated key for folder '%s' -> key='%s'",
folder_name,
@@ -712,7 +751,7 @@ class SerieScanner:
)
except Exception as exc:
logger.warning(
"Failed to generate key for folder '%s': %s",
"Unexpected error generating key for folder '%s': %s",
folder_name,
exc
)

View File

@@ -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

View File

@@ -550,8 +550,10 @@ class AniworldLoader(Loader):
'nocheckcertificate': True,
'logger': logger,
'progress_hooks': [events_progress_hook],
'downloader': 'ffmpeg',
'hls_use_mpegts': True,
# yt-dlp defaults to native HLS downloader which warns about
# "Live HLS streams are not supported" - disable to go
# straight to ffmpeg, avoiding the warning
'hls_prefer_native': False,
}
if header:
@@ -597,6 +599,40 @@ class AniworldLoader(Loader):
_cleanup_temp_file(temp_path)
continue
except Exception as exc:
# Check if this is an HLS-related failure that might succeed
# with additional ffmpeg options
exc_str = str(exc).lower()
is_hls_related = (
'hls' in exc_str or
'live' in exc_str or
'native downloader' in exc_str
)
if is_hls_related and 'ffmpeg' not in str(ydl_opts.get('downloader', '')):
logger.info(
"HLS stream detected, retrying with ffmpeg options: %s",
output_file
)
# Retry with ffmpeg explicitly set
retry_opts = ydl_opts.copy()
retry_opts['downloader'] = 'ffmpeg'
retry_opts['hls_use_mpegts'] = True
try:
with YoutubeDL(retry_opts) as ydl:
info = ydl.extract_info(link, download=True)
if os.path.exists(temp_path):
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info(
"Download completed successfully (retry): %s",
output_file
)
self.clear_cache()
return True
except Exception:
_cleanup_temp_file(temp_path)
# Continue to next provider if retry also fails
continue
logger.error(
"YoutubeDL download failed with provider %s: %s: %s",
provider_name, type(exc).__name__, exc

View File

@@ -10,7 +10,6 @@ import unicodedata
import uuid
from typing import Optional
# Valid key pattern: alphanumeric, hyphens, underscores
# Must be at least 1 char, URL-safe
VALID_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$')
@@ -102,6 +101,11 @@ def generate_key_from_folder(folder_name: str) -> str:
parts.append(char)
elif char.isspace():
parts.append(' ')
# Handle apostrophes - treat as part of word (remove, don't replace with space)
# This normalizes e.g., "Hell's" -> "Hells"
# Includes: ' (0x27), ' (0x2018), ' (0x2019), ' (0x02BC), ` (0x0060)
elif char in ("'", "'", "'", "'", "`", """, """):
pass # Skip - drop the apostrophe
else:
parts.append(' ')

View File

@@ -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)

View File

@@ -596,7 +596,55 @@ 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
logger.warning(
"Removing source duplicate folder '%s' — target '%s' already exists",
current_name,
expected_name,
)
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

View File

@@ -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:

View File

@@ -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."""