Task 3.1: Standardize series identifiers in DownloadService

- Updated DownloadService to use 'serie_id' (provider key) for identification
- Changed 'serie_folder' from Optional to required in models (DownloadItem, DownloadRequest)
- Removed incorrect fallback logic that used serie_id as folder name
- Enhanced docstrings to clarify purpose of each identifier field:
  * serie_id: Provider key (e.g., 'attack-on-titan') for lookups
  * serie_folder: Filesystem folder name (e.g., 'Attack on Titan (2013)') for file operations
- Updated logging to reference 'serie_key' for clarity
- Fixed all unit tests to include required serie_folder field
- All 25 download service tests passing
- All 47 download model tests passing
- Updated infrastructure.md with detailed documentation
- Marked Task 3.1 as completed in instructions.md

Benefits:
- Clear separation between provider identifier and filesystem path
- Prevents confusion from mixing different identifier types
- Consistent with broader series identifier standardization effort
- Better error messages when required fields are missing
This commit is contained in:
Lukas 2025-11-23 20:13:24 +01:00
parent 883f89b113
commit e1c8b616a8
6 changed files with 136 additions and 58 deletions

View File

@ -1374,6 +1374,59 @@ Clients can subscribe to specific topics (rooms) to receive targeted updates:
- Testing: Comprehensive unit tests in `tests/unit/test_download_service.py` - Testing: Comprehensive unit tests in `tests/unit/test_download_service.py`
cover queue operations, persistence, retry logic, and error handling cover queue operations, persistence, retry logic, and error handling
#### Series Identifier Standardization (November 2025)
**Task 3.1 Completed**: Updated DownloadService to use standardized series identifiers.
**Changes Made**:
- **serie_id Field**: Now explicitly documented as the provider key
(e.g., "attack-on-titan"). This is the unique, URL-safe identifier
used for all series lookups and identification throughout the system.
- **serie_folder Field**: Changed from Optional to required. This field
contains the filesystem folder name (e.g., "Attack on Titan (2013)")
and is used exclusively for filesystem operations.
- **Separation of Concerns**: Removed incorrect fallback logic that
used `serie_id` as a substitute for `serie_folder`. These fields now
serve distinct purposes and must both be provided.
- **Enhanced Documentation**: Updated docstrings in `add_to_queue()`
method and all Pydantic models to clarify the purpose and usage of
each identifier field.
- **Improved Logging**: Updated log statements to reference `serie_key`
for identification, making it clear which identifier is being used.
**Models Updated**:
- `DownloadItem` (src/server/models/download.py):
- `serie_id`: Required field with provider key
- `serie_folder`: Changed from Optional to required
- Both fields have enhanced field descriptions
- `DownloadRequest` (src/server/models/download.py):
- `serie_folder`: Changed from Optional to required
- Enhanced field descriptions for both identifiers
**Service Changes**:
- `DownloadService.add_to_queue()`: Updated to validate that
`serie_folder` is always provided and raises DownloadServiceError
if missing
- Removed fallback logic: `folder = item.serie_folder if item.serie_folder else item.serie_id`
- Added validation check before download execution
- Updated logging to use `serie_key` parameter name
**Testing**:
- Updated all test fixtures to include required `serie_folder` field
- All 25 download service tests passing
- All 47 download model tests passing
**Benefits**:
- Clear separation between provider identifier (key) and filesystem path (folder)
- Prevents confusion and potential bugs from mixing identifiers
- Consistent with broader series identifier standardization effort
- Better error messages when required fields are missing
### Download Queue API Endpoints (October 2025) ### Download Queue API Endpoints (October 2025)
Implemented comprehensive REST API endpoints for download queue management: Implemented comprehensive REST API endpoints for download queue management:

View File

