feat: Enhanced anime add flow with sanitized folders and targeted scan

- Add sanitize_folder_name utility for filesystem-safe folder names
- Add sanitized_folder property to Serie entity
- Update SerieList.add() to use sanitized display names for folders
- Add scan_single_series() method for targeted episode scanning
- Enhance add_series endpoint: DB save -> folder create -> targeted scan
- Update response to include missing_episodes and total_missing
- Add comprehensive unit tests for new functionality
- Update API tests with proper mock support
This commit is contained in:
Lukas 2025-12-26 12:49:23 +01:00
parent f28dc756c5
commit 1b7ca7b4da
11 changed files with 1370 additions and 146 deletions

View File

@ -17,7 +17,7 @@
"keep_days": 30 "keep_days": 30
}, },
"other": { "other": {
"master_password_hash": "$pbkdf2-sha256$29000$cC6FsJayNmYsZezdW6tVyg$5LMyYrqVoM0qwxugSedT6UFMnLHePg2atdECBxAVJEk" "master_password_hash": "$pbkdf2-sha256$29000$MoYQ4tx7D8FY631P6b3Xeg$Lkk9WJI928F4EzBrUe1VnRD9LgKzy31zoygoIGQwqKY"
}, },
"version": "1.0.0" "version": "1.0.0"
} }

View File

@ -100,7 +100,7 @@ For each task completed:
- [ ] Performance validated - [ ] Performance validated
- [ ] Code reviewed - [ ] Code reviewed
- [ ] Task marked as complete in instructions.md - [ ] Task marked as complete in instructions.md
- [ ] Infrastructure.md updated - [ ] Infrastructure.md updated and other docs
- [ ] Changes committed to git; keep your messages in git short and clear - [ ] Changes committed to git; keep your messages in git short and clear
- [ ] Take the next task - [ ] Take the next task
@ -121,114 +121,115 @@ For each task completed:
--- ---
--- ## Task: Enhanced Anime Add Flow
## Task: Add Database Transaction Support ### Overview
### Objective Enhance the anime addition workflow to automatically persist anime to the database, scan for missing episodes immediately, and create folders using the anime display name instead of the internal key.
Implement proper transaction handling across all database write operations using SQLAlchemy's transaction support. This ensures data consistency and prevents partial writes during compound operations.
### Background
Currently, the application uses SQLAlchemy sessions with auto-commit behavior through the `get_db_session()` generator. While individual operations are atomic, compound operations (multiple writes) can result in partial commits if an error occurs mid-operation.
### Requirements ### Requirements
1. **All database write operations must be wrapped in explicit transactions** 1. **After anime add → Save to database**: Ensure the anime is persisted to the database via `AnimeDBService.create_series()` immediately after validation
2. **Compound operations must be atomic** - either all writes succeed or all fail 2. **After anime add → Scan for missing episodes**: Trigger a targeted episode scan for only the newly added anime (not the entire library)
3. **Nested operations should use savepoints** for partial rollback capability 3. **After anime add → Create folder with anime name**: Use the anime display name (sanitized) for the folder, not the anime key
4. **Existing functionality must not break** - backward compatible changes only
5. **All tests must pass after implementation** ### Implementation Steps
--- #### Step 1: Examine Current Implementation
## Task: Graceful Shutdown Implementation ✅ COMPLETED 1. Open and read `src/server/routes/anime_routes.py` - find the `add_series` endpoint
2. Open and read `src/core/SerieScanner.py` - understand how scanning works
### Objective 3. Open and read `src/core/entities/Serie.py` and `src/core/entities/SerieList.py` - understand folder handling
4. Open and read `src/database/services/anime_db_service.py` - understand database operations
Implement proper graceful shutdown handling so that Ctrl+C (SIGINT) or SIGTERM triggers a clean shutdown sequence that terminates all concurrent processes and prevents database corruption. 5. Open and read `src/core/providers/AniWorldProvider.py` - understand how folders are created
### Background #### Step 2: Create Utility Function for Folder Name Sanitization
The application runs multiple concurrent services (WebSocket connections, download service with ThreadPoolExecutor, database sessions) that need to be properly cleaned up during shutdown. Without graceful shutdown, active downloads may corrupt state, database writes may be incomplete, and WebSocket clients won't receive disconnect notifications. 1. Create or update utility module at `src/utils/filesystem.py`
2. Implement `sanitize_folder_name(name: str) -> str` function that:
### Implementation Summary - Removes/replaces characters invalid for filesystems: `< > : " / \ | ? *`
- Trims leading/trailing whitespace and dots
The following components were implemented: - Handles edge cases (empty string, only invalid chars)
- Preserves Unicode characters (for Japanese titles, etc.)
#### 1. WebSocket Service Shutdown ([src/server/services/websocket_service.py](src/server/services/websocket_service.py))
#### Step 3: Update Serie Entity
- Added `shutdown()` method to `ConnectionManager` that:
- Broadcasts `{"type": "server_shutdown"}` notification to all connected clients 1. Open `src/core/entities/Serie.py`
- Gracefully closes each WebSocket connection with code 1001 (Going Away) 2. Add a `folder` property that returns sanitized display name instead of key
- Clears all connection tracking data structures 3. Ensure backward compatibility with existing series
- Supports configurable timeout (default 5 seconds)
- Added `shutdown()` method to `WebSocketService` that delegates to the manager #### Step 4: Update SerieList to Use Display Name for Folders
#### 2. Download Service Stop ([src/server/services/download_service.py](src/server/services/download_service.py)) 1. Open `src/core/entities/SerieList.py`
2. In the `add()` method, use `serie.folder` (display name) instead of `serie.key` when creating directories
- Enhanced `stop()` method to: 3. Ensure the folder path is correctly stored in the Serie object
- Persist active downloads back to "pending" status in database (allows resume on restart)
- Cancel active download tasks with proper timeout handling #### Step 5: Add Targeted Episode Scan Method to SerieScanner
- Shutdown ThreadPoolExecutor with `wait=True` and configurable timeout (default 10 seconds)
- Fall back to forced shutdown if timeout expires 1. Open `src/core/SerieScanner.py`
2. Add new method `scan_single_series(self, key: str) -> List[Episode]`:
#### 3. FastAPI Lifespan Shutdown ([src/server/fastapi_app.py](src/server/fastapi_app.py)) - Fetches the specific anime from database/SerieList by key
- Calls the provider to get available episodes
- Expanded shutdown sequence in proper order: - Compares with local files to find missing episodes
1. Broadcast shutdown notification via WebSocket - Returns list of missing episodes
2. Stop download service and persist state - Does NOT trigger a full library rescan
3. Clean up progress service (clear subscribers and active progress)
4. Close database connections with WAL checkpoint #### Step 6: Update add_series Endpoint
- Added timeout protection (30 seconds total) with remaining time tracking
- Each step has individual timeout to prevent hanging 1. Open `src/server/routes/anime_routes.py`
2. Modify the `add_series` endpoint to:
#### 4. Uvicorn Graceful Shutdown ([run_server.py](run_server.py)) - **Step A**: Validate the request (existing)
- **Step B**: Create Serie object with sanitized folder name
- Added `timeout_graceful_shutdown=30` parameter to uvicorn.run() - **Step C**: Save to database via `AnimeDBService.create_series()`
- Ensures uvicorn allows sufficient time for lifespan shutdown to complete - **Step D**: Add to SerieList (which creates the folder)
- Updated docstring to document Ctrl+C behavior - **Step E**: Call `SerieScanner.scan_single_series(key)` for targeted scan
- **Step F**: Return response including:
#### 5. Stop Script ([stop_server.sh](stop_server.sh)) - Success status
- Created folder path
- Replaced `kill -9` (SIGKILL) with `kill -TERM` (SIGTERM) - List of missing episodes found (if any)
- Added `wait_for_process()` function that waits up to 30 seconds for graceful shutdown
- Only falls back to SIGKILL if graceful shutdown times out #### Step 7: Update Provider Folder Handling
- Improved user feedback during shutdown process
1. Open `src/core/providers/AniWorldProvider.py`
#### 6. Database WAL Checkpoint ([src/server/database/connection.py](src/server/database/connection.py)) 2. Ensure download operations use `serie.folder` for filesystem paths
3. If `EnhancedProvider.py` exists, update it similarly
- Enhanced `close_db()` to run `PRAGMA wal_checkpoint(TRUNCATE)` for SQLite
- Ensures all pending WAL writes are flushed to main database file ### Acceptance Criteria
- Prevents database corruption during shutdown
- [ ] When adding a new anime, it is immediately saved to the database
### How Graceful Shutdown Works - [ ] When adding a new anime, only that anime is scanned for missing episodes (not full library)
- [ ] Folder is created using the sanitized display name (e.g., "Attack on Titan" not "attack-on-titan")
1. **Ctrl+C or SIGTERM received** → uvicorn catches signal - [ ] Special characters in anime names are properly handled (`:`, `?`, etc.)
2. **uvicorn triggers lifespan shutdown** → FastAPI's lifespan context manager exits - [ ] Existing anime entries continue to work (backward compatibility)
3. **WebSocket broadcast** → All connected clients receive shutdown notification - [ ] API response includes the created folder path and missing episodes count
4. **Download service stops** → Active downloads persisted, executor shutdown - [ ] Unit tests cover the new functionality
5. **Progress service cleanup** → Event subscribers cleared - [ ] No regressions in existing tests
6. **Database cleanup** → WAL checkpoint, connections disposed
7. **Process exits cleanly** → No data loss or corruption ### Testing Requirements
### Testing 1. **Unit Tests**:
```bash - Test `sanitize_folder_name()` with various inputs (special chars, Unicode, edge cases)
# Start server - Test `Serie.folder` property returns sanitized name
conda run -n AniWorld python run_server.py - Test `SerieScanner.scan_single_series()` only scans the specified anime
- Test database persistence on anime add
# Press Ctrl+C to trigger graceful shutdown
# Or use the stop script: 2. **Integration Tests**:
./stop_server.sh - Test full add flow: request → database → folder creation → scan
``` - Test that folder is created with correct name
- Test API response contains expected fields
### Verification
### Error Handling
- All existing tests pass (websocket, download service, database transactions)
- WebSocket clients receive disconnect notification before connection closes - If database save fails, return appropriate error and don't create folder
- Active downloads are preserved and can resume on restart - If folder creation fails (permissions, disk full), return error and rollback database entry
- SQLite WAL file is checkpointed before shutdown - If scan fails, still return success for add but indicate scan failure in response
- Log all operations with appropriate log levels
### Security Considerations
- Sanitize folder names to prevent path traversal attacks
- Validate anime name length to prevent filesystem issues
- Ensure folder is created within the configured library path only
--- ---

