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:
parent
f28dc756c5
commit
1b7ca7b4da
@ -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"
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@ -62,30 +62,49 @@ 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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
Extracts the series `key` from the provided link URL.
|
This endpoint performs the complete series addition flow:
|
||||||
The `key` is the URL-safe identifier used for all lookups.
|
1. Validates inputs and extracts the series key from the link URL
|
||||||
The `name` is stored as display metadata along with a
|
2. Creates a sanitized folder name from the display name
|
||||||
filesystem-friendly `folder` name derived from the 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)
|
||||||
|
|
||||||
Series are saved to the database using AnimeSeriesService when
|
The `key` is the URL-safe identifier used for all lookups.
|
||||||
database is available, falling back to in-memory storage otherwise.
|
The `name` is stored as display metadata and used to derive
|
||||||
|
the filesystem folder name (sanitized for filesystem safety).
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created folder for series: %s at %s",
|
||||||
|
name,
|
||||||
|
folder_path or folder
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
# Step E: Trigger targeted scan for missing episodes
|
||||||
"status": "success",
|
try:
|
||||||
"message": f"Successfully added series: {request.name}",
|
if series_app and hasattr(series_app, "scanner"):
|
||||||
"key": key,
|
missing_episodes = series_app.scanner.scan_single_series(
|
||||||
"folder": folder,
|
key=key,
|
||||||
"db_id": db_id
|
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)}",
|
||||||
|
|||||||
180
src/server/utils/filesystem.py
Normal file
180
src/server/utils/filesystem.py
Normal 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
|
||||||
@ -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}"
|
||||||
|
|||||||
295
tests/unit/test_filesystem_utils.py
Normal file
295
tests/unit/test_filesystem_utils.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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]}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user