Refactor: Defer folder creation to download time

- Remove folder creation from add_series endpoint
- Add folder creation to download() method in SeriesApp
- Maintain database persistence and targeted scanning
- Update tests to use tmp_path fixtures
- All add_series and download tests passing (13/13)
This commit is contained in:
Lukas 2026-01-11 17:15:59 +01:00
parent 3d2ef53463
commit 5c0a019e72
4 changed files with 96 additions and 47 deletions

View File

@ -108,34 +108,47 @@ For each task completed:
## TODO List: ## TODO List:
### Completed Tasks: ### Task: Refactor Series Addition and Folder Creation Logic
1. **✅ Fixed copy issue to folder /mnt/server/serien/Serien/** (Completed: 2026-01-09) **Priority:** High
**Status:** ✅ Complete
**Issue**: PermissionError when copying downloaded files to target directory #### Overview
``` Refactored the series addition workflow to defer folder creation until download time and ensure proper series scanning on addition. This improves the separation of concerns and ensures a cleaner workflow.
PermissionError: [Errno 13] Permission denied: '/mnt/server/serien/Serien/Gachiakuta (2025)/Season 1/Gachiakuta - S01E023 - (German Dub).mp4'
```
**Root Cause**: #### Completed Changes
- `shutil.copy2()` and `shutil.copy()` attempt to preserve file metadata (permissions, timestamps, ownership) 1. **Folder Creation Removed from Series Addition**
- Preserving metadata requires special permissions on the target directory - Modified [src/server/api/anime.py](../src/server/api/anime.py) to remove folder creation on series add
- The mounted network directory `/mnt/server/serien/Serien/` has restricted metadata permissions - Series are now only added to the database and in-memory structures
- Folder creation is deferred to download time
**Solution**: 2. **Folder Creation Added to Download Start**
- Updated [src/core/SeriesApp.py](../src/core/SeriesApp.py) `download()` method
- Added folder existence check before download
- Creates folder if it doesn't exist using the series folder name
- Includes proper error handling and logging
- Replaced `shutil.copy2()` with `shutil.copyfile()` in [enhanced_provider.py](../src/core/providers/enhanced_provider.py#L558) 3. **Database Persistence Maintained**
- Replaced `shutil.copy()` with `shutil.copyfile()` in [aniworld_provider.py](../src/core/providers/aniworld_provider.py#L329) - Series are still properly saved to the database on addition
- `shutil.copyfile()` only copies file content without attempting to preserve metadata - No regression in database entry creation
**Verification**: 4. **Targeted Scanning Works**
- Scan logic continues to work correctly
- Only the added series is scanned (not full library rescan)
- Works correctly even when folder doesn't exist yet
- Created comprehensive tests confirming the fix works #### Test Results
- Download process can now successfully copy files to `/mnt/server/serien/Serien/`
- Both providers (aniworld and enhanced) updated
### Active Tasks: - All add_series endpoint tests passing (9/9)
- All SeriesApp download tests passing (4/4)
- Total: 1132 tests passing (up from 1123 before changes)
- Remaining failures are unrelated to these changes (scan_service, download_service issues)
#### Note on Year Attribute
Year information is not currently available in the Serie class or search results. The folder naming currently uses just the series name without the year suffix. This can be enhanced in a future task when year metadata is added to the system.
---
_No active tasks at the moment._

View File

@ -12,6 +12,7 @@ Note:
import asyncio import asyncio
import logging import logging
import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -317,6 +318,36 @@ class SeriesApp:
) )
) )
# Create series folder if it doesn't exist
folder_path = os.path.join(self.directory_to_search, serie_folder)
if not os.path.exists(folder_path):
try:
os.makedirs(folder_path, exist_ok=True)
logger.info(
"Created series folder: %s (key: %s)",
folder_path,
key
)
except OSError as e:
logger.error(
"Failed to create series folder %s: %s",
folder_path,
str(e)
)
# Fire download failed event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="failed",
message=f"Failed to create folder: {str(e)}",
item_id=item_id,
)
)
return False
try: try:
def download_progress_handler(progress_info): def download_progress_handler(progress_info):
"""Handle download progress events from loader.""" """Handle download progress events from loader."""

View File

@ -739,8 +739,7 @@ async def add_series(
db_id db_id
) )
# Step D: Create folder on disk and add to SerieList # Step D: Add to SerieList (in-memory only, no folder creation)
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,
@ -750,25 +749,15 @@ async def add_series(
episodeDict={} episodeDict={}
) )
# Add to SerieList - this creates the folder with sanitized name # Add to in-memory cache without creating folder on disk
if hasattr(series_app.list, 'add'): if hasattr(series_app.list, 'keyDict'):
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
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 series_app.list.keyDict[key] = serie
logger.info(
logger.info( "Added series to in-memory cache: %s (key=%s, folder=%s)",
"Created folder for series: %s at %s", name,
name, key,
folder_path or folder folder
) )
# Step E: Trigger targeted scan for missing episodes # Step E: Trigger targeted scan for missing episodes
try: try:
@ -818,7 +807,7 @@ async def add_series(
"status": "success", "status": "success",
"message": f"Successfully added series: {name}", "message": f"Successfully added series: {name}",
"key": key, "key": key,
"folder": folder_path or folder, "folder": folder,
"db_id": db_id, "db_id": db_id,
"missing_episodes": missing_episodes_serializable, "missing_episodes": missing_episodes_serializable,
"total_missing": total_missing "total_missing": total_missing

View File

@ -107,10 +107,14 @@ class TestSeriesAppDownload:
@patch('src.core.SeriesApp.SerieScanner') @patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList') @patch('src.core.SeriesApp.SerieList')
async def test_download_success( async def test_download_success(
self, mock_serie_list, mock_scanner, mock_loaders self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
): ):
"""Test successful download.""" """Test successful download."""
test_dir = "/test/anime" test_dir = str(tmp_path / "anime")
# Create the test directory
import os
os.makedirs(test_dir, exist_ok=True)
app = SeriesApp(test_dir) app = SeriesApp(test_dir)
# Mock the events to prevent NoneType errors # Mock the events to prevent NoneType errors
@ -131,15 +135,23 @@ class TestSeriesAppDownload:
assert result is True assert result is True
app.loader.download.assert_called_once() app.loader.download.assert_called_once()
# Verify folder was created
folder_path = os.path.join(test_dir, "anime_folder")
assert os.path.exists(folder_path)
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders') @patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner') @patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList') @patch('src.core.SeriesApp.SerieList')
async def test_download_with_progress_callback( async def test_download_with_progress_callback(
self, mock_serie_list, mock_scanner, mock_loaders self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
): ):
"""Test download with progress callback.""" """Test download with progress callback."""
test_dir = "/test/anime" test_dir = str(tmp_path / "anime")
# Create the test directory
import os
os.makedirs(test_dir, exist_ok=True)
app = SeriesApp(test_dir) app = SeriesApp(test_dir)
# Mock the events # Mock the events
@ -172,10 +184,14 @@ class TestSeriesAppDownload:
@patch('src.core.SeriesApp.SerieScanner') @patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList') @patch('src.core.SeriesApp.SerieList')
async def test_download_cancellation( async def test_download_cancellation(
self, mock_serie_list, mock_scanner, mock_loaders self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
): ):
"""Test download cancellation during operation.""" """Test download cancellation during operation."""
test_dir = "/test/anime" test_dir = str(tmp_path / "anime")
# Create the test directory
import os
os.makedirs(test_dir, exist_ok=True)
app = SeriesApp(test_dir) app = SeriesApp(test_dir)
# Mock the events # Mock the events