View File

@ -461,3 +461,188 @@ class SerieScanner:
episodes_dict[season] = missing_episodes episodes_dict[season] = missing_episodes
return episodes_dict, "aniworld.to" return episodes_dict, "aniworld.to"
def scan_single_series(
self,
key: str,
folder: str,
) -> dict[int, list[int]]:
"""
Scan a single series for missing episodes.
This method performs a targeted scan for only the specified series,
without triggering a full library rescan. It fetches available
episodes from the provider and compares with local files.
Args:
key: The unique provider key for the series
folder: The filesystem folder name where the series is stored
Returns:
dict[int, list[int]]: Dictionary mapping season numbers to lists
of missing episode numbers. Empty dict if no missing episodes.
Raises:
ValueError: If key or folder is empty
Example:
>>> scanner = SerieScanner("/path/to/anime", loader)
>>> missing = scanner.scan_single_series(
... "attack-on-titan",
... "Attack on Titan"
... )
>>> print(missing)
{1: [5, 6, 7], 2: [1, 2]}
"""
if not key or not key.strip():
raise ValueError("Series key cannot be empty")
if not folder or not folder.strip():
raise ValueError("Series folder cannot be empty")
logger.info(
"Starting targeted scan for series: %s (folder: %s)",
key,
folder
)
# Generate unique operation ID for this targeted scan
operation_id = str(uuid.uuid4())
# Notify scan starting
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=1,
percentage=0.0,
message=f"Scanning series: {folder}",
details=f"Key: {key}"
)
)
try:
# Get the folder path
folder_path = os.path.join(self.directory, folder)
# Check if folder exists
if not os.path.isdir(folder_path):
logger.info(
"Series folder does not exist yet: %s - "
"will scan for available episodes from provider",
folder_path
)
mp4_files: list[str] = []
else:
# Find existing MP4 files in the folder
mp4_files = []
for root, _, files in os.walk(folder_path):
for file in files:
if file.endswith(".mp4"):
mp4_files.append(os.path.join(root, file))
logger.debug(
"Found %d existing MP4 files in folder %s",
len(mp4_files),
folder
)
# Get missing episodes from provider
missing_episodes, site = self.__get_missing_episodes_and_season(
key, mp4_files
)
# Update progress
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=1,
total=1,
percentage=100.0,
message=f"Scanned: {folder}",
details=f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
)
)
# Create or update Serie in keyDict
if key in self.keyDict:
# Update existing serie
self.keyDict[key].episodeDict = missing_episodes
logger.debug(
"Updated existing series %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
else:
# Create new serie entry
serie = Serie(
key=key,
name="", # Will be populated by caller if needed
site=site,
folder=folder,
episodeDict=missing_episodes
)
self.keyDict[key] = serie
logger.debug(
"Created new series entry for %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
# Notify completion
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
success=True,
message=f"Scan completed for {folder}",
statistics={
"missing_episodes": sum(
len(eps) for eps in missing_episodes.values()
),
"seasons_with_missing": len(missing_episodes)
}
)
)
logger.info(
"Targeted scan completed for %s: %d missing episodes across %d seasons",
key,
sum(len(eps) for eps in missing_episodes.values()),
len(missing_episodes)
)
return missing_episodes
except Exception as e:
error_msg = f"Failed to scan series {key}: {e}"
logger.error(error_msg, exc_info=True)
# Notify error
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"key": key, "folder": folder}
)
)
# Notify completion with failure
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=operation_id,
success=False,
message=error_msg
)
)
# Return empty dict on error (scan failed but not critical)
return {}