@ -171,38 +171,7 @@ For each task completed:
### Phase 3: Service Layer ### Phase 3: Service Layer
#### Task 3.1: Update DownloadService to Use Key ✅ **Task 3.1 completed and committed to git (November 2025)**
**File:** [`src/server/services/download_service.py`](src/server/services/download_service.py)
**Objective:** Change `DownloadService` to use `key` as the series identifier instead of mixing `serie_id` and `serie_folder`.
**Steps:**
1. Open [`src/server/services/download_service.py`](src/server/services/download_service.py)
2. In `add_to_queue()` method:
- Rename parameter `serie_id` to `series_key` (or keep as `serie_id` but document it's the key)
- Keep `serie_folder` for filesystem operations
- Keep `serie_name` for display
- Update docstring to clarify: `serie_id` is the provider key
3. Update `DownloadItem` dataclass:
- Change `serie_id` to use `key`
- Keep `serie_folder` for file operations
4. Update all internal methods to use `key` consistently
5. Update logging to reference `key` instead of `folder`
**Success Criteria:**
- [ ] `add_to_queue()` uses `key` for identification
- [ ] `DownloadItem` uses `key` as identifier
- [ ] Filesystem operations still use `serie_folder`
- [ ] All download service tests pass
**Test Command:**
```bash
conda run -n AniWorld python -m pytest tests/unit/ -k "DownloadService" -v
```
--- ---
@ -1052,18 +1021,18 @@ conda run -n AniWorld python -m pytest tests/integration/test_identifier_consist
### Completion Status ### Completion Status
- [ ] Phase 1: Core Models and Data Layer - [x] Phase 1: Core Models and Data Layer
- [ ] Task 1.1: Update Serie Class - [x] Task 1.1: Update Serie Class
- [ ] Task 1.2: Update SerieList - [x] Task 1.2: Update SerieList
- [ ] Task 1.3: Update SerieScanner - [x] Task 1.3: Update SerieScanner
- [ ] **Task 1.4: Update Provider Classes** ⭐ NEW - [x] **Task 1.4: Update Provider Classes**
- [ ] Phase 2: Core Application Layer - [x] Phase 2: Core Application Layer
- [ ] Task 2.1: Update SeriesApp - [x] Task 2.1: Update SeriesApp
- [ ] Phase 3: Service Layer - [ ] Phase 3: Service Layer
- [ ] Task 3.1: Update DownloadService - [x] Task 3.1: Update DownloadService ✅ **Completed November 2025**
- [ ] Task 3.2: Update AnimeService - [ ] Task 3.2: Update AnimeService
- [ ] **Task 3.3: Update ProgressService** ⭐ NEW - [ ] **Task 3.3: Update ProgressService**
- [ ] **Task 3.4: Update ScanService** ⭐ NEW - [ ] **Task 3.4: Update ScanService**
- [ ] Phase 4: API Layer - [ ] Phase 4: API Layer
- [ ] Task 4.1: Update Anime API Endpoints - [ ] Task 4.1: Update Anime API Endpoints
- [ ] Task 4.2: Update Download API Endpoints - [ ] Task 4.2: Update Download API Endpoints

View File

@ -66,9 +66,19 @@ class DownloadItem(BaseModel):
"""Represents a single download item in the queue.""" """Represents a single download item in the queue."""
id: str = Field(..., description="Unique download item identifier") id: str = Field(..., description="Unique download item identifier")
serie_id: str = Field(..., description="Series identifier (provider key)") serie_id: str = Field(
serie_folder: Optional[str] = Field( ...,
None, description="Series folder name on disk" description=(
"Series identifier - provider key "
"(e.g., 'attack-on-titan')"
)
)
serie_folder: str = Field(
...,
description=(
"Series folder name on disk "
"(e.g., 'Attack on Titan (2013)')"
)
) )
serie_name: str = Field(..., min_length=1, description="Series name") serie_name: str = Field(..., min_length=1, description="Series name")
episode: EpisodeIdentifier = Field( episode: EpisodeIdentifier = Field(
@ -160,9 +170,19 @@ class QueueStats(BaseModel):
class DownloadRequest(BaseModel): class DownloadRequest(BaseModel):
"""Request to add episode(s) to the download queue.""" """Request to add episode(s) to the download queue."""
serie_id: str = Field(..., description="Series identifier (provider key)") serie_id: str = Field(
serie_folder: Optional[str] = Field( ...,
None, description="Series folder name on disk" description=(
"Series identifier - provider key "
"(e.g., 'attack-on-titan')"
)
)
serie_folder: str = Field(
...,
description=(
"Series folder name on disk "
"(e.g., 'Attack on Titan (2013)')"
)
) )
serie_name: str = Field( serie_name: str = Field(
..., min_length=1, description="Series name for display" ..., min_length=1, description="Series name for display"

View File

@ -239,11 +239,16 @@ class DownloadService:
"""Add episodes to the download queue (FIFO order). """Add episodes to the download queue (FIFO order).
Args: Args:
serie_id: Series identifier (provider key) serie_id: Series identifier - provider key (e.g.,
serie_folder: Series folder name on disk 'attack-on-titan'). This is the unique identifier used
serie_name: Series display name for lookups and identification.
serie_folder: Series folder name on disk (e.g.,
'Attack on Titan (2013)'). Used for filesystem operations
only.
serie_name: Series display name for user interface
episodes: List of episodes to download episodes: List of episodes to download
priority: Queue priority level (ignored, kept for compatibility) priority: Queue priority level (ignored, kept for
compatibility)
Returns: Returns:
List of created download item IDs List of created download item IDs
@ -277,7 +282,8 @@ class DownloadService:
logger.info( logger.info(
"Item added to queue", "Item added to queue",
item_id=item.id, item_id=item.id,
serie=serie_name, serie_key=serie_id,
serie_name=serie_name,
season=episode.season, season=episode.season,
episode=episode.episode, episode=episode.episode,
) )
@ -792,7 +798,8 @@ class DownloadService:
logger.info( logger.info(
"Starting download", "Starting download",
item_id=item.id, item_id=item.id,
serie=item.serie_name, serie_key=item.serie_id,
serie_name=item.serie_name,
season=item.episode.season, season=item.episode.season,
episode=item.episode.episode, episode=item.episode.episode,
) )
@ -802,9 +809,15 @@ class DownloadService:
# - download started/progress/completed/failed events # - download started/progress/completed/failed events
# - All updates forwarded to ProgressService # - All updates forwarded to ProgressService
# - ProgressService broadcasts to WebSocket clients # - ProgressService broadcasts to WebSocket clients
folder = item.serie_folder if item.serie_folder else item.serie_id # Use serie_folder for filesystem operations and serie_id (key) for identification
if not item.serie_folder:
raise DownloadServiceError(
f"Missing serie_folder for download item {item.id}. "
"serie_folder is required for filesystem operations."
)
success = await self._anime_service.download( success = await self._anime_service.download(
serie_folder=folder, serie_folder=item.serie_folder,
season=item.episode.season, season=item.episode.season,
episode=item.episode.episode, episode=item.episode.episode,
key=item.serie_id, key=item.serie_id,

View File

@ -174,6 +174,7 @@ class TestDownloadItem:
item = DownloadItem( item = DownloadItem(
id="download_123", id="download_123",
serie_id="serie_456", serie_id="serie_456",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=episode, episode=episode,
status=DownloadStatus.PENDING, status=DownloadStatus.PENDING,
@ -192,6 +193,7 @@ class TestDownloadItem:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -211,6 +213,7 @@ class TestDownloadItem:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
progress=progress progress=progress
@ -225,6 +228,7 @@ class TestDownloadItem:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
started_at=now, started_at=now,
@ -240,6 +244,7 @@ class TestDownloadItem:
DownloadItem( DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="", serie_name="",
episode=episode episode=episode
) )
@ -251,6 +256,7 @@ class TestDownloadItem:
DownloadItem( DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
retry_count=-1 retry_count=-1
@ -263,6 +269,7 @@ class TestDownloadItem:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -279,6 +286,7 @@ class TestQueueStatus:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Folder",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -375,6 +383,7 @@ class TestDownloadRequest:
episode2 = EpisodeIdentifier(season=1, episode=2) episode2 = EpisodeIdentifier(season=1, episode=2)
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="serie_123",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episodes=[episode1, episode2], episodes=[episode1, episode2],
priority=DownloadPriority.HIGH priority=DownloadPriority.HIGH
@ -389,16 +398,21 @@ class TestDownloadRequest:
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="serie_123",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episodes=[episode] episodes=[episode]
) )
assert request.priority == DownloadPriority.NORMAL assert request.priority == DownloadPriority.NORMAL
def test_empty_episodes_list_allowed(self): def test_empty_episodes_list_allowed(self):
"""Test that empty episodes list is allowed at model level (endpoint validates).""" """Test that empty episodes list is allowed at model level.
(endpoint validates)
"""
# Empty list is now allowed at model level; endpoint validates # Empty list is now allowed at model level; endpoint validates
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="serie_123",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episodes=[] episodes=[]
) )
@ -410,6 +424,7 @@ class TestDownloadRequest:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DownloadRequest( DownloadRequest(
serie_id="serie_123", serie_id="serie_123",
serie_folder="Test Series (2023)",
serie_name="", serie_name="",
episodes=[episode] episodes=[episode]
) )
@ -453,7 +468,10 @@ class TestQueueOperationRequest:
assert "item1" in request.item_ids assert "item1" in request.item_ids
def test_empty_item_ids_allowed(self): def test_empty_item_ids_allowed(self):
"""Test that empty item_ids list is allowed at model level (endpoint validates).""" """Test that empty item_ids list is allowed at model level.
(endpoint validates)
"""
# Empty list is now allowed at model level; endpoint validates # Empty list is now allowed at model level; endpoint validates
request = QueueOperationRequest(item_ids=[]) request = QueueOperationRequest(item_ids=[])
assert request.item_ids == [] assert request.item_ids == []
@ -512,6 +530,7 @@ class TestModelSerialization:
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serie_id",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=episode episode=episode
) )
@ -526,6 +545,7 @@ class TestModelSerialization:
data = { data = {
"id": "test_id", "id": "test_id",
"serie_id": "serie_id", "serie_id": "serie_id",
"serie_folder": "Test Series (2023)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episode": { "episode": {
"season": 1, "season": 1,

View File

@ -346,6 +346,7 @@ class TestQueueControl:
completed_item = DownloadItem( completed_item = DownloadItem(
id="completed-1", id="completed-1",
serie_id="series-1", serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1), episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.COMPLETED, status=DownloadStatus.COMPLETED,
@ -454,6 +455,7 @@ class TestRetryLogic:
failed_item = DownloadItem( failed_item = DownloadItem(
id="failed-1", id="failed-1",
serie_id="series-1", serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1), episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED, status=DownloadStatus.FAILED,
@ -476,6 +478,7 @@ class TestRetryLogic:
failed_item = DownloadItem( failed_item = DownloadItem(
id="failed-1", id="failed-1",
serie_id="series-1", serie_id="series-1",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=EpisodeIdentifier(season=1, episode=1), episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.FAILED, status=DownloadStatus.FAILED,