View File

@ -62,31 +62,50 @@ class SerieList:
if not skip_load: if not skip_load:
self.load_series() self.load_series()
def add(self, serie: Serie) -> None: def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
""" """
Persist a new series if it is not already present (file-based mode). Persist a new series if it is not already present (file-based mode).
Uses serie.key for identification. The serie.folder is used for Uses serie.key for identification. Creates the filesystem folder
filesystem operations only. using either the sanitized display name (default) or the existing
folder property.
Args: Args:
serie: The Serie instance to add serie: The Serie instance to add
use_sanitized_folder: If True (default), use serie.sanitized_folder
for the filesystem folder name based on display name.
If False, use serie.folder as-is for backward compatibility.
Returns:
str: The folder path that was created/used
Note: Note:
This method creates data files on disk. For database storage, This method creates data files on disk. For database storage,
use add_to_db() instead. use add_to_db() instead.
""" """
if self.contains(serie.key): if self.contains(serie.key):
return # Return existing folder path
existing = self.keyDict[serie.key]
return os.path.join(self.directory, existing.folder)
data_path = os.path.join(self.directory, serie.folder, "data") # Determine folder name to use
anime_path = os.path.join(self.directory, serie.folder) if use_sanitized_folder:
folder_name = serie.sanitized_folder
# Update the serie's folder property to match what we create
serie.folder = folder_name
else:
folder_name = serie.folder
data_path = os.path.join(self.directory, folder_name, "data")
anime_path = os.path.join(self.directory, folder_name)
os.makedirs(anime_path, exist_ok=True) os.makedirs(anime_path, exist_ok=True)
if not os.path.isfile(data_path): if not os.path.isfile(data_path):
serie.save_to_file(data_path) serie.save_to_file(data_path)
# Store by key, not folder # Store by key, not folder
self.keyDict[serie.key] = serie self.keyDict[serie.key] = serie
return anime_path
def contains(self, key: str) -> bool: def contains(self, key: str) -> bool:
""" """
Return True when a series identified by ``key`` already exists. Return True when a series identified by ``key`` already exists.

View File

@ -1,6 +1,8 @@
import json import json
import warnings import warnings
from src.server.utils.filesystem import sanitize_folder_name
class Serie: class Serie:
""" """
@ -127,6 +129,35 @@ class Serie:
def episodeDict(self, value: dict[int, list[int]]): def episodeDict(self, value: dict[int, list[int]]):
self._episodeDict = value self._episodeDict = value
@property
def sanitized_folder(self) -> str:
"""
Get a filesystem-safe folder name derived from the display name.
This property returns a sanitized version of the series name
suitable for use as a filesystem folder name. It removes/replaces
characters that are invalid for filesystems while preserving
Unicode characters.
Use this property when creating folders for the series on disk.
The `folder` property stores the actual folder name used.
Returns:
str: Filesystem-safe folder name based on display name
Example:
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ...)
>>> serie.sanitized_folder
'Attack on Titan Final'
"""
# Use name if available, fall back to folder, then key
name_to_sanitize = self._name or self._folder or self._key
try:
return sanitize_folder_name(name_to_sanitize)
except ValueError:
# Fallback to key if name cannot be sanitized
return sanitize_folder_name(self._key)
def to_dict(self): def to_dict(self):
"""Convert Serie object to dictionary for JSON serialization.""" """Convert Serie object to dictionary for JSON serialization."""
return { return {

View File

@ -1,4 +1,5 @@
import logging import logging
import os
import warnings import warnings
from typing import Any, List, Optional from typing import Any, List, Optional
@ -21,6 +22,7 @@ from src.server.utils.dependencies import (
get_series_app, get_series_app,
require_auth, require_auth,
) )
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -620,16 +622,20 @@ async def add_series(
_auth: dict = Depends(require_auth), _auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app), series_app: Any = Depends(get_series_app),
db: Optional[AsyncSession] = Depends(get_optional_database_session), db: Optional[AsyncSession] = Depends(get_optional_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict: ) -> dict:
"""Add a new series to the library. """Add a new series to the library with full initialization.
This endpoint performs the complete series addition flow:
1. Validates inputs and extracts the series key from the link URL
2. Creates a sanitized folder name from the display name
3. Saves the series to the database (if available)
4. Creates the folder on disk with the sanitized name
5. Triggers a targeted scan for missing episodes (only this series)
Extracts the series `key` from the provided link URL.
The `key` is the URL-safe identifier used for all lookups. The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata along with a The `name` is stored as display metadata and used to derive
filesystem-friendly `folder` name derived from the name. the filesystem folder name (sanitized for filesystem safety).
Series are saved to the database using AnimeSeriesService when
database is available, falling back to in-memory storage otherwise.
Args: Args:
request: Request containing the series link and name. request: Request containing the series link and name.
@ -638,15 +644,23 @@ async def add_series(
_auth: Ensures the caller is authenticated (value unused) _auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance provided via dependency series_app: Core `SeriesApp` instance provided via dependency
db: Optional database session for async operations db: Optional database session for async operations
anime_service: AnimeService for scanning operations
Returns: Returns:
Dict[str, Any]: Status payload with success message, key, and db_id Dict[str, Any]: Status payload with:
- status: "success" or "exists"
- message: Human-readable status message
- key: Series unique identifier
- folder: Created folder path
- db_id: Database ID (if saved to DB)
- missing_episodes: Dict of missing episodes by season
- total_missing: Total count of missing episodes
Raises: Raises:
HTTPException: If adding the series fails or link is invalid HTTPException: If adding the series fails or link is invalid
""" """
try: try:
# Validate inputs # Step A: Validate inputs
if not request.link or not request.link.strip(): if not request.link or not request.link.strip():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -679,28 +693,40 @@ async def add_series(
detail="Could not extract series key from link", detail="Could not extract series key from link",
) )
# Create folder from name (filesystem-friendly) # Step B: Create sanitized folder name from display name
folder = request.name.strip() name = request.name.strip()
db_id = None try:
folder = sanitize_folder_name(name)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid series name for folder: {str(e)}",
)
# Try to save to database if available db_id = None
missing_episodes: dict = {}
scan_error: Optional[str] = None
# Step C: Save to database if available
if db is not None: if db is not None:
# Check if series already exists in database # Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key) existing = await AnimeSeriesService.get_by_key(db, key)
if existing: if existing:
return { return {
"status": "exists", "status": "exists",
"message": f"Series already exists: {request.name}", "message": f"Series already exists: {name}",
"key": key, "key": key,
"folder": existing.folder, "folder": existing.folder,
"db_id": existing.id "db_id": existing.id,
"missing_episodes": {},
"total_missing": 0
} }
# Save to database using AnimeSeriesService # Save to database using AnimeSeriesService
anime_series = await AnimeSeriesService.create( anime_series = await AnimeSeriesService.create(
db=db, db=db,
key=key, key=key,
name=request.name.strip(), name=name,
site="aniworld.to", site="aniworld.to",
folder=folder, folder=folder,
) )
@ -708,41 +734,109 @@ async def add_series(
logger.info( logger.info(
"Added series to database: %s (key=%s, db_id=%d)", "Added series to database: %s (key=%s, db_id=%d)",
request.name, name,
key, key,
db_id db_id
) )
# Also add to in-memory cache if series_app has the list attribute # Step D: Create folder on disk and add to SerieList
folder_path = None
if series_app and hasattr(series_app, "list"): if series_app and hasattr(series_app, "list"):
serie = Serie( serie = Serie(
key=key, key=key,
name=request.name.strip(), name=name,
site="aniworld.to", site="aniworld.to",
folder=folder, folder=folder,
episodeDict={} episodeDict={}
) )
# Add to in-memory cache
if hasattr(series_app.list, 'keyDict'): # Add to SerieList - this creates the folder with sanitized name
# Direct update without file saving if hasattr(series_app.list, 'add'):
series_app.list.keyDict[key] = serie
elif hasattr(series_app.list, 'add'):
# Legacy: use add method (may create file with deprecation warning)
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning) warnings.simplefilter("ignore", DeprecationWarning)
series_app.list.add(serie) folder_path = series_app.list.add(serie, use_sanitized_folder=True)
# Update folder to reflect what was actually created
folder = serie.folder
elif hasattr(series_app.list, 'keyDict'):
# Manual folder creation and cache update
if hasattr(series_app.list, 'directory'):
folder_path = os.path.join(series_app.list.directory, folder)
os.makedirs(folder_path, exist_ok=True)
series_app.list.keyDict[key] = serie
return { logger.info(
"status": "success", "Created folder for series: %s at %s",
"message": f"Successfully added series: {request.name}", name,
"key": key, folder_path or folder
"folder": folder, )
"db_id": db_id
# Step E: Trigger targeted scan for missing episodes
try:
if series_app and hasattr(series_app, "scanner"):
missing_episodes = series_app.scanner.scan_single_series(
key=key,
folder=folder
)
logger.info(
"Targeted scan completed for %s: found %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
# Update the serie in keyDict with the missing episodes
if hasattr(series_app, "list") and hasattr(series_app.list, "keyDict"):
if key in series_app.list.keyDict:
series_app.list.keyDict[key].episodeDict = missing_episodes
elif anime_service:
# Fallback to anime_service if scanner not directly available
# Note: This is a lightweight scan, not a full rescan
logger.info(
"Scanner not directly available, "
"skipping targeted scan for %s",
key
)
except Exception as e:
# Scan failure is not critical - series was still added
scan_error = str(e)
logger.warning(
"Targeted scan failed for %s: %s (series still added)",
key,
e
)
# Convert missing episodes keys to strings for JSON serialization
missing_episodes_serializable = {
str(season): episodes
for season, episodes in missing_episodes.items()
} }
# Calculate total missing
total_missing = sum(len(eps) for eps in missing_episodes.values())
# Step F: Return response
response = {
"status": "success",
"message": f"Successfully added series: {name}",
"key": key,
"folder": folder_path or folder,
"db_id": db_id,
"missing_episodes": missing_episodes_serializable,
"total_missing": total_missing
}
if scan_error:
response["scan_warning"] = f"Scan partially failed: {scan_error}"
return response
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception as exc:
logger.error("Failed to add series: %s", exc, exc_info=True) logger.error("Failed to add series: %s", exc, exc_info=True)
# Attempt to rollback database entry if folder creation failed
# (This is a best-effort cleanup)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add series: {str(exc)}", detail=f"Failed to add series: {str(exc)}",

View File

@ -0,0 +1,180 @@
"""Filesystem utilities for safe file and folder operations.
This module provides utility functions for safely handling filesystem
operations, including sanitizing folder names and path validation.
Security:
- All functions sanitize inputs to prevent path traversal attacks
- Invalid filesystem characters are removed or replaced
- Unicode characters are preserved for international titles
"""
import os
import re
import unicodedata
from typing import Optional
# Characters that are invalid in filesystem paths across platforms
# Windows: < > : " / \ | ? *
# Linux/Mac: / and null byte
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
# Additional characters to remove for cleaner folder names
EXTRA_CLEANUP_CHARS = '\r\n\t'
# Maximum folder name length (conservative for cross-platform compatibility)
MAX_FOLDER_NAME_LENGTH = 200
def sanitize_folder_name(
name: str,
replacement: str = "",
max_length: Optional[int] = None,
) -> str:
"""Sanitize a string for use as a filesystem folder name.
Removes or replaces characters that are invalid for filesystems while
preserving Unicode characters (for Japanese/Chinese titles, etc.).
Args:
name: The string to sanitize (e.g., anime display name)
replacement: Character to replace invalid chars with (default: "")
max_length: Maximum length for the result (default: MAX_FOLDER_NAME_LENGTH)
Returns:
str: A filesystem-safe folder name
Raises:
ValueError: If name is None, empty, or results in empty string
Examples:
>>> sanitize_folder_name("Attack on Titan: Final Season")
'Attack on Titan Final Season'
>>> sanitize_folder_name("What If...?")
'What If...'
>>> sanitize_folder_name("Re:Zero")
'ReZero'
>>> sanitize_folder_name("日本語タイトル")
'日本語タイトル'
"""
if name is None:
raise ValueError("Folder name cannot be None")
# Strip leading/trailing whitespace
name = name.strip()
if not name:
raise ValueError("Folder name cannot be empty")
max_len = max_length or MAX_FOLDER_NAME_LENGTH
# Normalize Unicode characters (NFC form for consistency)
name = unicodedata.normalize('NFC', name)
# Remove invalid filesystem characters
for char in INVALID_PATH_CHARS:
name = name.replace(char, replacement)
# Remove extra cleanup characters
for char in EXTRA_CLEANUP_CHARS:
name = name.replace(char, replacement)
# Remove control characters but preserve Unicode
name = ''.join(
char for char in name
if not unicodedata.category(char).startswith('C')
or char == ' ' # Preserve spaces
)
# Collapse multiple consecutive spaces
name = re.sub(r' +', ' ', name)
# Remove leading/trailing dots and whitespace
# (dots at start can make folders hidden on Unix)
name = name.strip('. ')
# Handle edge case: all characters were invalid
if not name:
raise ValueError(
"Folder name contains only invalid characters"
)
# Truncate to max length while avoiding breaking in middle of word
if len(name) > max_len:
# Try to truncate at a word boundary
truncated = name[:max_len]
last_space = truncated.rfind(' ')
if last_space > max_len // 2: # Only if we don't lose too much
truncated = truncated[:last_space]
name = truncated.rstrip()
return name
def is_safe_path(base_path: str, target_path: str) -> bool:
"""Check if target_path is safely within base_path.
Prevents path traversal attacks by ensuring the target path
is actually within the base path after resolution.
Args:
base_path: The base directory that should contain the target
target_path: The path to validate
Returns:
bool: True if target_path is safely within base_path
Example:
>>> is_safe_path("/anime", "/anime/Attack on Titan")
True
>>> is_safe_path("/anime", "/anime/../etc/passwd")
False
"""
# Resolve to absolute paths
base_resolved = os.path.abspath(base_path)
target_resolved = os.path.abspath(target_path)
# Check that target starts with base (with trailing separator)
base_with_sep = base_resolved + os.sep
return (
target_resolved == base_resolved or
target_resolved.startswith(base_with_sep)
)
def create_safe_folder(
base_path: str,
folder_name: str,
exist_ok: bool = True,
) -> str:
"""Create a folder with a sanitized name safely within base_path.
Args:
base_path: Base directory to create folder within
folder_name: Unsanitized folder name
exist_ok: If True, don't raise error if folder exists
Returns:
str: Full path to the created folder
Raises:
ValueError: If resulting path would be outside base_path
OSError: If folder creation fails
"""
# Sanitize the folder name
safe_name = sanitize_folder_name(folder_name)
# Construct full path
full_path = os.path.join(base_path, safe_name)
# Validate path safety
if not is_safe_path(base_path, full_path):
raise ValueError(
f"Folder name '{folder_name}' would create path outside "
f"base directory"
)
# Create the folder
os.makedirs(full_path, exist_ok=exist_ok)
return full_path

View File

@ -42,11 +42,17 @@ class FakeSeriesApp:
def __init__(self): def __init__(self):
"""Initialize fake series app.""" """Initialize fake series app."""
self.list = self # Changed from self.List to self.list self.list = self # Changed from self.List to self.list
self.scanner = FakeScanner() # Add fake scanner
self.directory = "/tmp/fake_anime"
self.keyDict = {} # Add keyDict for direct access
self._items = [ self._items = [
# Using realistic key values (URL-safe, lowercase, hyphenated) # Using realistic key values (URL-safe, lowercase, hyphenated)
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}), FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}), FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}),
] ]
# Populate keyDict
for item in self._items:
self.keyDict[item.key] = item
def GetMissingEpisode(self): def GetMissingEpisode(self):
"""Return series with missing episodes.""" """Return series with missing episodes."""
@ -60,11 +66,21 @@ class FakeSeriesApp:
"""Trigger rescan with callback.""" """Trigger rescan with callback."""
callback() callback()
def add(self, serie): def add(self, serie, use_sanitized_folder=True):
"""Add a serie to the list.""" """Add a serie to the list.
Args:
serie: The Serie instance to add
use_sanitized_folder: Whether to use sanitized folder name
Returns:
str: The folder path (fake path for testing)
"""
# Check if already exists # Check if already exists
if not any(s.key == serie.key for s in self._items): if not any(s.key == serie.key for s in self._items):
self._items.append(serie) self._items.append(serie)
self.keyDict[serie.key] = serie
return f"/tmp/fake_anime/{serie.folder}"
async def search(self, query): async def search(self, query):
"""Search for series (async).""" """Search for series (async)."""
@ -85,6 +101,14 @@ class FakeSeriesApp:
pass pass
class FakeScanner:
"""Mock SerieScanner for testing."""
def scan_single_series(self, key, folder):
"""Mock scan that returns some fake missing episodes."""
return {1: [1, 2, 3], 2: [1, 2]}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_auth_state(): def reset_auth_state():
"""Reset auth service state before each test.""" """Reset auth service state before each test."""
@ -273,3 +297,122 @@ async def test_add_series_endpoint_empty_link(authenticated_client):
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
assert "link" in data["detail"].lower() assert "link" in data["detail"].lower()
@pytest.mark.asyncio
async def test_add_series_extracts_key_from_full_url(authenticated_client):
"""Test that add_series extracts key from full URL."""
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": "https://aniworld.to/anime/stream/attack-on-titan",
"name": "Attack on Titan"
}
)
assert response.status_code == 200
data = response.json()
assert data["key"] == "attack-on-titan"
@pytest.mark.asyncio
async def test_add_series_sanitizes_folder_name(authenticated_client):
"""Test that add_series creates sanitized folder name."""
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": "https://aniworld.to/anime/stream/rezero",
"name": "Re:Zero - Starting Life in Another World?"
}
)
assert response.status_code == 200
data = response.json()
# Folder should not contain invalid characters
folder = data["folder"]
assert ":" not in folder
assert "?" not in folder
@pytest.mark.asyncio
async def test_add_series_returns_missing_episodes(authenticated_client):
"""Test that add_series returns missing episodes info."""
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": "https://aniworld.to/anime/stream/test-anime",
"name": "Test Anime"
}
)
assert response.status_code == 200
data = response.json()
# Response should contain missing episodes fields
assert "missing_episodes" in data
assert "total_missing" in data
assert isinstance(data["missing_episodes"], dict)
assert isinstance(data["total_missing"], int)
@pytest.mark.asyncio
async def test_add_series_response_structure(authenticated_client):
"""Test the full response structure of add_series."""
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": "https://aniworld.to/anime/stream/new-anime",
"name": "New Anime Series"
}
)
assert response.status_code == 200
data = response.json()
# Verify all expected fields are present
assert "status" in data
assert "message" in data
assert "key" in data
assert "folder" in data
assert "missing_episodes" in data
assert "total_missing" in data
# Status should be success or exists
assert data["status"] in ("success", "exists")
@pytest.mark.asyncio
async def test_add_series_special_characters_in_name(authenticated_client):
"""Test adding series with various special characters in name."""
test_cases = [
("86: Eighty-Six", "86-eighty-six"),
("Fate/Stay Night", "fate-stay-night"),
("What If...?", "what-if"),
("Steins;Gate", "steins-gate"),
]
for name, key in test_cases:
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": f"https://aniworld.to/anime/stream/{key}",
"name": name
}
)
assert response.status_code == 200
data = response.json()
# Get just the folder name (last part of path)
folder_path = data["folder"]
# Handle both full paths and just folder names
if "/" in folder_path:
folder_name = folder_path.rstrip("/").split("/")[-1]
else:
folder_name = folder_path
# Folder name should not contain invalid filesystem characters
invalid_chars = [':', '\\', '?', '*', '<', '>', '|', '"']
for char in invalid_chars:
assert char not in folder_name, f"Found '{char}' in folder name for {name}"

View File

@ -0,0 +1,295 @@
"""
Unit tests for filesystem utilities.
Tests the sanitize_folder_name function and related filesystem utilities.
"""
import os
import tempfile
import pytest
from src.server.utils.filesystem import (
MAX_FOLDER_NAME_LENGTH,
create_safe_folder,
is_safe_path,
sanitize_folder_name,
)
class TestSanitizeFolderName:
"""Test sanitize_folder_name function."""
def test_simple_name(self):
"""Test sanitizing a simple name with no special characters."""
assert sanitize_folder_name("Attack on Titan") == "Attack on Titan"
def test_name_with_colon(self):
"""Test sanitizing name with colon."""
result = sanitize_folder_name("Attack on Titan: Final Season")
assert ":" not in result
assert result == "Attack on Titan Final Season"
def test_name_with_question_mark(self):
"""Test sanitizing name with question mark."""
result = sanitize_folder_name("What If...?")
assert "?" not in result
# Trailing dots are stripped
assert result == "What If"
def test_name_with_multiple_special_chars(self):
"""Test sanitizing name with multiple special characters."""
result = sanitize_folder_name('Test: "Episode" <1> | Part?')
# All invalid chars should be removed
assert ":" not in result
assert '"' not in result
assert "<" not in result
assert ">" not in result
assert "|" not in result
assert "?" not in result
def test_name_with_forward_slash(self):
"""Test sanitizing name with forward slash."""
result = sanitize_folder_name("Attack/Titan")
assert "/" not in result
def test_name_with_backslash(self):
"""Test sanitizing name with backslash."""
result = sanitize_folder_name("Attack\\Titan")
assert "\\" not in result
def test_unicode_characters_preserved(self):
"""Test that Unicode characters are preserved."""
# Japanese title
result = sanitize_folder_name("進撃の巨人")
assert result == "進撃の巨人"
def test_mixed_unicode_and_special(self):
"""Test mixed Unicode and special characters."""
result = sanitize_folder_name("Re:ゼロ")
assert ":" not in result
assert "ゼロ" in result
def test_leading_dots_removed(self):
"""Test that leading dots are removed."""
result = sanitize_folder_name("...Hidden Folder")
assert not result.startswith(".")
def test_trailing_dots_removed(self):
"""Test that trailing dots are removed."""
result = sanitize_folder_name("Folder Name...")
assert not result.endswith(".")
def test_leading_spaces_removed(self):
"""Test that leading spaces are removed."""
result = sanitize_folder_name(" Attack on Titan")
assert result == "Attack on Titan"
def test_trailing_spaces_removed(self):
"""Test that trailing spaces are removed."""
result = sanitize_folder_name("Attack on Titan ")
assert result == "Attack on Titan"
def test_multiple_spaces_collapsed(self):
"""Test that multiple consecutive spaces are collapsed."""
result = sanitize_folder_name("Attack on Titan")
assert result == "Attack on Titan"
def test_null_byte_removed(self):
"""Test that null byte is removed."""
result = sanitize_folder_name("Attack\x00Titan")
assert "\x00" not in result
def test_newline_removed(self):
"""Test that newline is removed."""
result = sanitize_folder_name("Attack\nTitan")
assert "\n" not in result
def test_tab_removed(self):
"""Test that tab is removed."""
result = sanitize_folder_name("Attack\tTitan")
assert "\t" not in result
def test_none_raises_error(self):
"""Test that None raises ValueError."""
with pytest.raises(ValueError, match="cannot be None"):
sanitize_folder_name(None)
def test_empty_string_raises_error(self):
"""Test that empty string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
sanitize_folder_name("")
def test_whitespace_only_raises_error(self):
"""Test that whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
sanitize_folder_name(" ")
def test_only_invalid_chars_raises_error(self):
"""Test that string with only invalid characters raises ValueError."""
with pytest.raises(ValueError, match="only invalid characters"):
sanitize_folder_name("???:::***")
def test_max_length_truncation(self):
"""Test that long names are truncated."""
long_name = "A" * 300
result = sanitize_folder_name(long_name)
assert len(result) <= MAX_FOLDER_NAME_LENGTH
def test_max_length_custom(self):
"""Test custom max length."""
result = sanitize_folder_name("Attack on Titan", max_length=10)
assert len(result) <= 10
def test_truncation_at_word_boundary(self):
"""Test that truncation happens at word boundary when possible."""
result = sanitize_folder_name(
"The Very Long Anime Title That Needs Truncation",
max_length=25
)
# Should truncate at word boundary
assert len(result) <= 25
assert not result.endswith(" ")
def test_custom_replacement_character(self):
"""Test custom replacement character."""
result = sanitize_folder_name("Test:Name", replacement="_")
assert ":" not in result
assert "Test_Name" == result
def test_asterisk_removed(self):
"""Test that asterisk is removed."""
result = sanitize_folder_name("Attack*Titan")
assert "*" not in result
def test_pipe_removed(self):
"""Test that pipe is removed."""
result = sanitize_folder_name("Attack|Titan")
assert "|" not in result
def test_real_anime_titles(self):
"""Test real anime titles with special characters."""
# Test that invalid filesystem characters are removed
# Note: semicolon is NOT an invalid filesystem character
test_cases = [
("Re:Zero", ":"), # colon should be removed
("86: Eighty-Six", ":"), # colon should be removed
("Fate/Stay Night", "/"), # slash should be removed
("Sword Art Online: Alicization", ":"), # colon should be removed
("What If...?", "?"), # question mark should be removed
]
for input_name, forbidden_char in test_cases:
result = sanitize_folder_name(input_name)
assert forbidden_char not in result, f"'{forbidden_char}' should be removed from '{input_name}'"
class TestIsSafePath:
"""Test is_safe_path function."""
def test_valid_subpath(self):
"""Test that valid subpath returns True."""
assert is_safe_path("/anime", "/anime/Attack on Titan")
def test_exact_match(self):
"""Test that exact match returns True."""
assert is_safe_path("/anime", "/anime")
def test_path_traversal_rejected(self):
"""Test that path traversal is rejected."""
assert not is_safe_path("/anime", "/anime/../etc/passwd")
def test_parent_directory_rejected(self):
"""Test that parent directory is rejected."""
assert not is_safe_path("/anime/series", "/anime")
def test_sibling_directory_rejected(self):
"""Test that sibling directory is rejected."""
assert not is_safe_path("/anime", "/movies/film")
def test_nested_subpath(self):
"""Test deeply nested valid subpath."""
assert is_safe_path(
"/anime",
"/anime/Attack on Titan/Season 1/Episode 1"
)
class TestCreateSafeFolder:
"""Test create_safe_folder function."""
def test_creates_folder_with_sanitized_name(self):
"""Test that folder is created with sanitized name."""
with tempfile.TemporaryDirectory() as tmpdir:
path = create_safe_folder(tmpdir, "Attack: Titan?")
assert os.path.isdir(path)
assert ":" not in os.path.basename(path)
assert "?" not in os.path.basename(path)
def test_returns_full_path(self):
"""Test that full path is returned."""
with tempfile.TemporaryDirectory() as tmpdir:
path = create_safe_folder(tmpdir, "Test Folder")
assert path.startswith(tmpdir)
assert "Test Folder" in path
def test_exist_ok_true(self):
"""Test that existing folder doesn't raise with exist_ok=True."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create first time
path1 = create_safe_folder(tmpdir, "Test Folder")
# Create second time - should not raise
path2 = create_safe_folder(tmpdir, "Test Folder", exist_ok=True)
assert path1 == path2
def test_rejects_path_traversal(self):
"""Test that path traversal is rejected after sanitization."""
with tempfile.TemporaryDirectory() as tmpdir:
# After sanitization, "../../../etc" becomes "etc" (dots removed)
# So this test verifies the folder is created safely
# The sanitization removes the path traversal attempt
path = create_safe_folder(tmpdir, "../../../etc")
# The folder should be created within tmpdir, not escape it
assert is_safe_path(tmpdir, path)
# Folder name should be "etc" after sanitization (dots stripped)
assert os.path.basename(path) == "etc"
class TestSanitizeFolderNameEdgeCases:
"""Test edge cases for sanitize_folder_name."""
def test_control_characters_removed(self):
"""Test that control characters are removed."""
# ASCII control characters
result = sanitize_folder_name("Test\x01\x02\x03Name")
assert "\x01" not in result
assert "\x02" not in result
assert "\x03" not in result
def test_carriage_return_removed(self):
"""Test that carriage return is removed."""
result = sanitize_folder_name("Test\rName")
assert "\r" not in result
def test_unicode_normalization(self):
"""Test that Unicode is normalized."""
# Composed vs decomposed forms
result = sanitize_folder_name("café")
# Should be normalized to NFC form
assert result == "café"
def test_emoji_handling(self):
"""Test handling of emoji characters."""
result = sanitize_folder_name("Anime 🎬 Title")
# Emoji should be preserved (valid Unicode)
assert "🎬" in result or "Anime" in result
def test_single_character_name(self):
"""Test single character name."""
result = sanitize_folder_name("A")
assert result == "A"
def test_numbers_preserved(self):
"""Test that numbers are preserved."""
result = sanitize_folder_name("86: Eighty-Six (2021)")
assert "86" in result
assert "2021" in result

View File

@ -320,3 +320,96 @@ class TestSerieDeprecationWarnings:
finally: finally:
if os.path.exists(temp_filename): if os.path.exists(temp_filename):
os.remove(temp_filename) os.remove(temp_filename)
class TestSerieSanitizedFolder:
"""Test Serie.sanitized_folder property."""
def test_sanitized_folder_from_name(self):
"""Test that sanitized_folder uses the name property."""
serie = Serie(
key="attack-on-titan",
name="Attack on Titan: Final Season",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert ":" not in result
assert "Attack on Titan" in result
def test_sanitized_folder_removes_special_chars(self):
"""Test that special characters are removed."""
serie = Serie(
key="re-zero",
name="Re:Zero - Starting Life in Another World?",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert ":" not in result
assert "?" not in result
def test_sanitized_folder_fallback_to_folder(self):
"""Test fallback to folder when name is empty."""
serie = Serie(
key="test-key",
name="",
site="aniworld.to",
folder="Valid Folder Name",
episodeDict={}
)
result = serie.sanitized_folder
assert result == "Valid Folder Name"
def test_sanitized_folder_fallback_to_key(self):
"""Test fallback to key when name and folder can't be sanitized."""
serie = Serie(
key="valid-key",
name="",
site="aniworld.to",
folder="",
episodeDict={}
)
result = serie.sanitized_folder
assert result == "valid-key"
def test_sanitized_folder_preserves_unicode(self):
"""Test that Unicode characters are preserved."""
serie = Serie(
key="japanese-anime",
name="進撃の巨人",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert "進撃の巨人" in result
def test_sanitized_folder_with_various_anime_titles(self):
"""Test sanitized_folder with real anime titles."""
test_cases = [
("fate-stay-night", "Fate/Stay Night: UBW"),
("86-eighty-six", "86: Eighty-Six"),
("steins-gate", "Steins;Gate"),
]
for key, name in test_cases:
serie = Serie(
key=key,
name=name,
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
# Verify invalid filesystem characters are removed
# Note: semicolon is valid on Linux but we test common invalid chars
assert ":" not in result
assert "/" not in result

View File

@ -134,3 +134,186 @@ class TestSerieScannerScan:
scanner.scan() scanner.scan()
assert sample_serie.key in scanner.keyDict assert sample_serie.key in scanner.keyDict
class TestSerieScannerSingleSeries:
"""Test scan_single_series method for targeted scanning."""
def test_scan_single_series_basic(
self, temp_directory, mock_loader
):
"""Test basic scan_single_series functionality."""
scanner = SerieScanner(temp_directory, mock_loader)
# Mock the missing episodes calculation
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [5, 6, 7], 2: [1, 2]}, "aniworld.to")
):
result = scanner.scan_single_series(
key="attack-on-titan",
folder="Attack on Titan (2013)"
)
# Verify result structure
assert isinstance(result, dict)
assert 1 in result
assert 2 in result
assert result[1] == [5, 6, 7]
assert result[2] == [1, 2]
def test_scan_single_series_updates_keydict(
self, temp_directory, mock_loader
):
"""Test that scan_single_series updates keyDict."""
scanner = SerieScanner(temp_directory, mock_loader)
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [1, 2, 3]}, "aniworld.to")
):
scanner.scan_single_series(
key="test-anime",
folder="Test Anime"
)
# Verify keyDict was updated
assert "test-anime" in scanner.keyDict
assert scanner.keyDict["test-anime"].episodeDict == {1: [1, 2, 3]}
def test_scan_single_series_existing_entry(
self, temp_directory, mock_loader, sample_serie
):
"""Test scan_single_series updates existing entry in keyDict."""
scanner = SerieScanner(temp_directory, mock_loader)
# Pre-populate keyDict
scanner.keyDict[sample_serie.key] = sample_serie
old_episode_dict = sample_serie.episodeDict.copy()
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [10, 11, 12]}, "aniworld.to")
):
scanner.scan_single_series(
key=sample_serie.key,
folder=sample_serie.folder
)
# Verify existing entry was updated
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [10, 11, 12]}
def test_scan_single_series_empty_key_raises_error(
self, temp_directory, mock_loader
):
"""Test that empty key raises ValueError."""
scanner = SerieScanner(temp_directory, mock_loader)
with pytest.raises(ValueError, match="key cannot be empty"):
scanner.scan_single_series(key="", folder="Test Folder")
def test_scan_single_series_empty_folder_raises_error(
self, temp_directory, mock_loader
):
"""Test that empty folder raises ValueError."""
scanner = SerieScanner(temp_directory, mock_loader)
with pytest.raises(ValueError, match="folder cannot be empty"):
scanner.scan_single_series(key="test-key", folder="")
def test_scan_single_series_nonexistent_folder(
self, temp_directory, mock_loader
):
"""Test scanning a series with non-existent folder."""
scanner = SerieScanner(temp_directory, mock_loader)
# Mock to return some episodes (as if from provider)
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [1, 2, 3, 4, 5]}, "aniworld.to")
):
result = scanner.scan_single_series(
key="new-anime",
folder="NonExistent Folder"
)
# Should still return missing episodes from provider
assert result == {1: [1, 2, 3, 4, 5]}
def test_scan_single_series_error_handling(
self, temp_directory, mock_loader
):
"""Test that errors during scan return empty dict."""
scanner = SerieScanner(temp_directory, mock_loader)
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
side_effect=Exception("Provider error")
):
result = scanner.scan_single_series(
key="test-anime",
folder="Test Folder"
)
# Should return empty dict on error
assert result == {}
def test_scan_single_series_no_missing_episodes(
self, temp_directory, mock_loader
):
"""Test scan when no episodes are missing."""
scanner = SerieScanner(temp_directory, mock_loader)
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to")
):
result = scanner.scan_single_series(
key="complete-anime",
folder="Complete Anime"
)
assert result == {}
assert "complete-anime" in scanner.keyDict
assert scanner.keyDict["complete-anime"].episodeDict == {}
def test_scan_single_series_with_existing_files(
self, temp_directory, mock_loader
):
"""Test scan with existing MP4 files in folder."""
# Create folder with some files
anime_folder = os.path.join(temp_directory, "Test Anime")
os.makedirs(anime_folder, exist_ok=True)
season_folder = os.path.join(anime_folder, "Season 1")
os.makedirs(season_folder, exist_ok=True)
# Create dummy MP4 files
for ep in [1, 2, 3]:
mp4_path = os.path.join(
season_folder, f"Test Anime - S01E{ep:03d} - (German Dub).mp4"
)
with open(mp4_path, "w") as f:
f.write("dummy")
scanner = SerieScanner(temp_directory, mock_loader)
# Mock to return missing episodes (4, 5, 6)
with patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [4, 5, 6]}, "aniworld.to")
):
result = scanner.scan_single_series(
key="test-anime",
folder="Test Anime"
)
# Should only show missing episodes
assert result == {1: [4, 5, 6]}