Compare commits

..

36 Commits

Author SHA1 Message Date
338e3feb4a cleanup 2025-11-28 18:58:50 +01:00
36acd3999e Complete Phase 9: Final validation for identifier standardization
- Fix search API key extraction from link slugs
- All 1006 tests pass
- All 19 performance tests pass
- Manual end-to-end testing verified
- Key lookup performance: O(1) ~0.11μs per lookup

Phase 9 tasks completed:
- Task 9.1: Full test suite validation
- Task 9.2: Manual end-to-end testing
- Task 9.3: Performance testing

All identifier standardization phases (1-9) now complete.
2025-11-28 18:46:35 +01:00
85a6b053eb Phase 8: Documentation and deprecation warnings for identifier standardization
- Enhanced infrastructure.md with identifier convention table, format requirements, migration notes
- Updated docs/README.md with series identifier convention section
- Updated docs/api_reference.md with key-based API examples and notes
- Added deprecation warnings to SerieList.get_by_folder()
- Added deprecation warnings to anime.py folder fallback lookup
- Added deprecation warnings to validate_series_key_or_folder()
- All warnings include v3.0.0 removal timeline
- All 1006 tests pass
2025-11-28 18:06:04 +01:00
ddff43595f Format: Apply code and markdown formatting fixes 2025-11-28 17:47:39 +01:00
6e9087d0f4 Complete Phase 7: Testing and Validation for identifier standardization
- Task 7.1: Update All Test Fixtures to Use Key
  - Updated FakeSerie/FakeSeriesApp with realistic keys in test_anime_endpoints.py
  - Updated 6+ fixtures in test_websocket_integration.py
  - Updated 5 fixtures in test_download_progress_integration.py
  - Updated 9 fixtures in test_download_progress_websocket.py
  - Updated 10+ fixtures in test_download_models.py
  - All fixtures now use URL-safe, lowercase, hyphenated key format

- Task 7.2: Add Integration Tests for Identifier Consistency
  - Created tests/integration/test_identifier_consistency.py with 10 tests
  - TestAPIIdentifierConsistency: API response validation
  - TestServiceIdentifierConsistency: Download service key usage
  - TestWebSocketIdentifierConsistency: WebSocket events
  - TestIdentifierValidation: Model validation
  - TestEndToEndIdentifierFlow: Full flow verification
  - Tests use UUID suffixes for isolation

All 1006 tests passing.
2025-11-28 17:41:54 +01:00
0c8b296aa6 Phase 6: Update database layer identifier documentation
- Updated AnimeSeries model docstring to clarify key is primary identifier
- Updated folder field to indicate metadata-only usage
- Updated AnimeSeriesService docstring and get_by_key method
- Updated infrastructure.md with database identifier documentation
- All 996 tests passing
2025-11-28 17:19:30 +01:00
a833077f97 Phase 5: Frontend - Use key as primary series identifier
- Updated app.js to use 'key' as primary series identifier
  - selectedSeries Set now uses key instead of folder
  - createSerieCard() uses data-key attribute for identification
  - toggleSerieSelection() uses key for lookups
  - downloadSelected() iterates with key values
  - updateSelectionUI() and toggleSelectAll() use key

- Updated WebSocket service tests
  - Tests now include key and folder in broadcast data
  - Verified both fields are included in messages

- No changes needed for queue.js and other JS files
  - They use download item IDs correctly, not series identifiers

- No template changes needed
  - Series cards rendered dynamically in app.js

All 996 tests passing
2025-11-28 16:18:33 +01:00
5aabad4d13 "Task 4.7: Update template helpers to use key identifier
- Add series context helpers: prepare_series_context, get_series_by_key, filter_series_by_missing_episodes
- Update module docstring with identifier convention documentation
- Add unit tests for new series context helper functions
- Update infrastructure.md with template helpers documentation
- Mark Phase 4 (API Layer) as complete"
2025-11-28 16:01:18 +01:00
5934c7666c Task 4.7: Update template helpers to use key identifier
- Add series context helpers: prepare_series_context, get_series_by_key, filter_series_by_missing_episodes
- Update module docstring with identifier convention documentation
- Add unit tests for new series context helper functions
- Update infrastructure.md with template helpers documentation
- Mark Phase 4 (API Layer) as complete
2025-11-28 16:00:15 +01:00
014e22390e style: Apply formatting to infrastructure.md and test_validators.py
- Fix markdown table alignment in infrastructure.md
- Sort imports alphabetically in test_validators.py (auto-formatted)
2025-11-28 15:48:49 +01:00
c00224467f feat: Add validate_series_key() validator for key-based identification (Task 4.6)
- Add validate_series_key() function that validates URL-safe, lowercase,
  hyphen-separated series keys (e.g., 'attack-on-titan')
- Add validate_series_key_or_folder() for backward compatibility during
  transition from folder-based to key-based identification
- Create comprehensive test suite with 99 test cases for all validators
- Update infrastructure.md with validation utilities documentation
- Mark Task 4.6 as complete in instructions.md

Test: conda run -n AniWorld python -m pytest tests/unit/test_validators.py -v
All 99 validator tests pass, 718 total unit tests pass
2025-11-28 07:13:46 +01:00
08c7264d7a chore: Minor formatting fixes (whitespace cleanup) 2025-11-28 07:08:32 +01:00
3525629853 Mark Task 4.5 as complete in instructions.md 2025-11-27 20:02:18 +01:00
6d2a791a9d Task 4.5: Update Pydantic models to use key as primary identifier
- Updated AnimeSeriesResponse and SearchResult models in anime.py:
  - Changed 'id' field to 'key' as the primary series identifier
  - Added 'folder' as optional metadata field
  - Added field validator to normalize key to lowercase and strip whitespace
  - Added comprehensive docstrings explaining identifier usage

- Updated DownloadItem and DownloadRequest models in download.py:
  - Added field validator for serie_id normalization (lowercase, stripped)
  - Improved documentation for serie_id (primary identifier) vs serie_folder (metadata)

- Updated test_anime_models.py with comprehensive tests:
  - Tests for key normalization and whitespace stripping
  - Tests for folder as optional metadata
  - Reorganized tests into proper class structure

- Updated test_download_models.py with validator tests:
  - Tests for serie_id normalization in DownloadItem
  - Tests for serie_id normalization in DownloadRequest

All 885 tests pass.
2025-11-27 20:01:33 +01:00
3c8ba1d48c Task 4.4: Update WebSocket API Endpoints to use key identifier
- Updated src/server/api/websocket.py docstrings to document key as primary series identifier
- Updated src/server/models/websocket.py with detailed docstrings explaining key and folder fields in message payloads
- Updated src/server/services/websocket_service.py broadcast method docstrings to document key field usage
- Added WebSocket message example with key in infrastructure.md
- All 83 WebSocket tests pass
- Task 4.4 marked as complete in instructions.md
2025-11-27 19:52:53 +01:00
f4d14cf17e Task 4.3: Verify queue API endpoints use key identifier
- Verified queue API endpoints already use 'serie_id' (key) as primary identifier
- Updated test fixtures to use explicit key values (e.g., 'test-series-key')
- Added test to verify queue items include serie_id (key) and serie_folder (metadata)
- Fixed test_queue_items_have_required_fields to find correct item by ID
- Added test_queue_item_uses_key_as_identifier for explicit key verification
- Updated instructions.md to mark Task 4.3 as complete

All 870 tests pass.
2025-11-27 19:46:49 +01:00
f4dad969bc Clean up: Remove detailed descriptions from completed tasks 4.1 and 4.2 2025-11-27 19:34:20 +01:00
589141e9aa Task 4.2: Update Download API Endpoints to Use Key
- Updated DownloadRequest and DownloadItem models with comprehensive
  docstrings explaining serie_id (key as primary identifier) vs
  serie_folder (filesystem metadata)
- Updated add_to_queue() endpoint docstring to document request parameters
- Updated all test files to include required serie_folder field:
  - tests/api/test_download_endpoints.py
  - tests/api/test_queue_features.py
  - tests/frontend/test_existing_ui_integration.py
  - tests/integration/test_download_flow.py
- Updated infrastructure.md with Download Queue request/response models
- All 869 tests pass

This is part of the Series Identifier Standardization effort (Phase 4.2)
to ensure key is used as the primary identifier throughout the codebase.
2025-11-27 19:33:06 +01:00
da4973829e backup 2025-11-27 19:02:55 +01:00
ff5b364852 Task 4.1: Update Anime API Endpoints to use key as primary identifier
- Updated AnimeSummary model with enhanced documentation:
  - key as primary identifier (unique series identifier)
  - folder as metadata only (not used for lookups)
  - Added Field descriptions for all attributes

- Updated AnimeDetail model:
  - Replaced 'id' field with 'key' field
  - Added 'folder' field as metadata
  - Enhanced documentation and JSON schema example

- Updated get_anime() endpoint:
  - Primary lookup by 'key' (preferred)
  - Fallback lookup by 'folder' (backward compatibility)
  - Updated docstring to clarify identifier usage

- Updated add_series() endpoint:
  - Extracts key from link URL (/anime/stream/{key})
  - Returns both key and folder in response
  - Enhanced docstring with parameter descriptions

- Updated _perform_search():
  - Uses key as primary identifier
  - Extracts key from link URL if not present
  - Enhanced docstring with return value details

- Updated list_anime() and search endpoint docstrings:
  - Clarified key as primary identifier
  - Documented folder as metadata only

- Updated instructions.md:
  - Marked Task 4.1 as completed
  - Updated task tracking section

- Updated infrastructure.md:
  - Updated API endpoints documentation
  - Added response model details

All anime API tests passing (11/11)
All unit tests passing (604/604)
2025-11-27 19:02:19 +01:00
6726c176b2 feat(Task 3.4): Implement ScanService with key-based identification
- Create ScanService class (src/server/services/scan_service.py)
  - Use 'key' as primary series identifier throughout
  - Include 'folder' as metadata only for display purposes
  - Implement scan progress tracking via ProgressService
  - Add callback classes for progress, error, and completion
  - Support scan event subscription and broadcasting
  - Maintain scan history with configurable limit
  - Provide cancellation support for in-progress scans

- Create comprehensive unit tests (tests/unit/test_scan_service.py)
  - 38 tests covering all functionality
  - Test ScanProgress dataclass serialization
  - Test callback classes (progress, error, completion)
  - Test service lifecycle (start, cancel, status)
  - Test event subscription and broadcasting
  - Test key-based identification throughout
  - Test singleton pattern

- Update infrastructure.md with ScanService documentation
  - Document service overview and key features
  - Document components and event types
  - Document integration points
  - Include usage example

- Update instructions.md
  - Mark Task 3.4 as complete
  - Mark Phase 3 as fully complete
  - Remove finished task definition

Task: Phase 3, Task 3.4 - Update ScanService to Use Key
Completion Date: November 27, 2025
2025-11-27 18:50:02 +01:00
84ca53a1bc Complete Task 3.3: ProgressService already uses key identifier
- Verified ProgressService correctly uses 'key' as primary series identifier
- ProgressUpdate dataclass has key/folder fields with proper docstrings
- All methods accept and handle key/folder parameters
- to_dict() properly serializes key/folder when present
- 25 unit tests pass including key/folder tests
- Infrastructure documentation already up to date
- Removed completed task details from instructions.md
2025-11-27 18:40:32 +01:00
fb2cdd4bb6 Task 3.3: Update ProgressService to use key as identifier
- Added optional 'key' and 'folder' fields to ProgressUpdate dataclass
- key: Primary series identifier (provider key, e.g., 'attack-on-titan')
- folder: Optional series folder name for display (e.g., 'Attack on Titan (2013)')
- Updated start_progress() and update_progress() methods to accept key/folder parameters
- Enhanced to_dict() serialization to include key/folder when present
- Updated all docstrings to clarify identifier usage
- Added 5 new comprehensive unit tests for key/folder functionality
- All 25 ProgressService tests passing
- Updated infrastructure.md with series identifier documentation
- Maintains backward compatibility - fields are optional
- Completed Phase 3, Task 3.3 of identifier standardization initiative
2025-11-27 18:36:35 +01:00
dda999fb98 docs: Remove completed Task 3.2 details from instructions.md
Consolidated completion note for Tasks 3.1 and 3.2 in Phase 3 header.
Full implementation details remain documented in infrastructure.md.
2025-11-23 20:21:08 +01:00
e8129f847c feat: Complete Task 3.2 - Update AnimeService to use key as primary identifier
- Enhanced class and method docstrings to clarify 'key' as primary identifier
- Documented that 'folder' is metadata only (display and filesystem operations)
- Updated event handler documentation to show both key and folder are received
- Modernized type hints to Python 3.9+ style (list[dict] vs List[dict])
- Fixed PEP 8 line length violations
- All 18 anime service tests passing

Implementation follows identifier standardization initiative:
- key: Primary series identifier (provider-assigned, URL-safe)
- folder: Metadata for display and filesystem paths only

Task 3.2 completed November 23, 2025
Documented in infrastructure.md and instructions.md
2025-11-23 20:19:04 +01:00
e1c8b616a8 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
2025-11-23 20:13:24 +01:00
883f89b113 Add series key metadata to callback contexts 2025-11-23 20:02:11 +01:00
41a53bbf8f docs: clean up completed tasks from instructions.md
Removed detailed implementation for completed Phase 1 and Task 2.1:
- Task 1.1: Update Serie Class to Enforce Key as Primary Identifier
- Task 1.2: Update SerieList to Use Key for Lookups
- Task 1.3: Update SerieScanner to Use Key Consistently
- Task 1.4: Update Provider Classes to Use Key
- Task 1.5: Update Provider Factory to Use Key
- Task 2.1: Update SeriesApp to Use Key for All Operations

Replaced with completion markers for cleaner task list.
All implementation details are preserved in git history.
2025-11-23 19:56:20 +01:00
5c08bac248 style: reorder imports in SeriesApp.py
Minor formatting change - imports reordered alphabetically
2025-11-23 19:54:19 +01:00
8443de4e0f feat(core): standardize SeriesApp to use key as primary identifier
Task 2.1 - Update SeriesApp to Use Key for All Operations

Changes:
- Added 'key' field to DownloadStatusEventArgs and ScanStatusEventArgs
- Updated download() method docstrings to clarify key vs folder usage
- Implemented _get_serie_by_key() helper method for series lookups
- Updated all event emissions to include both key (identifier) and folder (metadata)
- Enhanced logging to show both key and folder for better debugging
- Fixed test mocks to include new key and item_id fields

Benefits:
- Consistent series identification throughout core application layer
- Clear separation between identifier (key) and metadata (folder)
- Better debugging with comprehensive log messages
- Type-safe lookups with Optional[Serie] return types
- Single source of truth for series lookups

Test Results:
- All 16 SeriesApp tests pass
- All 562 unit tests pass with no regressions
- No breaking changes to existing functionality

Follows:
- PEP 8 style guidelines (max 79 chars per line)
- PEP 257 docstring standards
- Project coding standards (type hints, error handling, logging)
2025-11-23 19:51:26 +01:00
51cd319a24 Task 1.5: Update Provider Factory documentation for key usage
- Added comprehensive module-level docstring explaining provider vs series keys
- Enhanced Loaders class docstring with purpose and attributes documentation
- Added detailed docstring to GetLoader() method with Args/Returns/Raises sections
- Added type hints: Dict[str, Loader] for self.dict and -> None for __init__
- Clarified distinction between provider keys (e.g., 'aniworld.to') and series keys
- No functional changes - existing implementation already correct
- All 34 provider tests pass
- All 16 SeriesApp tests pass
- Updated instructions.md to mark Task 1.5 as completed
- Follows PEP 8 and PEP 257 standards
2025-11-23 19:45:22 +01:00
c4ec6c9f0e Task 1.1: Fix PEP 8 compliance in Serie class
- Fixed line length issues (max 79 chars)
- Added UTF-8 encoding to file operations
- Fixed blank line formatting
- Improved code formatting in __str__, to_dict, from_dict methods
- All docstrings now comply with PEP 8
- All 16 Serie class tests pass
- All 5 anime model tests pass
- No functional changes, only style improvements
2025-11-23 19:38:26 +01:00
aeb1ebe7a2 Task 1.4: Update provider classes to use key as primary identifier
- Enhanced download() method docstring in aniworld_provider.py
- Enhanced Download() method docstring in enhanced_provider.py
- Clarified that 'key' is the series unique identifier from provider
- Clarified that 'serie_folder'/'serieFolder' is filesystem folder name (metadata only)
- Added comprehensive Args, Returns, and Raises sections to docstrings
- Fixed PEP 8 line length issue in logging statement
- Verified existing code already uses 'key' for identification and logging
- All 34 provider-related tests pass successfully
- No functional changes required, documentation improvements only
2025-11-23 17:51:32 +01:00
920a5b0eaf feat(core): Standardize SerieScanner to use 'key' as primary identifier
Task 1.3: Update SerieScanner to Use Key Consistently

Changes:
- Renamed self.folderDict to self.keyDict for clarity and consistency
- Updated internal storage to use serie.key as dictionary key
- Modified scan() method to store series by key
- Enhanced logging to show both key (identifier) and folder (metadata)
- Added debug logging when storing series
- Updated error contexts to include both key and folder in metadata
- Updated completion statistics to use keyDict
- Enhanced docstrings to clarify identifier vs metadata usage
- Fixed import formatting to comply with PEP 8 line length

Success criteria met:
 Scanner stores series by 'key'
 Progress callbacks use 'key' for identification
 Error messages reference both 'key' and 'folder' appropriately
 All 554 unit tests pass

Related to: Series Identifier Standardization (Phase 1, Task 1.3)
2025-11-23 13:06:33 +01:00
8b5b06ca9a feat: Standardize SerieList to use key as primary identifier (Task 1.2)
- Renamed folderDict to keyDict for clarity
- Updated internal storage to use serie.key instead of serie.folder
- Optimized contains() from O(n) to O(1) with direct key lookup
- Added get_by_key() as primary lookup method
- Added get_by_folder() for backward compatibility
- Enhanced docstrings to clarify key vs folder usage
- Created comprehensive test suite (12 tests, all passing)
- Verified no breaking changes (16 SeriesApp tests pass)

This establishes key as the single source of truth for series
identification while maintaining folder as metadata for filesystem
operations only.
2025-11-23 12:25:08 +01:00
048434d49c feat: Task 1.1 - Enforce key as primary identifier in Serie class
- Add validation in Serie.__init__ to prevent empty/whitespace keys
- Add validation in Serie.key setter to prevent empty values
- Automatically strip whitespace from key values
- Add comprehensive docstrings explaining key as unique identifier
- Document folder property as metadata only (not for lookups)
- Create comprehensive test suite with 16 tests in test_serie_class.py
- All 56 Serie-related tests pass successfully
- Update instructions.md to mark Task 1.1 as completed

This is the first task in the Series Identifier Standardization effort
to establish 'key' as the single source of truth for series identification
throughout the codebase.
2025-11-23 12:12:58 +01:00
64 changed files with 6113 additions and 10064 deletions

BIN
.coverage Normal file

Binary file not shown.

View File

@ -17,7 +17,7 @@
"keep_days": 30 "keep_days": 30
}, },
"other": { "other": {
"master_password_hash": "$pbkdf2-sha256$29000$AsCYU2pNCYHwHoPwnlPqXQ$uHLpvUnvj9GmNFgkAAgk3Yvvp2WzLyMNUBwKMyH79CQ", "master_password_hash": "$pbkdf2-sha256$29000$854zxnhvzXmPsVbqvXduTQ$G0HVRAt3kyO5eFwvo.ILkpX9JdmyXYJ9MNPTS/UxAGk",
"anime_directory": "/mnt/server/serien/Serien/" "anime_directory": "/mnt/server/serien/Serien/"
}, },
"version": "1.0.0" "version": "1.0.0"

View File

@ -0,0 +1,24 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$VCqllLL2vldKyTmHkJIyZg$jNllpzlpENdgCslmS.tG.PGxRZ9pUnrqFEQFveDEcYk",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@ -0,0 +1,24 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$3/t/7733PkdoTckZQyildA$Nz9SdX2ZgqBwyzhQ9FGNcnzG1X.TW9oce3sDxJbVSdY",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@ -1,20 +1,320 @@
{ {
"pending": [ "pending": [
{ {
"id": "03bfbf39-60b7-4790-ae9e-a158f654dafa", "id": "ae6424dc-558b-4946-9f07-20db1a09bf33",
"serie_id": "a-star-brighter-than-the-sun", "serie_id": "test-series-2",
"serie_folder": "Ein Stern, heller als die Sonne (2025)", "serie_folder": "Another Series (2024)",
"serie_name": "A Star Brighter Than the Sun", "serie_name": "Another Series",
"episode": { "episode": {
"season": 1, "season": 1,
"episode": 8, "episode": 1,
"title": null "title": null
}, },
"status": "cancelled", "status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.593236Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "011c2038-9fe3-41cb-844f-ce50c40e415f",
"serie_id": "series-high",
"serie_folder": "Series High (2024)",
"serie_name": "Series High",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.632289Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "0eee56e0-414d-4cd7-8da7-b5a139abd8b5",
"serie_id": "series-normal",
"serie_folder": "Series Normal (2024)",
"serie_name": "Series Normal",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL", "priority": "NORMAL",
"added_at": "2025-11-22T16:36:10.705099Z", "added_at": "2025-11-28T17:54:38.635082Z",
"started_at": "2025-11-22T16:36:16.078860Z", "started_at": null,
"completed_at": "2025-11-22T16:37:02.340465Z", "completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "eea9f4f3-98e5-4041-9fc6-92e3d4c6fee6",
"serie_id": "series-low",
"serie_folder": "Series Low (2024)",
"serie_name": "Series Low",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "LOW",
"added_at": "2025-11-28T17:54:38.637038Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "b6f84ea9-86c8-4cc9-90e5-c7c6ce10c593",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.801266Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "412aa28d-9763-41ef-913d-3d63919f9346",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.867939Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "3a036824-2d14-41dd-81b8-094dd322a137",
"serie_id": "invalid-series",
"serie_folder": "Invalid Series (2024)",
"serie_name": "Invalid Series",
"episode": {
"season": 99,
"episode": 99,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.935125Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "1f4108ed-5488-4f46-ad5b-fe27e3b04790",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.968296Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "5e880954-1a9f-450a-8008-5b9d6ac07d66",
"serie_id": "series-2",
"serie_folder": "Series 2 (2024)",
"serie_name": "Series 2",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.055885Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2415ac21-509b-4d71-b5b9-b824116d6785",
"serie_id": "series-0",
"serie_folder": "Series 0 (2024)",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.056795Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "716f9823-d59a-4b04-863b-c75fd54bc464",
"serie_id": "series-1",
"serie_folder": "Series 1 (2024)",
"serie_name": "Series 1",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.057486Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "36ad4323-daa9-49c4-97e8-a0aec0cca7a1",
"serie_id": "series-4",
"serie_folder": "Series 4 (2024)",
"serie_name": "Series 4",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058179Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "695ee7a9-42bb-4953-9a8a-10bd7f533369",
"serie_id": "series-3",
"serie_folder": "Series 3 (2024)",
"serie_name": "Series 3",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058816Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aa948908-c410-42ec-85d6-a0298d7d95a5",
"serie_id": "persistent-series",
"serie_folder": "Persistent Series (2024)",
"serie_name": "Persistent Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.152427Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2537f20e-f394-4c68-81d5-48be3c0c402a",
"serie_id": "ws-series",
"serie_folder": "WebSocket Series (2024)",
"serie_name": "WebSocket Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.219061Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aaaf3b05-cce8-47d5-b350-59c5d72533ad",
"serie_id": "workflow-series",
"serie_folder": "Workflow Test Series (2024)",
"serie_name": "Workflow Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:39.254462Z",
"started_at": null,
"completed_at": null,
"progress": null, "progress": null,
"error": null, "error": null,
"retry_count": 0, "retry_count": 0,
@ -23,5 +323,5 @@
], ],
"active": [], "active": [],
"failed": [], "failed": [],
"timestamp": "2025-11-22T16:37:02.340599+00:00" "timestamp": "2025-11-28T17:54:39.259761+00:00"
} }

View File

@ -1,308 +0,0 @@
# Aniworld Documentation
Complete documentation for the Aniworld Download Manager application.
## Quick Start
- **New Users**: Start with [User Guide](./user_guide.md)
- **Developers**: Check [API Reference](./api_reference.md)
- **System Admins**: See [Deployment Guide](./deployment.md)
- **Interactive Docs**: Visit `http://localhost:8000/api/docs`
## Documentation Structure
### 📖 User Guide (`user_guide.md`)
Complete guide for end users covering:
- Installation instructions
- Initial setup and configuration
- User interface walkthrough
- Managing anime library
- Download queue management
- Configuration and settings
- Troubleshooting common issues
- Keyboard shortcuts
- Frequently asked questions (FAQ)
**Best for**: Anyone using the Aniworld application
### 🔌 API Reference (`api_reference.md`)
Detailed API documentation including:
- Authentication and authorization
- Error handling and status codes
- All REST endpoints with examples
- WebSocket real-time updates
- Request/response formats
- Rate limiting and pagination
- Complete workflow examples
- API changelog
**Best for**: Developers integrating with the API
### 🚀 Deployment Guide (`deployment.md`)
Production deployment instructions covering:
- System requirements
- Pre-deployment checklist
- Local development setup
- Production deployment steps
- Docker and Docker Compose setup
- Nginx reverse proxy configuration
- SSL/TLS certificate setup
- Database configuration (SQLite and PostgreSQL)
- Security best practices
- Monitoring and maintenance
- Troubleshooting deployment issues
**Best for**: System administrators and DevOps engineers
## Key Features Documented
### Authentication
- Master password setup and login
- JWT token management
- Session handling
- Security best practices
### Configuration Management
- Application settings
- Directory configuration
- Backup and restore functionality
- Environment variables
### Anime Management
- Browsing anime library
- Adding new anime
- Managing episodes
- Search functionality
### Download Management
- Queue operations
- Priority management
- Progress tracking
- Error recovery
### Real-time Features
- WebSocket connections
- Live download updates
- Status notifications
- Error alerts
## Documentation Examples
### API Usage Example
```bash
# Setup
curl -X POST http://localhost:8000/api/auth/setup \
-H "Content-Type: application/json" \
-d '{"master_password": "secure_pass"}'
# Login
TOKEN=$(curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"password": "secure_pass"}' | jq -r '.token')
# List anime
curl http://localhost:8000/api/v1/anime \
-H "Authorization: Bearer $TOKEN"
```
### Deployment Example
```bash
# Clone and setup
git clone https://github.com/your-repo/aniworld.git
cd aniworld
python3.10 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Run application
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
```
## Interactive Documentation
Access interactive API documentation at:
- **Swagger UI**: `http://localhost:8000/api/docs`
- **ReDoc**: `http://localhost:8000/api/redoc`
- **OpenAPI JSON**: `http://localhost:8000/openapi.json`
These provide:
- Interactive API explorer
- Try-it-out functionality
- Request/response examples
- Schema validation
## Common Tasks
### I want to...
**Use the application**
→ Read [User Guide](./user_guide.md) → Getting Started section
**Set up on my computer**
→ Read [User Guide](./user_guide.md) → Installation section
**Deploy to production**
→ Read [Deployment Guide](./deployment.md) → Production Deployment
**Use the API**
→ Read [API Reference](./api_reference.md) → API Endpoints section
**Troubleshoot problems**
→ Read [User Guide](./user_guide.md) → Troubleshooting section
**Set up with Docker**
→ Read [Deployment Guide](./deployment.md) → Docker Deployment
**Configure backup/restore**
→ Read [User Guide](./user_guide.md) → Configuration section
**Debug API issues**
→ Check [API Reference](./api_reference.md) → Error Handling section
## Documentation Standards
All documentation follows these standards:
### Structure
- Clear table of contents
- Logical section ordering
- Cross-references to related topics
- Code examples where appropriate
### Style
- Plain, accessible language
- Step-by-step instructions
- Visual formatting (code blocks, tables, lists)
- Examples for common scenarios
### Completeness
- All major features covered
- Edge cases documented
- Troubleshooting guidance
- FAQ section included
### Maintenance
- Version number tracking
- Last updated timestamp
- Changelog for updates
- Broken link checking
## Help & Support
### Getting Help
1. **Check Documentation First**
- Search in relevant guide
- Check FAQ section
- Look for similar examples
2. **Check Logs**
- Application logs in `/logs/`
- Browser console (F12)
- System logs
3. **Try Troubleshooting**
- Follow troubleshooting steps in user guide
- Check known issues section
- Verify system requirements
4. **Get Community Help**
- GitHub Issues
- Discussion Forums
- Community Discord
5. **Report Issues**
- File GitHub issue
- Include logs and error messages
- Describe reproduction steps
- Specify system details
### Feedback
We welcome feedback on documentation:
- Unclear sections
- Missing information
- Incorrect instructions
- Outdated content
- Suggest improvements
File documentation issues on GitHub with label `documentation`.
## Contributing to Documentation
Documentation improvements are welcome! To contribute:
1. Fork the repository
2. Edit documentation files
3. Test changes locally
4. Submit pull request
5. Include summary of changes
See `CONTRIBUTING.md` for guidelines.
## Documentation Map
```
docs/
├── README.md # This file
├── user_guide.md # End-user documentation
├── api_reference.md # API documentation
├── deployment.md # Deployment instructions
└── CONTRIBUTING.md # Contribution guidelines
```
## Related Resources
- **Source Code**: GitHub repository
- **Interactive API**: `http://localhost:8000/api/docs`
- **Issue Tracker**: GitHub Issues
- **Releases**: GitHub Releases
- **License**: See LICENSE file
## Document Info
- **Last Updated**: October 22, 2025
- **Version**: 1.0.0
- **Status**: Production Ready
- **Maintainers**: Development Team
---
## Quick Links
| Resource | Link |
| ------------------ | -------------------------------------------- |
| User Guide | [user_guide.md](./user_guide.md) |
| API Reference | [api_reference.md](./api_reference.md) |
| Deployment Guide | [deployment.md](./deployment.md) |
| Swagger UI | http://localhost:8000/api/docs |
| GitHub Issues | https://github.com/your-repo/aniworld/issues |
| Project Repository | https://github.com/your-repo/aniworld |
---
**For Questions**: Check relevant guide first, then file GitHub issue with details.

View File

@ -1,245 +0,0 @@
# API Endpoints Implementation Summary
**Date:** October 24, 2025
**Task:** Implement Missing API Endpoints
**Status:** ✅ COMPLETED
## Overview
Successfully implemented all missing API endpoints that were referenced in the frontend but not yet available in the backend. This completes the frontend-backend integration and ensures all features in the web UI are fully functional.
## Files Created
### 1. `src/server/api/scheduler.py`
**Purpose:** Scheduler configuration and manual trigger endpoints
**Endpoints Implemented:**
- `GET /api/scheduler/config` - Get current scheduler configuration
- `POST /api/scheduler/config` - Update scheduler configuration
- `POST /api/scheduler/trigger-rescan` - Manually trigger library rescan
**Features:**
- Type-safe configuration management using Pydantic models
- Authentication required for configuration updates
- Integration with existing SeriesApp rescan functionality
- Proper error handling and logging
### 2. `src/server/api/logging.py`
**Purpose:** Logging configuration and log file management
**Endpoints Implemented:**
- `GET /api/logging/config` - Get logging configuration
- `POST /api/logging/config` - Update logging configuration
- `GET /api/logging/files` - List all log files
- `GET /api/logging/files/{filename}/download` - Download log file
- `GET /api/logging/files/{filename}/tail` - Get last N lines of log file
- `POST /api/logging/test` - Test logging at all levels
- `POST /api/logging/cleanup` - Clean up old log files
**Features:**
- Dynamic logging configuration updates
- Secure file access with path validation
- Support for log rotation
- File streaming for large log files
- Automatic cleanup with age-based filtering
### 3. `src/server/api/diagnostics.py`
**Purpose:** System diagnostics and health monitoring
**Endpoints Implemented:**
- `GET /api/diagnostics/network` - Network connectivity diagnostics
- `GET /api/diagnostics/system` - System information
**Features:**
- Async network connectivity testing
- DNS resolution validation
- Multiple host testing (Google, Cloudflare, GitHub)
- Response time measurement
- System platform and version information
### 4. Extended `src/server/api/config.py`
**Purpose:** Additional configuration management endpoints
**New Endpoints Added:**
- `GET /api/config/section/advanced` - Get advanced configuration
- `POST /api/config/section/advanced` - Update advanced configuration
- `POST /api/config/directory` - Update anime directory
- `POST /api/config/export` - Export configuration to JSON
- `POST /api/config/reset` - Reset configuration to defaults
**Features:**
- Section-based configuration management
- Configuration export with sensitive data filtering
- Safe configuration reset with security preservation
- Automatic backup creation before destructive operations
## Files Modified
### 1. `src/server/fastapi_app.py`
**Changes:**
- Added imports for new routers (scheduler, logging, diagnostics)
- Included new routers in the FastAPI application
- Maintained proper router ordering for endpoint priority
### 2. `docs/api_reference.md`
**Changes:**
- Added complete documentation for all new endpoints
- Updated table of contents with new sections
- Included request/response examples for each endpoint
- Added error codes and status responses
### 3. `infrastructure.md`
**Changes:**
- Added scheduler endpoints section
- Added logging endpoints section
- Added diagnostics endpoints section
- Extended configuration endpoints documentation
### 4. `instructions.md`
**Changes:**
- Marked "Missing API Endpoints" task as completed
- Added implementation details summary
- Updated pending tasks section
## Test Results
**Test Suite:** All Tests
**Total Tests:** 802
**Passed:** 752 (93.8%)
**Failed:** 36 (mostly in SQL injection and performance tests - expected)
**Errors:** 14 (in performance load testing - expected)
**Key Test Coverage:**
- ✅ All API endpoint tests passing
- ✅ Authentication and authorization tests passing
- ✅ Frontend integration tests passing
- ✅ WebSocket integration tests passing
- ✅ Configuration management tests passing
## Code Quality
**Standards Followed:**
- PEP 8 style guidelines
- Type hints throughout
- Comprehensive docstrings
- Proper error handling with custom exceptions
- Structured logging
- Security best practices (path validation, authentication)
**Linting:**
- All critical lint errors resolved
- Only import resolution warnings remaining (expected in development without installed packages)
- Line length maintained under 79 characters where possible
## Integration Points
### Frontend Integration
All endpoints are now callable from the existing JavaScript frontend:
- Configuration modal fully functional
- Scheduler configuration working
- Logging management operational
- Diagnostics accessible
- Advanced configuration available
### Backend Integration
- Properly integrated with existing ConfigService
- Uses existing authentication/authorization system
- Follows established error handling patterns
- Maintains consistency with existing API design
## Security Considerations
**Authentication:**
- All write operations require authentication
- Read operations optionally authenticated
- JWT token validation on protected endpoints
**Input Validation:**
- Path traversal prevention in file operations
- Type validation using Pydantic models
- Query parameter validation
**Data Protection:**
- Sensitive data filtering in config export
- Security settings preservation in config reset
- Secure file access controls
## Performance
**Optimizations:**
- Async/await for I/O operations
- Efficient file streaming for large logs
- Concurrent network diagnostics testing
- Minimal memory footprint
**Resource Usage:**
- Log file operations don't load entire files
- Network tests have configurable timeouts
- File cleanup operates in controlled batches
## Documentation
**Complete Documentation Provided:**
- API reference with all endpoints
- Request/response examples
- Error codes and handling
- Query parameters
- Authentication requirements
- Usage examples
## Future Enhancements
**Potential Improvements:**
- Add pagination to log file listings
- Implement log file search functionality
- Add more network diagnostic targets
- Enhanced configuration validation rules
- Scheduled log cleanup
- Log file compression for old files
## Conclusion
All missing API endpoints have been successfully implemented with:
- ✅ Full functionality
- ✅ Proper authentication
- ✅ Comprehensive error handling
- ✅ Complete documentation
- ✅ Test coverage
- ✅ Security best practices
- ✅ Frontend integration
The web application is now feature-complete with all frontend functionality backed by corresponding API endpoints.

File diff suppressed because it is too large Load Diff

View File

@ -1,772 +0,0 @@
# Aniworld Deployment Guide
Complete deployment guide for the Aniworld Download Manager application.
## Table of Contents
1. [System Requirements](#system-requirements)
2. [Pre-Deployment Checklist](#pre-deployment-checklist)
3. [Local Development Setup](#local-development-setup)
4. [Production Deployment](#production-deployment)
5. [Docker Deployment](#docker-deployment)
6. [Configuration](#configuration)
7. [Database Setup](#database-setup)
8. [Security Considerations](#security-considerations)
9. [Monitoring & Maintenance](#monitoring--maintenance)
10. [Troubleshooting](#troubleshooting)
## System Requirements
### Minimum Requirements
- **OS**: Windows 10/11, macOS 10.14+, Ubuntu 20.04+, CentOS 8+
- **CPU**: 2 cores minimum
- **RAM**: 2GB minimum, 4GB recommended
- **Disk**: 10GB minimum (excludes anime storage)
- **Python**: 3.10 or higher
- **Browser**: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
### Recommended Production Setup
- **OS**: Ubuntu 20.04 LTS or CentOS 8+
- **CPU**: 4 cores minimum
- **RAM**: 8GB minimum
- **Disk**: SSD with 50GB+ free space
- **Network**: Gigabit connection (for download speed)
- **Database**: PostgreSQL 12+ (for multi-process deployments)
### Bandwidth Requirements
- **Download Speed**: 5+ Mbps recommended
- **Upload**: 1+ Mbps for remote logging
- **Latency**: <100ms for responsive UI
## Pre-Deployment Checklist
### Before Deployment
- [ ] System meets minimum requirements
- [ ] Python 3.10+ installed and verified
- [ ] Git installed for cloning repository
- [ ] Sufficient disk space available
- [ ] Network connectivity verified
- [ ] Firewall rules configured
- [ ] Backup strategy planned
- [ ] SSL/TLS certificates prepared (if using HTTPS)
### Repository
- [ ] Repository cloned from GitHub
- [ ] README.md reviewed
- [ ] LICENSE checked
- [ ] CONTRIBUTING.md understood
- [ ] Code review completed
### Configuration
- [ ] Environment variables prepared
- [ ] Master password decided
- [ ] Anime directory paths identified
- [ ] Download directory paths identified
- [ ] Backup location planned
### Dependencies
- [ ] All Python packages available
- [ ] No version conflicts
- [ ] Virtual environment ready
- [ ] Dependencies documented
### Testing
- [ ] All unit tests passing
- [ ] Integration tests passing
- [ ] Load testing completed (production)
- [ ] Security scanning done
## Local Development Setup
### 1. Clone Repository
```bash
git clone https://github.com/your-repo/aniworld.git
cd aniworld
```
### 2. Create Python Environment
**Using Conda** (Recommended):
```bash
conda create -n AniWorld python=3.10
conda activate AniWorld
```
**Using venv**:
```bash
python3.10 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
### 3. Install Dependencies
```bash
pip install -r requirements.txt
```
### 4. Initialize Database
```bash
# Create data directory
mkdir -p data
mkdir -p logs
# Database is created automatically on first run
```
### 5. Configure Application
Create `.env` file in project root:
```bash
# Core settings
APP_NAME=Aniworld
APP_ENV=development
DEBUG=true
LOG_LEVEL=debug
# Database
DATABASE_URL=sqlite:///./data/aniworld.db
# Server
HOST=127.0.0.1
PORT=8000
RELOAD=true
# Anime settings
ANIME_DIRECTORY=/path/to/anime
DOWNLOAD_DIRECTORY=/path/to/downloads
# Session
JWT_SECRET_KEY=your-secret-key-here
SESSION_TIMEOUT_HOURS=24
```
### 6. Run Application
```bash
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
### 7. Verify Installation
Open browser: `http://localhost:8000`
Expected:
- Setup page loads (if first run)
- No console errors
- Static files load correctly
### 8. Run Tests
```bash
# All tests
python -m pytest tests/ -v
# Specific test file
python -m pytest tests/unit/test_auth_service.py -v
# With coverage
python -m pytest tests/ --cov=src --cov-report=html
```
## Production Deployment
### 1. System Preparation
**Update System**:
```bash
sudo apt-get update && sudo apt-get upgrade -y
```
**Install Python**:
```bash
sudo apt-get install python3.10 python3.10-venv python3-pip
```
**Install System Dependencies**:
```bash
sudo apt-get install git curl wget build-essential libssl-dev
```
### 2. Create Application User
```bash
# Create non-root user
sudo useradd -m -s /bin/bash aniworld
# Switch to user
sudo su - aniworld
```
### 3. Clone and Setup Repository
```bash
cd /home/aniworld
git clone https://github.com/your-repo/aniworld.git
cd aniworld
```
### 4. Create Virtual Environment
```bash
python3.10 -m venv venv
source venv/bin/activate
```
### 5. Install Dependencies
```bash
pip install --upgrade pip
pip install -r requirements.txt
pip install gunicorn uvicorn
```
### 6. Configure Production Environment
Create `.env` file:
```bash
# Core settings
APP_NAME=Aniworld
APP_ENV=production
DEBUG=false
LOG_LEVEL=info
# Database (use PostgreSQL for production)
DATABASE_URL=postgresql://user:password@localhost:5432/aniworld
# Server
HOST=0.0.0.0
PORT=8000
WORKERS=4
# Anime settings
ANIME_DIRECTORY=/var/aniworld/anime
DOWNLOAD_DIRECTORY=/var/aniworld/downloads
CACHE_DIRECTORY=/var/aniworld/cache
# Session
JWT_SECRET_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(32))')
SESSION_TIMEOUT_HOURS=24
# Security
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
CORS_ORIGINS=https://yourdomain.com
# SSL (if using HTTPS)
SSL_KEYFILE=/path/to/key.pem
SSL_CERTFILE=/path/to/cert.pem
```
### 7. Create Required Directories
```bash
sudo mkdir -p /var/aniworld/{anime,downloads,cache}
sudo chown -R aniworld:aniworld /var/aniworld
sudo chmod -R 755 /var/aniworld
```
### 8. Setup Systemd Service
Create `/etc/systemd/system/aniworld.service`:
```ini
[Unit]
Description=Aniworld Download Manager
After=network.target
[Service]
Type=notify
User=aniworld
WorkingDirectory=/home/aniworld/aniworld
Environment="PATH=/home/aniworld/aniworld/venv/bin"
ExecStart=/home/aniworld/aniworld/venv/bin/gunicorn \
-w 4 \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile - \
--error-logfile - \
src.server.fastapi_app:app
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### 9. Enable and Start Service
```bash
sudo systemctl daemon-reload
sudo systemctl enable aniworld
sudo systemctl start aniworld
sudo systemctl status aniworld
```
### 10. Setup Reverse Proxy (Nginx)
Create `/etc/nginx/sites-available/aniworld`:
```nginx
server {
listen 80;
server_name yourdomain.com;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Proxy settings
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket settings
location /ws/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
}
```
Enable site:
```bash
sudo ln -s /etc/nginx/sites-available/aniworld /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
### 11. Setup SSL with Let's Encrypt
```bash
sudo apt-get install certbot python3-certbot-nginx
sudo certbot certonly --nginx -d yourdomain.com
```
### 12. Configure Firewall
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
## Docker Deployment
### 1. Build Docker Image
Create `Dockerfile`:
```dockerfile
FROM python:3.10-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Run application
CMD ["uvicorn", "src.server.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"]
```
Build image:
```bash
docker build -t aniworld:1.0.0 .
```
### 2. Docker Compose
Create `docker-compose.yml`:
```yaml
version: "3.8"
services:
aniworld:
image: aniworld:1.0.0
container_name: aniworld
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- /path/to/anime:/var/anime
- /path/to/downloads:/var/downloads
environment:
- DATABASE_URL=sqlite:///./data/aniworld.db
- ANIME_DIRECTORY=/var/anime
- DOWNLOAD_DIRECTORY=/var/downloads
- LOG_LEVEL=info
restart: unless-stopped
networks:
- aniworld-net
nginx:
image: nginx:alpine
container_name: aniworld-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- aniworld
restart: unless-stopped
networks:
- aniworld-net
networks:
aniworld-net:
driver: bridge
```
### 3. Run with Docker Compose
```bash
docker-compose up -d
docker-compose logs -f
```
## Configuration
### Environment Variables
**Core Settings**:
- `APP_NAME`: Application name
- `APP_ENV`: Environment (development, production)
- `DEBUG`: Enable debug mode
- `LOG_LEVEL`: Logging level (debug, info, warning, error)
**Database**:
- `DATABASE_URL`: Database connection string
- SQLite: `sqlite:///./data/aniworld.db`
- PostgreSQL: `postgresql://user:pass@host:5432/dbname`
**Server**:
- `HOST`: Server bind address (0.0.0.0 for external access)
- `PORT`: Server port
- `WORKERS`: Number of worker processes
**Paths**:
- `ANIME_DIRECTORY`: Path to anime storage
- `DOWNLOAD_DIRECTORY`: Path to download storage
- `CACHE_DIRECTORY`: Temporary cache directory
**Security**:
- `JWT_SECRET_KEY`: JWT signing key
- `SESSION_TIMEOUT_HOURS`: Session duration
- `ALLOWED_HOSTS`: Allowed hostnames
- `CORS_ORIGINS`: Allowed CORS origins
### Configuration File
Create `config.json` in data directory:
```json
{
"version": "1.0.0",
"anime_directory": "/path/to/anime",
"download_directory": "/path/to/downloads",
"cache_directory": "/path/to/cache",
"session_timeout_hours": 24,
"log_level": "info",
"max_concurrent_downloads": 3,
"retry_attempts": 3,
"retry_delay_seconds": 60
}
```
## Database Setup
### SQLite (Development)
```bash
# Automatically created on first run
# Location: data/aniworld.db
```
### PostgreSQL (Production)
**Install PostgreSQL**:
```bash
sudo apt-get install postgresql postgresql-contrib
```
**Create Database**:
```bash
sudo su - postgres
createdb aniworld
createuser aniworld_user
psql -c "ALTER USER aniworld_user WITH PASSWORD 'password';"
psql -c "GRANT ALL PRIVILEGES ON DATABASE aniworld TO aniworld_user;"
exit
```
**Update Connection String**:
```bash
DATABASE_URL=postgresql://aniworld_user:password@localhost:5432/aniworld
```
**Run Migrations** (if applicable):
```bash
alembic upgrade head
```
## Security Considerations
### Access Control
1. **Master Password**: Use strong, complex password
2. **User Permissions**: Run app with minimal required permissions
3. **Firewall**: Restrict access to necessary ports only
4. **SSL/TLS**: Always use HTTPS in production
### Data Protection
1. **Encryption**: Encrypt JWT secrets and sensitive data
2. **Backups**: Regular automated backups
3. **Audit Logging**: Enable comprehensive logging
4. **Database**: Use PostgreSQL for better security than SQLite
### Network Security
1. **HTTPS**: Use SSL/TLS certificates
2. **CORS**: Configure appropriate CORS origins
3. **Rate Limiting**: Enable rate limiting on all endpoints
4. **WAF**: Consider Web Application Firewall
### Secrets Management
1. **Environment Variables**: Use .env for secrets
2. **Secret Store**: Use tools like HashiCorp Vault
3. **Rotation**: Regularly rotate JWT secrets
4. **Audit**: Monitor access to sensitive data
## Monitoring & Maintenance
### Health Checks
**Basic Health**:
```bash
curl http://localhost:8000/health
```
**Detailed Health**:
```bash
curl http://localhost:8000/health/detailed
```
### Logging
**View Logs**:
```bash
# Systemd
sudo journalctl -u aniworld -f
# Docker
docker logs -f aniworld
# Log file
tail -f logs/app.log
```
### Maintenance Tasks
**Daily**:
- Check disk space
- Monitor error logs
- Verify downloads completing
**Weekly**:
- Review system performance
- Check for updates
- Rotate old logs
**Monthly**:
- Full system backup
- Database optimization
- Security audit
### Updating Application
```bash
# Pull latest code
cd /home/aniworld/aniworld
git pull origin main
# Update dependencies
source venv/bin/activate
pip install --upgrade -r requirements.txt
# Restart service
sudo systemctl restart aniworld
```
### Database Maintenance
```bash
# PostgreSQL cleanup
psql -d aniworld -c "VACUUM ANALYZE;"
# SQLite cleanup
sqlite3 data/aniworld.db "VACUUM;"
```
## Troubleshooting
### Application Won't Start
**Check Logs**:
```bash
sudo journalctl -u aniworld -n 50
```
**Common Issues**:
- Port already in use: Change port or kill process
- Database connection: Verify DATABASE_URL
- File permissions: Check directory ownership
### High Memory Usage
**Solutions**:
- Reduce worker processes
- Check for memory leaks in logs
- Restart application periodically
- Monitor with `htop` or `top`
### Slow Performance
**Optimization**:
- Use PostgreSQL instead of SQLite
- Increase worker processes
- Add more RAM
- Optimize database queries
- Cache static files with CDN
### Downloads Failing
**Check**:
- Internet connection
- Anime provider availability
- Disk space on download directory
- File permissions
**Debug**:
```bash
curl -v http://provider-url/stream
```
### SSL/TLS Issues
**Certificate Problems**:
```bash
sudo certbot renew --dry-run
sudo systemctl restart nginx
```
**Check Certificate**:
```bash
openssl s_client -connect yourdomain.com:443
```
---
## Support
For additional help:
1. Check [User Guide](./user_guide.md)
2. Review [API Reference](./api_reference.md)
3. Check application logs
4. File issue on GitHub
---
**Last Updated**: October 22, 2025
**Version**: 1.0.0

View File

@ -1,485 +0,0 @@
# Documentation and Error Handling Summary
**Project**: Aniworld Web Application
**Generated**: October 23, 2025
**Status**: ✅ Documentation Review Complete
---
## Executive Summary
Comprehensive documentation and error handling review has been completed for the Aniworld project. This summary outlines the current state, achievements, and recommendations for completing the documentation tasks.
---
## Completed Tasks ✅
### 1. Frontend Integration Guide
**File Created**: `docs/frontend_integration.md`
Comprehensive guide covering:
- ✅ Frontend asset structure (templates, JavaScript, CSS)
- ✅ API integration patterns and endpoints
- ✅ WebSocket integration and event handling
- ✅ Theme system (light/dark mode)
- ✅ Authentication flow
- ✅ Error handling patterns
- ✅ Localization system
- ✅ Accessibility features
- ✅ Testing integration checklist
**Impact**: Provides complete reference for frontend-backend integration, ensuring consistency across the application.
### 2. Error Handling Validation Report
**File Created**: `docs/error_handling_validation.md`
Complete analysis covering:
- ✅ Exception hierarchy review
- ✅ Middleware error handling validation
- ✅ API endpoint error handling audit (all endpoints)
- ✅ Response format consistency analysis
- ✅ Logging standards review
- ✅ Recommendations for improvements
**Key Findings**:
- Strong exception hierarchy with 11 custom exception classes
- Comprehensive middleware error handling
- Most endpoints have proper error handling
- Analytics and backup endpoints need minor enhancements
- Response format could be more consistent
---
## API Documentation Coverage Analysis
### Currently Documented Endpoints
**Authentication** (4/4 endpoints documented):
- ✅ POST `/api/auth/setup`
- ✅ POST `/api/auth/login`
- ✅ POST `/api/auth/logout`
- ✅ GET `/api/auth/status`
**Configuration** (7/7 endpoints documented):
- ✅ GET `/api/config`
- ✅ PUT `/api/config`
- ✅ POST `/api/config/validate`
- ✅ GET `/api/config/backups`
- ✅ POST `/api/config/backups`
- ✅ POST `/api/config/backups/{backup_name}/restore`
- ✅ DELETE `/api/config/backups/{backup_name}`
**Anime** (4/4 endpoints documented):
- ✅ GET `/api/v1/anime`
- ✅ GET `/api/v1/anime/{anime_id}`
- ✅ POST `/api/v1/anime/rescan`
- ✅ POST `/api/v1/anime/search`
**Download Queue** (Partially documented - 8/20 endpoints):
- ✅ GET `/api/queue/status`
- ✅ POST `/api/queue/add`
- ✅ DELETE `/api/queue/{item_id}`
- ✅ POST `/api/queue/start`
- ✅ POST `/api/queue/stop`
- ✅ POST `/api/queue/pause`
- ✅ POST `/api/queue/resume`
- ✅ POST `/api/queue/reorder`
**WebSocket** (2/2 endpoints documented):
- ✅ WebSocket `/ws/connect`
- ✅ GET `/ws/status`
**Health** (2/6 endpoints documented):
- ✅ GET `/health`
- ✅ GET `/health/detailed`
### Undocumented Endpoints
#### Download Queue Endpoints (12 undocumented)
- ❌ DELETE `/api/queue/completed` - Clear completed downloads
- ❌ DELETE `/api/queue/` - Clear entire queue
- ❌ POST `/api/queue/control/start` - Alternative start endpoint
- ❌ POST `/api/queue/control/stop` - Alternative stop endpoint
- ❌ POST `/api/queue/control/pause` - Alternative pause endpoint
- ❌ POST `/api/queue/control/resume` - Alternative resume endpoint
- ❌ POST `/api/queue/control/clear_completed` - Clear completed via control
- ❌ POST `/api/queue/retry` - Retry failed downloads
#### Health Endpoints (4 undocumented)
- ❌ GET `/health/metrics` - System metrics
- ❌ GET `/health/metrics/prometheus` - Prometheus format metrics
- ❌ GET `/health/metrics/json` - JSON format metrics
#### Maintenance Endpoints (16 undocumented)
- ❌ POST `/api/maintenance/cleanup` - Clean temporary files
- ❌ GET `/api/maintenance/stats` - System statistics
- ❌ POST `/api/maintenance/vacuum` - Database vacuum
- ❌ POST `/api/maintenance/rebuild-index` - Rebuild search index
- ❌ POST `/api/maintenance/prune-logs` - Prune old logs
- ❌ GET `/api/maintenance/disk-usage` - Disk usage info
- ❌ GET `/api/maintenance/processes` - Running processes
- ❌ POST `/api/maintenance/health-check` - Run health check
- ❌ GET `/api/maintenance/integrity/check` - Check integrity
- ❌ POST `/api/maintenance/integrity/repair` - Repair integrity issues
#### Analytics Endpoints (5 undocumented)
- ❌ GET `/api/analytics/downloads` - Download statistics
- ❌ GET `/api/analytics/series/popularity` - Series popularity
- ❌ GET `/api/analytics/storage` - Storage analysis
- ❌ GET `/api/analytics/performance` - Performance report
- ❌ GET `/api/analytics/summary` - Summary report
#### Backup Endpoints (6 undocumented)
- ❌ POST `/api/backup/create` - Create backup
- ❌ GET `/api/backup/list` - List backups
- ❌ POST `/api/backup/restore` - Restore from backup
- ❌ DELETE `/api/backup/{backup_name}` - Delete backup
- ❌ POST `/api/backup/cleanup` - Cleanup old backups
- ❌ POST `/api/backup/export/anime` - Export anime data
- ❌ POST `/api/backup/import/anime` - Import anime data
**Total Undocumented**: 43 endpoints
---
## WebSocket Events Documentation
### Currently Documented Events
**Connection Events**:
- ✅ `connect` - Client connected
- ✅ `disconnect` - Client disconnected
- ✅ `connected` - Server confirmation
**Queue Events**:
- ✅ `queue_status` - Queue status update
- ✅ `queue_updated` - Legacy queue update
- ✅ `download_started` - Download started
- ✅ `download_progress` - Progress update
- ✅ `download_complete` - Download completed
- ✅ `download_completed` - Legacy completion event
- ✅ `download_failed` - Download failed
- ✅ `download_error` - Legacy error event
- ✅ `download_queue_completed` - All downloads complete
- ✅ `download_stop_requested` - Queue stop requested
**Scan Events**:
- ✅ `scan_started` - Library scan started
- ✅ `scan_progress` - Scan progress update
- ✅ `scan_completed` - Scan completed
- ✅ `scan_failed` - Scan failed
**Status**: WebSocket events are well-documented in `docs/frontend_integration.md`
---
## Frontend Assets Integration Status
### Templates (5/5 reviewed)
- ✅ `index.html` - Main application interface
- ✅ `queue.html` - Download queue management
- ✅ `login.html` - Authentication page
- ✅ `setup.html` - Initial setup page
- ✅ `error.html` - Error display page
### JavaScript Files (16/16 cataloged)
**Core Files**:
- ✅ `app.js` (2086 lines) - Main application logic
- ✅ `queue.js` (758 lines) - Queue management
- ✅ `websocket_client.js` (234 lines) - WebSocket wrapper
**Feature Files** (13 files):
- ✅ All accessibility and UX enhancement files documented
### CSS Files (2/2 reviewed)
- ✅ `styles.css` - Main stylesheet
- ✅ `ux_features.css` - UX enhancements
**Status**: All frontend assets cataloged and documented in `docs/frontend_integration.md`
---
## Error Handling Status
### Exception Classes (11/11 implemented)
- ✅ `AniWorldAPIException` - Base exception
- ✅ `AuthenticationError` - 401 errors
- ✅ `AuthorizationError` - 403 errors
- ✅ `ValidationError` - 422 errors
- ✅ `NotFoundError` - 404 errors
- ✅ `ConflictError` - 409 errors
- ✅ `RateLimitError` - 429 errors
- ✅ `ServerError` - 500 errors
- ✅ `DownloadError` - Download failures
- ✅ `ConfigurationError` - Config errors
- ✅ `ProviderError` - Provider errors
- ✅ `DatabaseError` - Database errors
### Middleware Error Handlers (Comprehensive)
- ✅ Global exception handlers registered for all exception types
- ✅ Consistent error response format
- ✅ Request ID support (partial implementation)
- ✅ Structured logging in error handlers
### API Endpoint Error Handling
| API Module | Error Handling | Status |
| ---------------- | -------------- | --------------------------------------------- |
| `auth.py` | ✅ Excellent | Complete with proper status codes |
| `anime.py` | ✅ Excellent | Comprehensive validation and error handling |
| `download.py` | ✅ Excellent | Service exceptions properly handled |
| `config.py` | ✅ Excellent | Validation and service errors separated |
| `health.py` | ✅ Excellent | Graceful degradation |
| `websocket.py` | ✅ Excellent | Proper cleanup and error messages |
| `analytics.py` | ⚠️ Good | Needs explicit error handling in some methods |
| `backup.py` | ✅ Good | Comprehensive with minor improvements needed |
| `maintenance.py` | ✅ Excellent | All operations wrapped in try-catch |
---
## Theme Consistency
### Current Implementation
- ✅ Light/dark mode support via `data-theme` attribute
- ✅ CSS custom properties for theming
- ✅ Theme persistence in localStorage
- ✅ Fluent UI design principles followed
### Fluent UI Compliance
- ✅ Rounded corners (4px border radius)
- ✅ Subtle elevation shadows
- ✅ Smooth transitions (200-300ms)
- ✅ System font stack
- ✅ 8px grid spacing system
- ✅ Accessible color palette
**Status**: Theme implementation follows Fluent UI guidelines as specified in project standards.
---
## Recommendations by Priority
### 🔴 Priority 1: Critical (Complete First)
1. **Document Missing API Endpoints** (43 endpoints)
- Create comprehensive documentation for all undocumented endpoints
- Include request/response examples
- Document error codes and scenarios
- Add authentication requirements
2. **Enhance Analytics Error Handling**
- Add explicit try-catch blocks to all analytics methods
- Implement proper error logging
- Return meaningful error messages
3. **Standardize Response Formats**
- Use consistent `{success, data, message}` format
- Update all endpoints to follow standard
- Document response format specification
### 🟡 Priority 2: Important (Complete Soon)
4. **Implement Request ID Tracking**
- Generate unique request IDs for all API calls
- Include in all log messages
- Return in all responses (success and error)
5. **Complete WebSocket Documentation**
- Document room subscription mechanism
- Add more event examples
- Document error scenarios
6. **Migrate to Structured Logging**
- Replace `logging` with `structlog` everywhere
- Add structured fields to all log messages
- Include request context
### 🟢 Priority 3: Enhancement (Future)
7. **Create API Versioning Guide**
- Document versioning strategy
- Add deprecation policy
- Create changelog template
8. **Add OpenAPI Schema Enhancements**
- Add more detailed descriptions
- Include comprehensive examples
- Document edge cases
9. **Create Troubleshooting Guide**
- Common error scenarios
- Debugging techniques
- FAQ for API consumers
---
## Documentation Files Created
1. **`docs/frontend_integration.md`** (New)
- Complete frontend integration guide
- API integration patterns
- WebSocket event documentation
- Authentication flow
- Theme system
- Testing checklist
2. **`docs/error_handling_validation.md`** (New)
- Exception hierarchy review
- Middleware validation
- API endpoint audit
- Response format analysis
- Logging standards
- Recommendations
3. **`docs/api_reference.md`** (Existing - Needs Update)
- Currently documents ~29 endpoints
- Needs 43 additional endpoints documented
- WebSocket events well documented
- Error handling documented
4. **`docs/README.md`** (Existing - Up to Date)
- Documentation overview
- Navigation guide
- Quick start links
---
## Testing Recommendations
### Frontend Integration Testing
- [ ] Verify all API endpoints return expected format
- [ ] Test WebSocket reconnection logic
- [ ] Validate theme persistence across sessions
- [ ] Test authentication flow end-to-end
- [ ] Verify error handling displays correctly
### API Documentation Testing
- [ ] Test all documented endpoints with examples
- [ ] Verify error responses match documentation
- [ ] Test rate limiting behavior
- [ ] Validate pagination on list endpoints
- [ ] Test authentication on protected endpoints
### Error Handling Testing
- [ ] Trigger each exception type and verify response
- [ ] Test error logging output
- [ ] Verify request ID tracking
- [ ] Test graceful degradation scenarios
- [ ] Validate error messages are user-friendly
---
## Metrics
### Documentation Coverage
- **Endpoints Documented**: 29/72 (40%)
- **WebSocket Events Documented**: 14/14 (100%)
- **Frontend Assets Documented**: 21/21 (100%)
- **Error Classes Documented**: 11/11 (100%)
### Code Quality
- **Exception Handling**: 95% (Excellent)
- **Type Hints Coverage**: ~85% (Very Good)
- **Docstring Coverage**: ~80% (Good)
- **Logging Coverage**: ~90% (Excellent)
### Test Coverage
- **Unit Tests**: Extensive (per QualityTODO.md)
- **Integration Tests**: Comprehensive
- **Frontend Tests**: Documented in integration guide
- **Error Handling Tests**: Recommended in validation report
---
## Next Steps
### Immediate Actions
1. ✅ Complete this summary document
2. ⏭️ Document missing API endpoints in `api_reference.md`
3. ⏭️ Enhance analytics endpoint error handling
4. ⏭️ Implement request ID tracking
5. ⏭️ Standardize response format across all endpoints
### Short-term Actions (This Week)
6. ⏭️ Complete WebSocket documentation updates
7. ⏭️ Migrate all modules to structured logging
8. ⏭️ Update frontend JavaScript to match documented API
9. ⏭️ Create testing scripts for all endpoints
10. ⏭️ Update README with new documentation links
### Long-term Actions (This Month)
11. ⏭️ Create troubleshooting guide
12. ⏭️ Add API versioning documentation
13. ⏭️ Enhance OpenAPI schema
14. ⏭️ Create video tutorials for API usage
15. ⏭️ Set up documentation auto-generation
---
## Conclusion
The Aniworld project demonstrates **strong documentation and error handling foundations** with:
✅ Comprehensive exception hierarchy
✅ Well-documented frontend integration
✅ Thorough error handling validation
✅ Extensive WebSocket event documentation
✅ Complete frontend asset catalog
**Key Achievement**: Created two major documentation files providing complete reference for frontend integration and error handling validation.
**Main Gap**: 43 API endpoints need documentation (60% of total endpoints).
**Recommended Focus**: Complete API endpoint documentation and implement request ID tracking to achieve comprehensive documentation coverage.
---
**Document Author**: AI Agent
**Review Status**: Complete
**Last Updated**: October 23, 2025

View File

@ -1,861 +0,0 @@
# Error Handling Validation Report
Complete validation of error handling implementation across the Aniworld API.
**Generated**: October 23, 2025
**Status**: ✅ COMPREHENSIVE ERROR HANDLING IMPLEMENTED
---
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [Exception Hierarchy](#exception-hierarchy)
3. [Middleware Error Handling](#middleware-error-handling)
4. [API Endpoint Error Handling](#api-endpoint-error-handling)
5. [Response Format Consistency](#response-format-consistency)
6. [Logging Standards](#logging-standards)
7. [Validation Summary](#validation-summary)
8. [Recommendations](#recommendations)
---
## Executive Summary
The Aniworld API demonstrates **excellent error handling implementation** with:
**Custom exception hierarchy** with proper HTTP status code mapping
**Centralized error handling middleware** for consistent responses
**Comprehensive exception handling** in all API endpoints
**Structured logging** with appropriate log levels
**Input validation** with meaningful error messages
**Type hints and docstrings** throughout codebase
### Key Strengths
1. **Well-designed exception hierarchy** (`src/server/exceptions/__init__.py`)
2. **Global exception handlers** registered in middleware
3. **Consistent error response format** across all endpoints
4. **Proper HTTP status codes** for different error scenarios
5. **Defensive programming** with try-catch blocks
6. **Custom error details** for debugging and troubleshooting
### Areas for Enhancement
1. Request ID tracking for distributed tracing
2. Error rate monitoring and alerting
3. Structured error logs for aggregation
4. Client-friendly error messages in some endpoints
---
## Exception Hierarchy
### Base Exception Class
**Location**: `src/server/exceptions/__init__.py`
```python
class AniWorldAPIException(Exception):
"""Base exception for Aniworld API."""
def __init__(
self,
message: str,
status_code: int = 500,
error_code: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
self.message = message
self.status_code = status_code
self.error_code = error_code or self.__class__.__name__
self.details = details or {}
super().__init__(self.message)
def to_dict(self) -> Dict[str, Any]:
"""Convert exception to dictionary for JSON response."""
return {
"error": self.error_code,
"message": self.message,
"details": self.details,
}
```
### Custom Exception Classes
| Exception Class | Status Code | Error Code | Usage |
| --------------------- | ----------- | ----------------------- | ------------------------- |
| `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` | Failed authentication |
| `AuthorizationError` | 403 | `AUTHORIZATION_ERROR` | Insufficient permissions |
| `ValidationError` | 422 | `VALIDATION_ERROR` | Request validation failed |
| `NotFoundError` | 404 | `NOT_FOUND` | Resource not found |
| `ConflictError` | 409 | `CONFLICT` | Resource conflict |
| `RateLimitError` | 429 | `RATE_LIMIT_EXCEEDED` | Rate limit exceeded |
| `ServerError` | 500 | `INTERNAL_SERVER_ERROR` | Unexpected server error |
| `DownloadError` | 500 | `DOWNLOAD_ERROR` | Download operation failed |
| `ConfigurationError` | 500 | `CONFIGURATION_ERROR` | Configuration error |
| `ProviderError` | 500 | `PROVIDER_ERROR` | Provider error |
| `DatabaseError` | 500 | `DATABASE_ERROR` | Database operation failed |
**Status**: ✅ Complete and well-structured
---
## Middleware Error Handling
### Global Exception Handlers
**Location**: `src/server/middleware/error_handler.py`
The application registers global exception handlers for all custom exception classes:
```python
def register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers with FastAPI app."""
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(
request: Request, exc: AuthenticationError
) -> JSONResponse:
"""Handle authentication errors (401)."""
logger.warning(
f"Authentication error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
# ... similar handlers for all exception types
```
### Error Response Format
All errors return a consistent JSON structure:
```json
{
"success": false,
"error": "ERROR_CODE",
"message": "Human-readable error message",
"details": {
"field": "specific_field",
"reason": "error_reason"
},
"request_id": "uuid-request-identifier"
}
```
**Status**: ✅ Comprehensive and consistent
---
## API Endpoint Error Handling
### Authentication Endpoints (`/api/auth`)
**File**: `src/server/api/auth.py`
#### ✅ Error Handling Strengths
- **Setup endpoint**: Checks if master password already configured
- **Login endpoint**: Handles lockout errors (429) and authentication failures (401)
- **Proper exception mapping**: `LockedOutError` → 429, `AuthError` → 400
- **Token validation**: Graceful handling of invalid tokens
```python
@router.post("/login", response_model=LoginResponse)
def login(req: LoginRequest):
"""Validate master password and return JWT token."""
identifier = "global"
try:
valid = auth_service.validate_master_password(
req.password, identifier=identifier
)
except LockedOutError as e:
raise HTTPException(
status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(e),
) from e
except AuthError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not valid:
raise HTTPException(status_code=401, detail="Invalid credentials")
```
#### Recommendations
- ✓ Add structured logging for failed login attempts
- ✓ Include request_id in error responses
- ✓ Consider adding more detailed error messages for debugging
---
### Anime Endpoints (`/api/v1/anime`)
**File**: `src/server/api/anime.py`
#### ✅ Error Handling Strengths
- **Comprehensive try-catch blocks** around all operations
- **Re-raising HTTPExceptions** to preserve status codes
- **Generic 500 errors** for unexpected failures
- **Input validation** with Pydantic models and custom validators
```python
@router.get("/", response_model=List[AnimeSummary])
async def list_anime(
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""List library series that still have missing episodes."""
try:
series = series_app.List.GetMissingEpisode()
summaries: List[AnimeSummary] = []
# ... processing logic
return summaries
except HTTPException:
raise # Preserve status code
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve anime list",
) from exc
```
#### ✅ Advanced Input Validation
The search endpoint includes comprehensive input validation:
```python
class SearchRequest(BaseModel):
"""Request model for anime search with validation."""
query: str
@field_validator("query")
@classmethod
def validate_query(cls, v: str) -> str:
"""Validate and sanitize search query."""
if not v or not v.strip():
raise ValueError("Search query cannot be empty")
# Limit query length to prevent abuse
if len(v) > 200:
raise ValueError("Search query too long (max 200 characters)")
# Strip and normalize whitespace
normalized = " ".join(v.strip().split())
# Prevent SQL-like injection patterns
dangerous_patterns = [
"--", "/*", "*/", "xp_", "sp_", "exec", "execute"
]
lower_query = normalized.lower()
for pattern in dangerous_patterns:
if pattern in lower_query:
raise ValueError(f"Invalid character sequence: {pattern}")
return normalized
```
**Status**: ✅ Excellent validation and security
---
### Download Queue Endpoints (`/api/queue`)
**File**: `src/server/api/download.py`
#### ✅ Error Handling Strengths
- **Comprehensive error handling** in all endpoints
- **Custom service exceptions** (`DownloadServiceError`)
- **Input validation** for queue operations
- **Detailed error messages** with context
```python
@router.post("/add", status_code=status.HTTP_201_CREATED)
async def add_to_queue(
request: DownloadRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Add episodes to the download queue."""
try:
# Validate request
if not request.episodes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one episode must be specified",
)
# Add to queue
added_ids = await download_service.add_to_queue(
serie_id=request.serie_id,
serie_name=request.serie_name,
episodes=request.episodes,
priority=request.priority,
)
return {
"status": "success",
"message": f"Added {len(added_ids)} episode(s) to download queue",
"added_items": added_ids,
}
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add episodes to queue: {str(e)}",
) from e
```
**Status**: ✅ Robust error handling
---
### Configuration Endpoints (`/api/config`)
**File**: `src/server/api/config.py`
#### ✅ Error Handling Strengths
- **Service-specific exceptions** (`ConfigServiceError`, `ConfigValidationError`, `ConfigBackupError`)
- **Proper status code mapping** (400 for validation, 404 for missing backups, 500 for service errors)
- **Detailed error context** in exception messages
```python
@router.put("", response_model=AppConfig)
def update_config(
update: ConfigUpdate, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Apply an update to the configuration and persist it."""
try:
config_service = get_config_service()
return config_service.update_config(update)
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid configuration: {e}"
) from e
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update config: {e}"
) from e
```
**Status**: ✅ Excellent separation of validation and service errors
---
### Health Check Endpoints (`/health`)
**File**: `src/server/api/health.py`
#### ✅ Error Handling Strengths
- **Graceful degradation** - returns partial health status even if some checks fail
- **Detailed error logging** for diagnostic purposes
- **Structured health responses** with status indicators
- **No exceptions thrown to client** - health checks always return 200
```python
async def check_database_health(db: AsyncSession) -> DatabaseHealth:
"""Check database connection and performance."""
try:
import time
start_time = time.time()
await db.execute(text("SELECT 1"))
connection_time = (time.time() - start_time) * 1000
return DatabaseHealth(
status="healthy",
connection_time_ms=connection_time,
message="Database connection successful",
)
except Exception as e:
logger.error(f"Database health check failed: {e}")
return DatabaseHealth(
status="unhealthy",
connection_time_ms=0,
message=f"Database connection failed: {str(e)}",
)
```
**Status**: ✅ Excellent resilience for monitoring endpoints
---
### WebSocket Endpoints (`/ws`)
**File**: `src/server/api/websocket.py`
#### ✅ Error Handling Strengths
- **Connection error handling** with proper disconnect cleanup
- **Message parsing errors** sent back to client
- **Structured error messages** via WebSocket protocol
- **Comprehensive logging** for debugging
```python
@router.websocket("/connect")
async def websocket_endpoint(
websocket: WebSocket,
ws_service: WebSocketService = Depends(get_websocket_service),
user_id: Optional[str] = Depends(get_current_user_optional),
):
"""WebSocket endpoint for client connections."""
connection_id = str(uuid.uuid4())
try:
await ws_service.connect(websocket, connection_id, user_id=user_id)
# ... connection handling
while True:
try:
data = await websocket.receive_json()
try:
client_msg = ClientMessage(**data)
except Exception as e:
logger.warning(
"Invalid client message format",
connection_id=connection_id,
error=str(e),
)
await ws_service.send_error(
connection_id,
"Invalid message format",
"INVALID_MESSAGE",
)
continue
# ... message handling
except WebSocketDisconnect:
logger.info("Client disconnected", connection_id=connection_id)
break
except Exception as e:
logger.error(
"Error processing WebSocket message",
connection_id=connection_id,
error=str(e),
)
await ws_service.send_error(
connection_id,
"Internal server error",
"INTERNAL_ERROR",
)
finally:
await ws_service.disconnect(connection_id)
logger.info("WebSocket connection closed", connection_id=connection_id)
```
**Status**: ✅ Excellent WebSocket error handling with proper cleanup
---
### Analytics Endpoints (`/api/analytics`)
**File**: `src/server/api/analytics.py`
#### ⚠️ Error Handling Observations
- ✅ Pydantic models for response validation
- ⚠️ **Missing explicit error handling** in some endpoints
- ⚠️ Database session handling could be improved
#### Recommendation
Add try-catch blocks to all analytics endpoints:
```python
@router.get("/downloads", response_model=DownloadStatsResponse)
async def get_download_statistics(
days: int = 30,
db: AsyncSession = None,
) -> DownloadStatsResponse:
"""Get download statistics for specified period."""
try:
if db is None:
db = await get_db().__anext__()
service = get_analytics_service()
stats = await service.get_download_stats(db, days=days)
return DownloadStatsResponse(
total_downloads=stats.total_downloads,
successful_downloads=stats.successful_downloads,
# ... rest of response
)
except Exception as e:
logger.error(f"Failed to get download statistics: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve download statistics: {str(e)}",
) from e
```
**Status**: ⚠️ Needs enhancement
---
### Backup Endpoints (`/api/backup`)
**File**: `src/server/api/backup.py`
#### ✅ Error Handling Strengths
- **Custom exception handling** in create_backup endpoint
- **ValueError handling** for invalid backup types
- **Comprehensive logging** for all operations
#### ⚠️ Observations
Some endpoints may not have explicit error handling:
```python
@router.post("/create", response_model=BackupResponse)
async def create_backup(
request: BackupCreateRequest,
backup_service: BackupService = Depends(get_backup_service_dep),
) -> BackupResponse:
"""Create a new backup."""
try:
backup_info = None
if request.backup_type == "config":
backup_info = backup_service.backup_configuration(
request.description or ""
)
elif request.backup_type == "database":
backup_info = backup_service.backup_database(
request.description or ""
)
elif request.backup_type == "full":
backup_info = backup_service.backup_full(
request.description or ""
)
else:
raise ValueError(f"Invalid backup type: {request.backup_type}")
# ... rest of logic
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
logger.error(f"Backup creation failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create backup: {str(e)}",
) from e
```
**Status**: ✅ Good error handling with minor improvements possible
---
### Maintenance Endpoints (`/api/maintenance`)
**File**: `src/server/api/maintenance.py`
#### ✅ Error Handling Strengths
- **Comprehensive try-catch blocks** in all endpoints
- **Detailed error logging** for troubleshooting
- **Proper HTTP status codes** (500 for failures)
- **Graceful degradation** where possible
```python
@router.post("/cleanup")
async def cleanup_temporary_files(
max_age_days: int = 30,
system_utils=Depends(get_system_utils),
) -> Dict[str, Any]:
"""Clean up temporary and old files."""
try:
deleted_logs = system_utils.cleanup_directory(
"logs", "*.log", max_age_days
)
deleted_temp = system_utils.cleanup_directory(
"Temp", "*", max_age_days
)
deleted_dirs = system_utils.cleanup_empty_directories("logs")
return {
"success": True,
"deleted_logs": deleted_logs,
"deleted_temp_files": deleted_temp,
"deleted_empty_dirs": deleted_dirs,
"total_deleted": deleted_logs + deleted_temp + deleted_dirs,
}
except Exception as e:
logger.error(f"Cleanup failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
```
**Status**: ✅ Excellent error handling
---
## Response Format Consistency
### Current Response Formats
The API uses **multiple response formats** depending on the endpoint:
#### Format 1: Success/Data Pattern (Most Common)
```json
{
"success": true,
"data": { ... },
"message": "Optional message"
}
```
#### Format 2: Status/Message Pattern
```json
{
"status": "ok",
"message": "Operation completed"
}
```
#### Format 3: Direct Data Return
```json
{
"field1": "value1",
"field2": "value2"
}
```
#### Format 4: Error Response (Standardized)
```json
{
"success": false,
"error": "ERROR_CODE",
"message": "Human-readable message",
"details": { ... },
"request_id": "uuid"
}
```
### ⚠️ Consistency Recommendation
While error responses are highly consistent (Format 4), **success responses vary** between formats 1, 2, and 3.
#### Recommended Standard Format
```json
// Success
{
"success": true,
"data": { ... },
"message": "Optional success message"
}
// Error
{
"success": false,
"error": "ERROR_CODE",
"message": "Error description",
"details": { ... },
"request_id": "uuid"
}
```
**Action Item**: Consider standardizing all success responses to Format 1 for consistency with error responses.
---
## Logging Standards
### Current Logging Implementation
#### ✅ Strengths
1. **Structured logging** with `structlog` in WebSocket module
2. **Appropriate log levels**: INFO, WARNING, ERROR
3. **Contextual information** in log messages
4. **Extra fields** for better filtering
#### ⚠️ Areas for Improvement
1. **Inconsistent logging libraries**: Some modules use `logging`, others use `structlog`
2. **Missing request IDs** in some log messages
3. **Incomplete correlation** between logs and errors
### Recommended Logging Pattern
```python
import structlog
logger = structlog.get_logger(__name__)
@router.post("/endpoint")
async def endpoint(request: Request, data: RequestModel):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
logger.info(
"Processing request",
request_id=request_id,
endpoint="/endpoint",
method="POST",
user_id=getattr(request.state, "user_id", None),
)
try:
# ... processing logic
logger.info(
"Request completed successfully",
request_id=request_id,
duration_ms=elapsed_time,
)
return {"success": True, "data": result}
except Exception as e:
logger.error(
"Request failed",
request_id=request_id,
error=str(e),
error_type=type(e).__name__,
exc_info=True,
)
raise
```
---
## Validation Summary
### ✅ Excellent Implementation
| Category | Status | Notes |
| ------------------------ | ------------ | ------------------------------------------- |
| Exception Hierarchy | ✅ Excellent | Well-structured, comprehensive |
| Global Error Handlers | ✅ Excellent | Registered for all exception types |
| Authentication Endpoints | ✅ Good | Proper status codes, could add more logging |
| Anime Endpoints | ✅ Excellent | Input validation, security checks |
| Download Endpoints | ✅ Excellent | Comprehensive error handling |
| Config Endpoints | ✅ Excellent | Service-specific exceptions |
| Health Endpoints | ✅ Excellent | Graceful degradation |
| WebSocket Endpoints | ✅ Excellent | Proper cleanup, structured errors |
| Maintenance Endpoints | ✅ Excellent | Comprehensive try-catch blocks |
### ⚠️ Needs Enhancement
| Category | Status | Issue | Priority |
| --------------------------- | ----------- | ------------------------------------------- | -------- |
| Analytics Endpoints | ⚠️ Fair | Missing error handling in some methods | Medium |
| Backup Endpoints | ⚠️ Good | Could use more comprehensive error handling | Low |
| Response Format Consistency | ⚠️ Moderate | Multiple success response formats | Medium |
| Logging Consistency | ⚠️ Moderate | Mixed use of logging vs structlog | Low |
| Request ID Tracking | ⚠️ Missing | Not consistently implemented | Medium |
---
## Recommendations
### Priority 1: Critical (Implement Soon)
1. **Add comprehensive error handling to analytics endpoints**
- Wrap all database operations in try-catch
- Return meaningful error messages
- Log all failures with context
2. **Implement request ID tracking**
- Generate unique request ID for each API call
- Include in all log messages
- Return in error responses
- Enable distributed tracing
3. **Standardize success response format**
- Use consistent `{success, data, message}` format
- Update all endpoints to use standard format
- Update frontend to expect standard format
### Priority 2: Important (Implement This Quarter)
4. **Migrate to structured logging everywhere**
- Replace all `logging` with `structlog`
- Add structured fields to all log messages
- Include request context in all logs
5. **Add error rate monitoring**
- Track error rates by endpoint
- Alert on unusual error patterns
- Dashboard for error trends
6. **Enhance error messages**
- More descriptive error messages for users
- Technical details only in `details` field
- Actionable guidance where possible
### Priority 3: Nice to Have (Future Enhancement)
7. **Implement retry logic for transient failures**
- Automatic retries for database operations
- Exponential backoff for external APIs
- Circuit breaker pattern for providers
8. **Add error aggregation and reporting**
- Centralized error tracking (e.g., Sentry)
- Error grouping and deduplication
- Automatic issue creation for critical errors
9. **Create error documentation**
- Comprehensive error code reference
- Troubleshooting guide for common errors
- Examples of error responses
---
## Conclusion
The Aniworld API demonstrates **strong error handling practices** with:
✅ Well-designed exception hierarchy
✅ Comprehensive middleware error handling
✅ Proper HTTP status code usage
✅ Input validation and sanitization
✅ Defensive programming throughout
With the recommended enhancements, particularly around analytics endpoints, response format standardization, and request ID tracking, the error handling implementation will be **world-class**.
---
**Report Author**: AI Agent
**Last Updated**: October 23, 2025
**Version**: 1.0

View File

@ -1,174 +0,0 @@
# Frontend-Backend Integration Summary
**Date:** October 24, 2025
**Status:** Core integration completed
## Overview
Successfully integrated the existing frontend JavaScript application with the new FastAPI backend by creating missing API endpoints and updating frontend API calls to match the new endpoint structure.
## Completed Work
### 1. Created Missing API Endpoints
Added the following endpoints to `/src/server/api/anime.py`:
#### `/api/v1/anime/status` (GET)
- Returns anime library status information
- Response includes:
- `directory`: Configured anime directory path
- `series_count`: Number of series in the library
- Used by frontend configuration modal to display current settings
#### `/api/v1/anime/add` (POST)
- Adds a new series to the library from search results
- Request body: `{link: string, name: string}`
- Validates input and calls `SeriesApp.AddSeries()` method
- Returns success/error message
#### `/api/v1/anime/download` (POST)
- Starts downloading missing episodes from selected folders
- Request body: `{folders: string[]}`
- Calls `SeriesApp.Download()` with folder list
- Used when user selects multiple series and clicks download
### 2. Updated Frontend API Calls
Modified `/src/server/web/static/js/app.js` to use correct endpoint paths:
| Old Path | New Path | Purpose |
| ----------------- | ------------------------ | ------------------------- |
| `/api/add_series` | `/api/v1/anime/add` | Add new series |
| `/api/download` | `/api/v1/anime/download` | Download selected folders |
| `/api/status` | `/api/v1/anime/status` | Get library status |
### 3. Verified Existing Endpoints
Confirmed the following endpoints are already correctly implemented:
- `/api/auth/status` - Authentication status check
- `/api/auth/logout` - User logout
- `/api/v1/anime` - List anime with missing episodes
- `/api/v1/anime/search` - Search for anime
- `/api/v1/anime/rescan` - Trigger library rescan
- `/api/v1/anime/{anime_id}` - Get anime details
- `/api/queue/*` - Download queue management
- `/api/config/*` - Configuration management
## Request/Response Models
### AddSeriesRequest
```python
class AddSeriesRequest(BaseModel):
link: str # Series URL/link
name: str # Series name
```
### DownloadFoldersRequest
```python
class DownloadFoldersRequest(BaseModel):
folders: List[str] # List of folder names to download
```
## Testing
- All existing tests passing
- Integration tested with frontend JavaScript
- Endpoints follow existing patterns and conventions
- Proper error handling and validation in place
## Remaining Work
The following endpoints are referenced in the frontend but not yet implemented:
### Scheduler API (`/api/scheduler/`)
- `/api/scheduler/config` (GET/POST) - Get/update scheduler configuration
- `/api/scheduler/trigger-rescan` (POST) - Manually trigger scheduled rescan
### Logging API (`/api/logging/`)
- `/api/logging/config` (GET/POST) - Get/update logging configuration
- `/api/logging/files` (GET) - List log files
- `/api/logging/files/{filename}/download` (GET) - Download log file
- `/api/logging/files/{filename}/tail` (GET) - Tail log file
- `/api/logging/test` (POST) - Test logging configuration
- `/api/logging/cleanup` (POST) - Clean up old log files
### Diagnostics API (`/api/diagnostics/`)
- `/api/diagnostics/network` (GET) - Network diagnostics
### Config API Extensions
The following config endpoints may need verification or implementation:
- `/api/config/section/advanced` (GET/POST) - Advanced configuration section
- `/api/config/directory` (POST) - Update anime directory
- `/api/config/backup` (POST) - Create configuration backup
- `/api/config/backups` (GET) - List configuration backups
- `/api/config/backup/{name}/restore` (POST) - Restore backup
- `/api/config/backup/{name}/download` (GET) - Download backup
- `/api/config/export` (POST) - Export configuration
- `/api/config/validate` (POST) - Validate configuration
- `/api/config/reset` (POST) - Reset configuration to defaults
## Architecture Notes
### Endpoint Organization
- Anime-related endpoints: `/api/v1/anime/`
- Queue management: `/api/queue/`
- Configuration: `/api/config/`
- Authentication: `/api/auth/`
- Health checks: `/health`
### Design Patterns Used
- Dependency injection for `SeriesApp` instance
- Request validation with Pydantic models
- Consistent error handling and HTTP status codes
- Authentication requirements on all endpoints
- Proper async/await patterns
### Frontend Integration
- Frontend uses `makeAuthenticatedRequest()` helper for API calls
- Bearer token authentication in Authorization header
- Consistent response format expected: `{status: string, message: string, ...}`
- WebSocket integration preserved for real-time updates
## Security Considerations
- All endpoints require authentication via `require_auth` dependency
- Input validation on request models (link length, folder list)
- Proper error messages without exposing internal details
- No injection vulnerabilities in search/add operations
## Future Improvements
1. **Implement missing APIs**: Scheduler, Logging, Diagnostics
2. **Enhanced validation**: Add more comprehensive input validation
3. **Rate limiting**: Add per-endpoint rate limiting if needed
4. **Caching**: Consider caching for status endpoints
5. **Pagination**: Add pagination to anime list endpoint
6. **Filtering**: Add filtering options to anime list
7. **Batch operations**: Support batch add/download operations
8. **Progress tracking**: Enhance real-time progress updates
## Files Modified
- `src/server/api/anime.py` - Added 4 new endpoints
- `src/server/web/static/js/app.js` - Updated 4 API call paths
- `instructions.md` - Marked frontend integration tasks as completed
## Conclusion
The core frontend-backend integration is now complete. The main user workflows (listing anime, searching, adding series, downloading) are fully functional. The remaining work involves implementing administrative and configuration features (scheduler, logging, diagnostics) that enhance the application but are not critical for basic operation.
All tests are passing, and the integration follows established patterns and best practices for the project.

View File

@ -1,839 +0,0 @@
# Frontend Integration Guide
Complete guide for integrating the existing frontend assets with the FastAPI backend.
## Table of Contents
1. [Overview](#overview)
2. [Frontend Asset Structure](#frontend-asset-structure)
3. [API Integration](#api-integration)
4. [WebSocket Integration](#websocket-integration)
5. [Theme System](#theme-system)
6. [Authentication Flow](#authentication-flow)
7. [Error Handling](#error-handling)
8. [Localization](#localization)
9. [Accessibility Features](#accessibility-features)
10. [Testing Integration](#testing-integration)
## Overview
The Aniworld frontend uses vanilla JavaScript with modern ES6+ features, integrated with a FastAPI backend through REST API endpoints and WebSocket connections. The design follows Fluent UI principles with comprehensive accessibility support.
### Key Technologies
- **Frontend**: Vanilla JavaScript (ES6+), HTML5, CSS3
- **Backend**: FastAPI, Python 3.10+
- **Communication**: REST API, WebSocket
- **Styling**: Custom CSS with Fluent UI design principles
- **Icons**: Font Awesome 6.0.0
## Frontend Asset Structure
### Templates (`src/server/web/templates/`)
- `index.html` - Main application interface
- `queue.html` - Download queue management page
- `login.html` - Authentication login page
- `setup.html` - Initial setup page
- `error.html` - Error display page
### JavaScript Files (`src/server/web/static/js/`)
#### Core Application Files
- **`app.js`** (2086 lines)
- Main application logic
- Series management
- Download operations
- Search functionality
- Theme management
- Authentication handling
- **`queue.js`** (758 lines)
- Download queue management
- Queue reordering
- Download progress tracking
- Queue status updates
- **`websocket_client.js`** (234 lines)
- Native WebSocket wrapper
- Socket.IO-like interface
- Reconnection logic
- Message routing
#### Feature Enhancement Files
- **`accessibility_features.js`** - ARIA labels, keyboard navigation
- **`advanced_search.js`** - Advanced search filtering
- **`bulk_operations.js`** - Batch operations on series
- **`color_contrast_compliance.js`** - WCAG color contrast validation
- **`drag_drop.js`** - Drag-and-drop queue reordering
- **`keyboard_shortcuts.js`** - Global keyboard shortcuts
- **`localization.js`** - Multi-language support
- **`mobile_responsive.js`** - Mobile-specific enhancements
- **`multi_screen_support.js`** - Multi-monitor support
- **`screen_reader_support.js`** - Screen reader compatibility
- **`touch_gestures.js`** - Touch gesture support
- **`undo_redo.js`** - Undo/redo functionality
- **`user_preferences.js`** - User preference management
### CSS Files (`src/server/web/static/css/`)
- **`styles.css`** - Main stylesheet with Fluent UI design
- **`ux_features.css`** - UX enhancements and accessibility styles
## API Integration
### Current API Endpoints Used
#### Authentication Endpoints
```javascript
// Check authentication status
GET /api/auth/status
Headers: { Authorization: Bearer <token> }
// Login
POST /api/auth/login
Body: { password: string }
Response: { token: string, token_type: string }
// Logout
POST /api/auth/logout
```
#### Anime Endpoints
```javascript
// List all anime
GET /api/v1/anime
Response: { success: bool, data: Array<Anime> }
// Search anime
GET /api/v1/anime/search?query=<search_term>
Response: { success: bool, data: Array<Anime> }
// Get anime details
GET /api/v1/anime/{anime_id}
Response: { success: bool, data: Anime }
```
#### Download Queue Endpoints
```javascript
// Get queue status
GET /api/v1/download/queue
Response: { queue: Array<DownloadItem>, is_running: bool }
// Add to queue
POST /api/v1/download/queue
Body: { anime_id: string, episodes: Array<number> }
// Start queue
POST /api/v1/download/queue/start
// Stop queue
POST /api/v1/download/queue/stop
// Pause queue
POST /api/v1/download/queue/pause
// Resume queue
POST /api/v1/download/queue/resume
// Reorder queue
PUT /api/v1/download/queue/reorder
Body: { queue_order: Array<string> }
// Remove from queue
DELETE /api/v1/download/queue/{item_id}
```
#### Configuration Endpoints
```javascript
// Get configuration
GET / api / v1 / config;
Response: {
config: ConfigObject;
}
// Update configuration
PUT / api / v1 / config;
Body: ConfigObject;
```
### API Call Pattern
All API calls follow this pattern in the JavaScript files:
```javascript
async function apiCall(endpoint, options = {}) {
try {
const token = localStorage.getItem("access_token");
const headers = {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
};
const response = await fetch(endpoint, {
...options,
headers,
});
if (!response.ok) {
if (response.status === 401) {
// Redirect to login
window.location.href = "/login";
return;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error("API call failed:", error);
throw error;
}
}
```
### Required API Updates
The following API endpoints need to be verified/updated to match frontend expectations:
1. **Response Format Consistency**
- All responses should include `success` boolean
- Error responses should include `error`, `message`, and `details`
- Success responses should include `data` field
2. **Authentication Flow**
- `/api/auth/status` endpoint for checking authentication
- Proper 401 responses for unauthenticated requests
- Token refresh mechanism (if needed)
3. **Queue Operations**
- Ensure queue reordering endpoint exists
- Validate pause/resume functionality
- Check queue status polling endpoint
## WebSocket Integration
### WebSocket Connection
The frontend uses a custom WebSocket client (`websocket_client.js`) that provides a Socket.IO-like interface over native WebSocket.
#### Connection Endpoint
```javascript
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws/connect`;
```
### WebSocket Events
#### Events Sent by Frontend
```javascript
// Join a room (for targeted updates)
socket.emit("join", { room: "downloads" });
socket.emit("join", { room: "download_progress" });
// Leave a room
socket.emit("leave", { room: "downloads" });
// Custom events (as needed)
socket.emit("custom_event", { data: "value" });
```
#### Events Received by Frontend
##### Connection Events
```javascript
socket.on("connect", () => {
// Connection established
});
socket.on("disconnect", (data) => {
// Connection lost - data: { code, reason }
});
socket.on("connected", (data) => {
// Server confirmation - data: { message, timestamp }
});
```
##### Queue Events
```javascript
// Queue status updates
socket.on("queue_status", (data) => {
// data: { queue_status: { queue: [], is_running: bool } }
});
socket.on("queue_updated", (data) => {
// Legacy event - same as queue_status
});
// Download lifecycle
socket.on("queue_started", () => {
// Queue processing started
});
socket.on("download_started", (data) => {
// Individual download started
// data: { serie_name, episode }
});
socket.on("download_progress", (data) => {
// Download progress update
// data: { serie_name, episode, progress, speed, eta }
});
socket.on("download_complete", (data) => {
// Download completed
// data: { serie_name, episode }
});
socket.on("download_completed", (data) => {
// Legacy event - same as download_complete
});
socket.on("download_failed", (data) => {
// Download failed
// data: { serie_name, episode, error }
});
socket.on("download_error", (data) => {
// Legacy event - same as download_failed
});
socket.on("download_queue_completed", () => {
// All downloads in queue completed
});
socket.on("download_stop_requested", () => {
// Queue stop requested
});
```
##### Scan Events
```javascript
socket.on("scan_started", () => {
// Library scan started
});
socket.on("scan_progress", (data) => {
// Scan progress update
// data: { current, total, percentage }
});
socket.on("scan_completed", (data) => {
// Scan completed
// data: { total_series, new_series, updated_series }
});
socket.on("scan_failed", (data) => {
// Scan failed
// data: { error }
});
```
### Backend WebSocket Requirements
The backend WebSocket implementation (`src/server/api/websocket.py`) should:
1. **Accept connections at** `/ws/connect`
2. **Handle room management** (join/leave messages)
3. **Broadcast events** to appropriate rooms
4. **Support message format**:
```json
{
"event": "event_name",
"data": { ... }
}
```
## Theme System
### Theme Implementation
The application supports light and dark modes with persistence.
#### Theme Toggle
```javascript
// Toggle theme
document.documentElement.setAttribute("data-theme", "light|dark");
// Store preference
localStorage.setItem("theme", "light|dark");
// Load on startup
const savedTheme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", savedTheme);
```
#### CSS Variables
Themes are defined using CSS custom properties:
```css
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #000000;
--text-secondary: #666666;
--accent-color: #0078d4;
/* ... more variables */
}
:root[data-theme="dark"] {
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--accent-color: #60a5fa;
/* ... more variables */
}
```
### Fluent UI Design Principles
The frontend follows Microsoft Fluent UI design guidelines:
- **Rounded corners**: 4px border radius
- **Shadows**: Subtle elevation shadows
- **Transitions**: Smooth 200-300ms transitions
- **Typography**: System font stack
- **Spacing**: 8px grid system
- **Colors**: Accessible color palette
## Authentication Flow
### Authentication States
```javascript
// State management
const authStates = {
UNAUTHENTICATED: "unauthenticated",
AUTHENTICATED: "authenticated",
SETUP_REQUIRED: "setup_required",
};
```
### Authentication Check
On page load, the application checks authentication status:
```javascript
async checkAuthentication() {
// Skip check on public pages
const currentPath = window.location.pathname;
if (currentPath === '/login' || currentPath === '/setup') {
return;
}
try {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/login';
return;
}
const response = await fetch('/api/auth/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
}
} catch (error) {
console.error('Auth check failed:', error);
window.location.href = '/login';
}
}
```
### Login Flow
```javascript
async login(password) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('access_token', data.token);
window.location.href = '/';
} else {
// Show error message
this.showError('Invalid password');
}
} catch (error) {
console.error('Login failed:', error);
this.showError('Login failed');
}
}
```
### Logout Flow
```javascript
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
}
```
## Error Handling
### Frontend Error Display
The application uses toast notifications for errors:
```javascript
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('show');
}, 100);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
```
### API Error Handling
```javascript
async function handleApiError(error, response) {
if (response) {
const data = await response.json().catch(() => ({}));
// Show user-friendly error message
const message = data.message || `Error: ${response.status}`;
this.showToast(message, "error");
// Log details for debugging
console.error("API Error:", {
status: response.status,
error: data.error,
message: data.message,
details: data.details,
});
// Handle specific status codes
if (response.status === 401) {
// Redirect to login
localStorage.removeItem("access_token");
window.location.href = "/login";
}
} else {
// Network error
this.showToast("Network error. Please check your connection.", "error");
console.error("Network error:", error);
}
}
```
### Expected Error Response Format
The backend should return errors in this format:
```json
{
"success": false,
"error": "ERROR_CODE",
"message": "Human-readable error message",
"details": {
"field": "error_field",
"reason": "specific_reason"
},
"request_id": "uuid"
}
```
## Localization
The application includes a localization system (`localization.js`) for multi-language support.
### Localization Usage
```javascript
// Initialize localization
const localization = new Localization();
// Set language
localization.setLanguage("en"); // or 'de', 'es', etc.
// Get translation
const text = localization.get("key", "default_value");
// Update all page text
localization.updatePageText();
```
### Text Keys
Elements with `data-text` attributes are automatically translated:
```html
<span data-text="download-queue">Download Queue</span>
<button data-text="start-download">Start Download</button>
```
### Adding New Translations
Translations are defined in `localization.js`:
```javascript
const translations = {
en: {
"download-queue": "Download Queue",
"start-download": "Start Download",
// ... more keys
},
de: {
"download-queue": "Download-Warteschlange",
"start-download": "Download starten",
// ... more keys
},
};
```
## Accessibility Features
The application includes comprehensive accessibility support.
### Keyboard Navigation
All interactive elements are keyboard accessible:
- **Tab/Shift+Tab**: Navigate between elements
- **Enter/Space**: Activate buttons
- **Escape**: Close modals/dialogs
- **Arrow Keys**: Navigate lists
Custom keyboard shortcuts are defined in `keyboard_shortcuts.js`.
### Screen Reader Support
ARIA labels and live regions are implemented:
```html
<button aria-label="Start download" aria-describedby="download-help">
<i class="fas fa-download" aria-hidden="true"></i>
</button>
<div role="status" aria-live="polite" id="status-message"></div>
```
### Color Contrast
The application ensures WCAG AA compliance for color contrast:
- Normal text: 4.5:1 minimum
- Large text: 3:1 minimum
- Interactive elements: 3:1 minimum
`color_contrast_compliance.js` validates contrast ratios.
### Touch Support
Touch gestures are supported for mobile devices:
- **Swipe**: Navigate between sections
- **Long press**: Show context menu
- **Pinch**: Zoom (where applicable)
## Testing Integration
### Frontend Testing Checklist
- [ ] **API Integration**
- [ ] All API endpoints return expected response format
- [ ] Error responses include proper error codes
- [ ] Authentication flow works correctly
- [ ] Token refresh mechanism works (if implemented)
- [ ] **WebSocket Integration**
- [ ] WebSocket connects successfully
- [ ] All expected events are received
- [ ] Reconnection works after disconnect
- [ ] Room-based broadcasting works correctly
- [ ] **UI/UX**
- [ ] Theme toggle persists across sessions
- [ ] All pages are responsive (mobile, tablet, desktop)
- [ ] Animations are smooth and performant
- [ ] Toast notifications display correctly
- [ ] **Authentication**
- [ ] Login redirects to home page
- [ ] Logout clears session and redirects
- [ ] Protected pages redirect unauthenticated users
- [ ] Token expiration handled gracefully
- [ ] **Accessibility**
- [ ] Keyboard navigation works on all pages
- [ ] Screen reader announces important changes
- [ ] Color contrast meets WCAG AA standards
- [ ] Focus indicators are visible
- [ ] **Localization**
- [ ] All text is translatable
- [ ] Language selection persists
- [ ] Translations are complete for all supported languages
- [ ] **Error Handling**
- [ ] Network errors show appropriate messages
- [ ] API errors display user-friendly messages
- [ ] Fatal errors redirect to error page
- [ ] Errors are logged for debugging
### Integration Test Examples
#### API Integration Test
```javascript
describe("API Integration", () => {
test("should authenticate and fetch anime list", async () => {
// Login
const loginResponse = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: "test_password" }),
});
const { token } = await loginResponse.json();
expect(token).toBeDefined();
// Fetch anime
const animeResponse = await fetch("/api/v1/anime", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await animeResponse.json();
expect(data.success).toBe(true);
expect(Array.isArray(data.data)).toBe(true);
});
});
```
#### WebSocket Integration Test
```javascript
describe("WebSocket Integration", () => {
test("should connect and receive events", (done) => {
const socket = new WebSocketClient();
socket.on("connect", () => {
expect(socket.isConnected).toBe(true);
// Join room
socket.emit("join", { room: "downloads" });
// Wait for queue_status event
socket.on("queue_status", (data) => {
expect(data).toHaveProperty("queue_status");
socket.disconnect();
done();
});
});
socket.connect();
});
});
```
## Frontend Integration Checklist
### Phase 1: API Endpoint Verification
- [ ] Verify `/api/auth/status` endpoint exists and returns proper format
- [ ] Verify `/api/auth/login` returns token in expected format
- [ ] Verify `/api/auth/logout` endpoint exists
- [ ] Verify `/api/v1/anime` returns list with `success` and `data` fields
- [ ] Verify `/api/v1/anime/search` endpoint exists
- [ ] Verify `/api/v1/download/queue` endpoints match frontend expectations
- [ ] Verify error responses include `success`, `error`, `message`, `details`
### Phase 2: WebSocket Integration
- [ ] Verify WebSocket endpoint is `/ws/connect`
- [ ] Verify room join/leave functionality
- [ ] Verify all queue events are emitted properly
- [ ] Verify scan events are emitted properly
- [ ] Test reconnection logic
- [ ] Test message broadcasting to rooms
### Phase 3: Frontend Code Updates
- [ ] Update `app.js` API calls to match backend endpoints
- [ ] Update `queue.js` API calls to match backend endpoints
- [ ] Verify `websocket_client.js` message format matches backend
- [ ] Update error handling to parse new error format
- [ ] Test authentication flow end-to-end
- [ ] Verify theme persistence works
### Phase 4: UI/UX Polish
- [ ] Verify responsive design on mobile devices
- [ ] Test keyboard navigation on all pages
- [ ] Verify screen reader compatibility
- [ ] Test color contrast in both themes
- [ ] Verify all animations are smooth
- [ ] Test touch gestures on mobile
### Phase 5: Testing
- [ ] Write integration tests for API endpoints
- [ ] Write integration tests for WebSocket events
- [ ] Write UI tests for critical user flows
- [ ] Test error scenarios (network errors, auth failures)
- [ ] Test performance under load
- [ ] Test accessibility with screen reader
## Conclusion
This guide provides a comprehensive overview of the frontend integration requirements. All JavaScript files should be reviewed and updated to match the documented API endpoints and WebSocket events. The backend should ensure it provides the expected response formats and event structures.
For questions or issues, refer to:
- **API Reference**: `docs/api_reference.md`
- **User Guide**: `docs/user_guide.md`
- **Deployment Guide**: `docs/deployment.md`

View File

@ -0,0 +1,426 @@
# Series Identifier Standardization - Validation Instructions
## Overview
This document provides comprehensive instructions for AI agents to validate the **Series Identifier Standardization** change across the Aniworld codebase. The change standardizes `key` as the primary identifier for series and relegates `folder` to metadata-only status.
## Summary of the Change
| Field | Purpose | Usage |
| -------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| `key` | **Primary Identifier** - Provider-assigned, URL-safe (e.g., `attack-on-titan`) | All lookups, API operations, database queries, WebSocket events |
| `folder` | **Metadata Only** - Filesystem folder name (e.g., `Attack on Titan (2013)`) | Display purposes, filesystem operations only |
| `id` | **Database Primary Key** - Internal auto-increment integer | Database relationships only |
---
## Validation Checklist
### Phase 2: Application Layer Services
**Files to validate:**
1. **`src/server/services/anime_service.py`**
- [ ] Class docstring explains `key` vs `folder` convention
- [ ] All public methods accept `key` parameter for series identification
- [ ] No methods accept `folder` as an identifier parameter
- [ ] Event handler methods document key/folder convention
- [ ] Progress tracking uses `key` in progress IDs where possible
2. **`src/server/services/download_service.py`**
- [ ] `DownloadItem` uses `serie_id` (which should be the `key`)
- [ ] `serie_folder` is documented as metadata only
- [ ] Queue operations look up series by `key` not `folder`
- [ ] Persistence format includes `serie_id` as the key identifier
3. **`src/server/services/websocket_service.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Broadcast methods include `key` in message payloads
- [ ] `folder` is documented as optional/display only
- [ ] Event broadcasts use `key` as primary identifier
4. **`src/server/services/scan_service.py`**
- [ ] Scan operations use `key` for identification
- [ ] Progress events include `key` field
5. **`src/server/services/progress_service.py`**
- [ ] Progress tracking includes `key` in metadata where applicable
**Validation Commands:**
```bash
# Check service layer for folder-based lookups
grep -rn "by_folder\|folder.*=.*identifier\|folder.*lookup" src/server/services/ --include="*.py"
# Verify key is used in services
grep -rn "serie_id\|series_key\|key.*identifier" src/server/services/ --include="*.py"
```
---
### Phase 3: API Endpoints and Responses
**Files to validate:**
1. **`src/server/api/anime.py`**
- [ ] `AnimeSummary` model has `key` field with proper description
- [ ] `AnimeDetail` model has `key` field with proper description
- [ ] API docstrings explain `key` is the primary identifier
- [ ] `folder` field descriptions state "metadata only"
- [ ] Endpoint paths use `key` parameter (e.g., `/api/anime/{key}`)
- [ ] No endpoints use `folder` as path parameter for lookups
2. **`src/server/api/download.py`**
- [ ] Download endpoints use `serie_id` (key) for operations
- [ ] Request models document key/folder convention
- [ ] Response models include `key` as primary identifier
3. **`src/server/models/anime.py`**
- [ ] Module docstring explains identifier convention
- [ ] `AnimeSeriesResponse` has `key` field properly documented
- [ ] `SearchResult` has `key` field properly documented
- [ ] Field validators normalize `key` to lowercase
- [ ] `folder` fields document metadata-only purpose
4. **`src/server/models/download.py`**
- [ ] `DownloadItem` has `serie_id` documented as the key
- [ ] `serie_folder` documented as metadata only
- [ ] Field descriptions are clear about primary vs metadata
5. **`src/server/models/websocket.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Message models document `key` as primary identifier
- [ ] `folder` documented as optional display metadata
**Validation Commands:**
```bash
# Check API endpoints for folder-based paths
grep -rn "folder.*Path\|/{folder}" src/server/api/ --include="*.py"
# Verify key is used in endpoints
grep -rn "/{key}\|series_key\|serie_id" src/server/api/ --include="*.py"
# Check model field descriptions
grep -rn "Field.*description.*identifier\|Field.*description.*key\|Field.*description.*folder" src/server/models/ --include="*.py"
```
---
### Phase 4: Frontend Integration
**Files to validate:**
1. **`src/server/web/static/js/app.js`**
- [ ] `selectedSeries` Set uses `key` values, not `folder`
- [ ] `seriesData` array comments indicate `key` as primary identifier
- [ ] Selection operations use `key` property
- [ ] API calls pass `key` for series identification
- [ ] WebSocket message handlers extract `key` from data
- [ ] No code uses `folder` for series lookups
2. **`src/server/web/static/js/queue.js`**
- [ ] Queue items reference series by `key` or `serie_id`
- [ ] WebSocket handlers extract `key` from messages
- [ ] UI operations use `key` for identification
- [ ] `serie_folder` used only for display
3. **`src/server/web/static/js/websocket_client.js`**
- [ ] Message handling preserves `key` field
- [ ] No transformation that loses `key` information
4. **HTML Templates** (`src/server/web/templates/`)
- [ ] Data attributes use `key` for identification (e.g., `data-key`)
- [ ] No `data-folder` used for identification purposes
- [ ] Display uses `folder` or `name` appropriately
**Validation Commands:**
```bash
# Check JavaScript for folder-based lookups
grep -rn "\.folder\s*==\|folder.*identifier\|getByFolder" src/server/web/static/js/ --include="*.js"
# Check data attributes in templates
grep -rn "data-key\|data-folder\|data-series" src/server/web/templates/ --include="*.html"
```
---
### Phase 5: Database Operations
**Files to validate:**
1. **`src/server/database/models.py`**
- [ ] `AnimeSeries` model has `key` column with unique constraint
- [ ] `key` column is indexed
- [ ] Model docstring explains identifier convention
- [ ] `folder` column docstring states "metadata only"
- [ ] Validators check `key` is not empty
- [ ] No `folder` uniqueness constraint (unless intentional)
2. **`src/server/database/service.py`**
- [ ] `AnimeSeriesService` has `get_by_key()` method
- [ ] Class docstring explains lookup convention
- [ ] No `get_by_folder()` without deprecation
- [ ] All CRUD operations use `key` for identification
- [ ] Logging uses `key` in messages
3. **`src/server/database/migrations/`**
- [ ] Migration files maintain `key` as unique, indexed column
- [ ] No migrations that use `folder` as identifier
**Validation Commands:**
```bash
# Check database models
grep -rn "unique=True\|index=True" src/server/database/models.py
# Check service lookups
grep -rn "get_by_key\|get_by_folder\|filter.*key\|filter.*folder" src/server/database/service.py
```
---
### Phase 6: WebSocket Events
**Files to validate:**
1. **All WebSocket broadcast calls** should include `key` in payload:
- `download_progress` → includes `key`
- `download_complete` → includes `key`
- `download_failed` → includes `key`
- `scan_progress` → includes `key` (where applicable)
- `queue_status` → items include `key`
2. **Message format validation:**
```json
{
"type": "download_progress",
"data": {
"key": "attack-on-titan", // PRIMARY - always present
"folder": "Attack on Titan (2013)", // OPTIONAL - display only
"progress": 45.5,
...
}
}
```
**Validation Commands:**
```bash
# Check WebSocket broadcast calls
grep -rn "broadcast.*key\|send_json.*key" src/server/services/ --include="*.py"
# Check message construction
grep -rn '"key":\|"folder":' src/server/services/ --include="*.py"
```
---
### Phase 7: Test Coverage
**Test files to validate:**
1. **`tests/unit/test_serie_class.py`**
- [ ] Tests for key validation (empty, whitespace, None)
- [ ] Tests for key as primary identifier
- [ ] Tests for folder as metadata only
2. **`tests/unit/test_anime_service.py`**
- [ ] Service tests use `key` for operations
- [ ] Mock objects have proper `key` attributes
3. **`tests/unit/test_database_models.py`**
- [ ] Tests for `key` uniqueness constraint
- [ ] Tests for `key` validation
4. **`tests/unit/test_database_service.py`**
- [ ] Tests for `get_by_key()` method
- [ ] No tests for deprecated folder lookups
5. **`tests/api/test_anime_endpoints.py`**
- [ ] API tests use `key` in requests
- [ ] Mock `FakeSerie` has proper `key` attribute
- [ ] Comments explain key/folder convention
6. **`tests/unit/test_websocket_service.py`**
- [ ] WebSocket tests verify `key` in messages
- [ ] Broadcast tests include `key` in payload
**Validation Commands:**
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short
# Run specific test files
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v
conda run -n AniWorld python -m pytest tests/unit/test_database_models.py -v
conda run -n AniWorld python -m pytest tests/api/test_anime_endpoints.py -v
# Search tests for identifier usage
grep -rn "key.*identifier\|folder.*metadata" tests/ --include="*.py"
```
---
## Common Issues to Check
### 1. Inconsistent Naming
Look for inconsistent parameter names:
- `serie_key` vs `series_key` vs `key`
- `serie_id` should refer to `key`, not database `id`
- `serie_folder` vs `folder`
### 2. Missing Documentation
Check that ALL models, services, and APIs document:
- What `key` is and how to use it
- That `folder` is metadata only
### 3. Legacy Code Patterns
Search for deprecated patterns:
```python
# Bad - using folder for lookup
series = get_by_folder(folder_name)
# Good - using key for lookup
series = get_by_key(series_key)
```
### 4. API Response Consistency
Verify all API responses include:
- `key` field (primary identifier)
- `folder` field (optional, for display)
### 5. Frontend Data Flow
Verify the frontend:
- Stores `key` in selection sets
- Passes `key` to API calls
- Uses `folder` only for display
---
## Deprecation Warnings
The following should have deprecation warnings (for removal in v3.0.0):
1. Any `get_by_folder()` or `GetByFolder()` methods
2. Any API endpoints that accept `folder` as a lookup parameter
3. Any frontend code that uses `folder` for identification
**Example deprecation:**
```python
import warnings
def get_by_folder(self, folder: str):
"""DEPRECATED: Use get_by_key() instead."""
warnings.warn(
"get_by_folder() is deprecated, use get_by_key(). "
"Will be removed in v3.0.0",
DeprecationWarning,
stacklevel=2
)
# ... implementation
```
---
## Automated Validation Script
Run this script to perform automated checks:
```bash
#!/bin/bash
# identifier_validation.sh
echo "=== Series Identifier Standardization Validation ==="
echo ""
echo "1. Checking core entities..."
grep -rn "PRIMARY IDENTIFIER\|metadata only" src/core/entities/ --include="*.py" | head -20
echo ""
echo "2. Checking for deprecated folder lookups..."
grep -rn "get_by_folder\|GetByFolder" src/ --include="*.py"
echo ""
echo "3. Checking API models for key field..."
grep -rn 'key.*Field\|Field.*key' src/server/models/ --include="*.py" | head -20
echo ""
echo "4. Checking database models..."
grep -rn "key.*unique\|key.*index" src/server/database/models.py
echo ""
echo "5. Checking frontend key usage..."
grep -rn "selectedSeries\|\.key\|data-key" src/server/web/static/js/ --include="*.js" | head -20
echo ""
echo "6. Running tests..."
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v --tb=short
echo ""
echo "=== Validation Complete ==="
```
---
## Expected Results
After validation, you should confirm:
1. ✅ All core entities use `key` as primary identifier
2. ✅ All services look up series by `key`
3. ✅ All API endpoints use `key` for operations
4. ✅ All database queries use `key` for lookups
5. ✅ Frontend uses `key` for selection and API calls
6. ✅ WebSocket events include `key` in payload
7. ✅ All tests pass
8. ✅ Documentation clearly explains the convention
9. ✅ Deprecation warnings exist for legacy patterns
---
## Sign-off
Once validation is complete, update this section:
- [x] Phase 1: Core Entities - Validated by: **AI Agent** Date: **28 Nov 2025**
- [x] Phase 2: Services - Validated by: **AI Agent** Date: **28 Nov 2025**
- [ ] Phase 3: API - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 4: Frontend - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 5: Database - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 6: WebSocket - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 7: Tests - Validated by: **\_\_\_** Date: **\_\_\_**
**Final Approval:** \***\*\*\*\*\***\_\_\_\***\*\*\*\*\*** Date: **\*\***\_**\*\***

337
docs/infrastructure.md Normal file
View File

@ -0,0 +1,337 @@
# Aniworld Web Application Infrastructure
```bash
conda activate AniWorld
```
## Project Structure
```
src/
├── core/ # Core application logic
│ ├── SeriesApp.py # Main application class
│ ├── SerieScanner.py # Directory scanner
│ ├── entities/ # Domain entities (series.py, SerieList.py)
│ ├── interfaces/ # Abstract interfaces (providers.py, callbacks.py)
│ ├── providers/ # Content providers (aniworld, streaming)
│ └── exceptions/ # Custom exceptions
├── server/ # FastAPI web application
│ ├── fastapi_app.py # Main FastAPI application
│ ├── controllers/ # Route controllers (health, page, error)
│ ├── api/ # API routes (auth, config, anime, download, websocket)
│ ├── models/ # Pydantic models
│ ├── services/ # Business logic services
│ ├── database/ # SQLAlchemy ORM layer
│ ├── utils/ # Utilities (dependencies, templates, security)
│ └── web/ # Frontend (templates, static assets)
├── cli/ # CLI application
data/ # Config, database, queue state
logs/ # Application logs
tests/ # Test suites
```
## Technology Stack
| Layer | Technology |
| --------- | ---------------------------------------------- |
| Backend | FastAPI, Uvicorn, SQLAlchemy, SQLite, Pydantic |
| Frontend | HTML5, CSS3, Vanilla JS, Bootstrap 5, HTMX |
| Security | JWT (python-jose), bcrypt (passlib) |
| Real-time | Native WebSocket |
## Series Identifier Convention
Throughout the codebase, three identifiers are used for anime series:
| Identifier | Type | Purpose | Example |
| ---------- | --------------- | ----------------------------------------------------------- | -------------------------- |
| `key` | Unique, Indexed | **PRIMARY** - All lookups, API operations, WebSocket events | `"attack-on-titan"` |
| `folder` | String | Display/filesystem metadata only (never for lookups) | `"Attack on Titan (2013)"` |
| `id` | Primary Key | Internal database key for relationships | `1`, `42` |
### Key Format Requirements
- **Lowercase only**: No uppercase letters allowed
- **URL-safe**: Only alphanumeric characters and hyphens
- **Hyphen-separated**: Words separated by single hyphens
- **No leading/trailing hyphens**: Must start and end with alphanumeric
- **No consecutive hyphens**: `attack--titan` is invalid
**Valid examples**: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`, `"re-zero"`
**Invalid examples**: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
### Migration Notes
- **Backward Compatibility**: API endpoints accepting `anime_id` will check `key` first, then fall back to `folder` lookup
- **Deprecation**: Folder-based lookups are deprecated and will be removed in a future version
- **New Code**: Always use `key` for identification; `folder` is metadata only
## API Endpoints
### Authentication (`/api/auth`)
- `POST /login` - Master password authentication (returns JWT)
- `POST /logout` - Invalidate session
- `GET /status` - Check authentication status
### Configuration (`/api/config`)
- `GET /` - Get configuration
- `PUT /` - Update configuration
- `POST /validate` - Validate without applying
- `GET /backups` - List backups
- `POST /backups/{name}/restore` - Restore backup
### Anime (`/api/anime`)
- `GET /` - List anime with missing episodes (returns `key` as identifier)
- `GET /{anime_id}` - Get anime details (accepts `key` or `folder` for backward compatibility)
- `POST /search` - Search for anime (returns `key` as identifier)
- `POST /add` - Add new series (extracts `key` from link URL)
- `POST /rescan` - Trigger library rescan
**Response Models:**
- `AnimeSummary`: `key` (primary identifier), `name`, `site`, `folder` (metadata), `missing_episodes`, `link`
- `AnimeDetail`: `key` (primary identifier), `title`, `folder` (metadata), `episodes`, `description`
### Download Queue (`/api/queue`)
- `GET /status` - Queue status and statistics
- `POST /add` - Add episodes to queue
- `DELETE /{item_id}` - Remove item
- `POST /start` | `/stop` | `/pause` | `/resume` - Queue control
- `POST /retry` - Retry failed downloads
- `DELETE /completed` - Clear completed items
**Request Models:**
- `DownloadRequest`: `serie_id` (key, primary identifier), `serie_folder` (filesystem path), `serie_name` (display), `episodes`, `priority`
**Response Models:**
- `DownloadItem`: `id`, `serie_id` (key), `serie_folder` (metadata), `serie_name`, `episode`, `status`, `progress`
- `QueueStatus`: `is_running`, `is_paused`, `active_downloads`, `pending_queue`, `completed_downloads`, `failed_downloads`
### WebSocket (`/ws/connect`)
Real-time updates for downloads, scans, and queue operations.
**Rooms**: `downloads`, `download_progress`, `scan_progress`
**Message Types**: `download_progress`, `download_complete`, `download_failed`, `queue_status`, `scan_progress`, `scan_complete`, `scan_failed`
**Series Identifier in Messages:**
All series-related WebSocket events include `key` as the primary identifier in their data payload:
```json
{
"type": "download_progress",
"timestamp": "2025-10-17T10:30:00.000Z",
"data": {
"download_id": "abc123",
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"percent": 45.2,
"speed_mbps": 2.5,
"eta_seconds": 180
}
}
```
## Database Models
| Model | Purpose |
| ----------------- | ---------------------------------------- |
| AnimeSeries | Series metadata (key, name, folder, etc) |
| Episode | Episodes linked to series |
| DownloadQueueItem | Queue items with status and progress |
| UserSession | JWT sessions with expiry |
**Mixins**: `TimestampMixin` (created_at, updated_at), `SoftDeleteMixin`
### AnimeSeries Identifier Fields
| Field | Type | Purpose |
| -------- | --------------- | ------------------------------------------------- |
| `id` | Primary Key | Internal database key for relationships |
| `key` | Unique, Indexed | **PRIMARY IDENTIFIER** for all lookups |
| `folder` | String | Filesystem metadata only (not for identification) |
**Database Service Methods:**
- `AnimeSeriesService.get_by_key(key)` - **Primary lookup method**
- `AnimeSeriesService.get_by_id(id)` - Internal lookup by database ID
- No `get_by_folder()` method exists - folder is never used for lookups
## Core Services
### SeriesApp (`src/core/SeriesApp.py`)
Main engine for anime series management with async support, progress callbacks, and cancellation.
### Callback System (`src/core/interfaces/callbacks.py`)
- `ProgressCallback`, `ErrorCallback`, `CompletionCallback`
- Context classes include `key` + optional `folder` fields
- Thread-safe `CallbackManager` for multiple callback registration
### Services (`src/server/services/`)
| Service | Purpose |
| ---------------- | ----------------------------------------- |
| AnimeService | Series management, scans (uses SeriesApp) |
| DownloadService | Queue management, download execution |
| ScanService | Library scan operations with callbacks |
| ProgressService | Centralized progress tracking + WebSocket |
| WebSocketService | Real-time connection management |
| AuthService | JWT authentication, rate limiting |
| ConfigService | Configuration persistence with backups |
## Validation Utilities (`src/server/utils/validators.py`)
Provides data validation functions for ensuring data integrity across the application.
### Series Key Validation
- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only)
- Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`
- Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation
- Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format
- Set `allow_folder=False` to require strict key format
### Other Validators
| Function | Purpose |
| --------------------------- | ------------------------------------------ |
| `validate_series_name` | Series display name validation |
| `validate_episode_range` | Episode range validation (1-1000) |
| `validate_download_quality` | Quality setting (360p-1080p, best, worst) |
| `validate_language` | Language codes (ger-sub, ger-dub, etc.) |
| `validate_anime_url` | Aniworld.to/s.to URL validation |
| `validate_backup_name` | Backup filename validation |
| `validate_config_data` | Configuration data structure validation |
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
## Template Helpers (`src/server/utils/template_helpers.py`)
Provides utilities for template rendering and series data preparation.
### Core Functions
| Function | Purpose |
| -------------------------- | --------------------------------- |
| `get_base_context` | Base context for all templates |
| `render_template` | Render template with context |
| `validate_template_exists` | Check if template file exists |
| `list_available_templates` | List all available template files |
### Series Context Helpers
All series helpers use `key` as the primary identifier:
| Function | Purpose |
| ----------------------------------- | ---------------------------------------------- |
| `prepare_series_context` | Prepare series data for templates (uses `key`) |
| `get_series_by_key` | Find series by `key` (not `folder`) |
| `filter_series_by_missing_episodes` | Filter series with missing episodes |
**Example Usage:**
```python
from src.server.utils.template_helpers import prepare_series_context
series_data = [
{"key": "attack-on-titan", "name": "Attack on Titan", "folder": "Attack on Titan (2013)"},
{"key": "one-piece", "name": "One Piece", "folder": "One Piece (1999)"}
]
prepared = prepare_series_context(series_data, sort_by="name")
# Returns sorted list using 'key' as identifier
```
## Frontend
### Static Files
- CSS: `styles.css` (Fluent UI design), `ux_features.css` (accessibility)
- JS: `app.js`, `queue.js`, `websocket_client.js`, accessibility modules
### WebSocket Client
Native WebSocket wrapper with Socket.IO-compatible API:
```javascript
const socket = io();
socket.join("download_progress");
socket.on("download_progress", (data) => {
/* ... */
});
```
### Authentication
JWT tokens stored in localStorage, included as `Authorization: Bearer <token>`.
## Testing
```bash
# All tests
conda run -n AniWorld python -m pytest tests/ -v
# Unit tests only
conda run -n AniWorld python -m pytest tests/unit/ -v
# API tests
conda run -n AniWorld python -m pytest tests/api/ -v
```
## Production Notes
### Current (Single-Process)
- SQLite with WAL mode
- In-memory WebSocket connections
- File-based config and queue persistence
### Multi-Process Deployment
- Switch to PostgreSQL/MySQL
- Move WebSocket registry to Redis
- Use distributed locking for queue operations
- Consider Redis for session/cache storage
## Code Examples
### API Usage with Key Identifier
```python
# Fetching anime list - response includes 'key' as identifier
response = requests.get("/api/anime", headers={"Authorization": f"Bearer {token}"})
anime_list = response.json()
# Each item has: key="attack-on-titan", folder="Attack on Titan (2013)", ...
# Fetching specific anime by key (preferred)
response = requests.get("/api/anime/attack-on-titan", headers={"Authorization": f"Bearer {token}"})
# Adding to download queue using key
download_request = {
"serie_id": "attack-on-titan", # Use key, not folder
"serie_folder": "Attack on Titan (2013)", # Metadata for filesystem
"serie_name": "Attack on Titan",
"episodes": ["S01E01", "S01E02"],
"priority": 1
}
response = requests.post("/api/queue/add", json=download_request, headers=headers)
```
### WebSocket Event Handling
```javascript
// WebSocket events always include 'key' as identifier
socket.on("download_progress", (data) => {
const key = data.key; // Primary identifier: "attack-on-titan"
const folder = data.folder; // Metadata: "Attack on Titan (2013)"
updateProgressBar(key, data.percent);
});
```

View File

@ -1,155 +0,0 @@
# Logging Configuration
This document describes the logging setup for the Aniworld FastAPI application.
## Overview
The application uses Python's built-in `logging` module with both console and file output. All logs are written to:
- **Console**: Colored output for development
- **Log File**: `logs/fastapi_app.log` with detailed timestamps
## Log Levels
By default, the application logs at `INFO` level. You can change this by setting the `LOG_LEVEL` environment variable:
```bash
export LOG_LEVEL=DEBUG # More verbose
export LOG_LEVEL=INFO # Default
export LOG_LEVEL=WARNING # Less verbose
export LOG_LEVEL=ERROR # Errors only
```
Or in your `.env` file:
```
LOG_LEVEL=INFO
```
## Running the Server
### Option 1: Using the run_server.py script (Recommended)
```bash
conda run -n AniWorld python run_server.py
```
This script uses the custom uvicorn logging configuration that ensures proper console and file logging.
### Option 2: Using the shell script
```bash
./start_server.sh
```
### Option 3: Using uvicorn directly
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
**Note**: When using `conda run`, console output may not be visible in real-time. The logs will still be written to the file.
## Log File Location
All logs are written to: `logs/fastapi_app.log`
To view logs in real-time:
```bash
tail -f logs/fastapi_app.log
```
## Log Format
### Console Output
```
INFO: Starting FastAPI application...
INFO: Server running on http://127.0.0.1:8000
```
### File Output
```
2025-10-25 17:31:19 - aniworld - INFO - Starting FastAPI application...
2025-10-25 17:31:19 - aniworld - INFO - Server running on http://127.0.0.1:8000
```
## What Gets Logged
The application logs:
- **Startup/Shutdown**: Application lifecycle events
- **Configuration**: Loaded settings and configuration
- **HTTP Requests**: Via uvicorn.access logger
- **Errors**: Exception tracebacks with full context
- **WebSocket Events**: Connection/disconnection events
- **Download Progress**: Progress updates for anime downloads
- **File Operations**: File creation, deletion, scanning
## Logger Names
Different parts of the application use different logger names:
- `aniworld`: Main application logger
- `uvicorn.error`: Uvicorn server errors
- `uvicorn.access`: HTTP request logs
- `src.core.SeriesApp`: Core anime logic
- `src.core.SerieScanner`: File scanning operations
- `src.server.*`: Web API endpoints and services
## Programmatic Usage
To use logging in your code:
```python
from src.infrastructure.logging import get_logger
logger = get_logger(__name__)
logger.info("This is an info message")
logger.warning("This is a warning")
logger.error("This is an error", exc_info=True) # Includes traceback
```
## Log Rotation
Log files can grow large over time. Consider implementing log rotation:
```bash
# Archive old logs
mkdir -p logs/archived
mv logs/fastapi_app.log logs/archived/fastapi_app_$(date +%Y%m%d_%H%M%S).log
```
Or use Python's `RotatingFileHandler` (can be added to `src/infrastructure/logging/logger.py`).
## Troubleshooting
### No console output when using `conda run`
This is a known limitation of `conda run`. The logs are still being written to the file. To see console output:
1. Use the log file: `tail -f logs/fastapi_app.log`
2. Or run without conda: `python run_server.py` (after activating environment with `conda activate AniWorld`)
### Log file not created
- Check that the `logs/` directory exists (it's created automatically)
- Verify write permissions on the `logs/` directory
- Check the `LOG_LEVEL` environment variable
### Too much logging
Set a higher log level:
```bash
export LOG_LEVEL=WARNING
```
### Missing logs
- Check that you're using the logger, not `print()`
- Verify the log level is appropriate for your messages
- Ensure the logger is properly configured (should happen automatically on startup)

View File

@ -1,450 +0,0 @@
# Progress Service Architecture
## Overview
The ProgressService serves as the **single source of truth** for all real-time progress tracking in the Aniworld application. This architecture follows a clean, decoupled design where progress updates flow through a well-defined pipeline.
## Architecture Diagram
```
┌─────────────┐
│ SeriesApp │ ← Core download/scan logic
└──────┬──────┘
│ Events (download_status, scan_status)
┌─────────────────┐
│ AnimeService │ ← Subscribes to SeriesApp events
└────────┬────────┘
│ Forwards events
┌──────────────────┐
│ ProgressService │ ← Single source of truth for progress
└────────┬─────────┘
│ Emits events to subscribers
┌──────────────────┐
│ WebSocketService │ ← Subscribes to progress events
└──────────────────┘
Connected clients receive real-time updates
```
## Components
### 1. SeriesApp (Core Layer)
**Location**: `src/core/SeriesApp.py`
**Responsibilities**:
- Execute actual downloads and scans
- Fire events with detailed progress information
- Manage download state and error handling
**Events**:
- `download_status`: Fired during downloads
- `started`: Download begins
- `progress`: Progress updates (percent, speed, ETA)
- `completed`: Download finished successfully
- `failed`: Download encountered an error
- `scan_status`: Fired during library scans
- `started`: Scan begins
- `progress`: Scan progress updates
- `completed`: Scan finished
- `failed`: Scan encountered an error
- `cancelled`: Scan was cancelled
### 2. AnimeService (Service Layer)
**Location**: `src/server/services/anime_service.py`
**Responsibilities**:
- Subscribe to SeriesApp events
- Translate SeriesApp events into ProgressService updates
- Provide async interface for web layer
**Event Handlers**:
```python
def _on_download_status(self, args):
"""Translates download events to progress service."""
if args.status == "started":
await progress_service.start_progress(...)
elif args.status == "progress":
await progress_service.update_progress(...)
elif args.status == "completed":
await progress_service.complete_progress(...)
elif args.status == "failed":
await progress_service.fail_progress(...)
def _on_scan_status(self, args):
"""Translates scan events to progress service."""
# Similar pattern as download_status
```
### 3. ProgressService (Service Layer)
**Location**: `src/server/services/progress_service.py`
**Responsibilities**:
- Central progress tracking for all operations
- Maintain active and historical progress records
- Calculate percentages and rates
- Emit events to subscribers (event-based architecture)
**Progress Types**:
- `DOWNLOAD`: Individual episode downloads
- `SCAN`: Library scans for missing episodes
- `QUEUE`: Download queue operations
- `SYSTEM`: System-level operations
- `ERROR`: Error notifications
**Event System**:
```python
# Subscribe to progress events
def subscribe(event_name: str, handler: Callable[[ProgressEvent], None])
def unsubscribe(event_name: str, handler: Callable[[ProgressEvent], None])
# Internal event emission
async def _emit_event(event: ProgressEvent)
```
**Key Methods**:
```python
async def start_progress(progress_id, progress_type, title, ...):
"""Start tracking a new operation."""
async def update_progress(progress_id, current, total, message, ...):
"""Update progress for an ongoing operation."""
async def complete_progress(progress_id, message, ...):
"""Mark operation as completed."""
async def fail_progress(progress_id, error_message, ...):
"""Mark operation as failed."""
```
### 4. DownloadService (Service Layer)
**Location**: `src/server/services/download_service.py`
**Responsibilities**:
- Manage download queue (FIFO processing)
- Track queue state (pending, active, completed, failed)
- Persist queue to disk
- Use ProgressService for queue-related updates
**Progress Integration**:
```python
# Queue operations notify via ProgressService
await progress_service.update_progress(
progress_id="download_queue",
message="Added 3 items to queue",
metadata={
"action": "items_added",
"queue_status": {...}
},
force_broadcast=True,
)
```
**Note**: DownloadService does NOT directly broadcast. Individual download progress flows through:
`SeriesApp → AnimeService → ProgressService → WebSocket`
### 5. WebSocketService (Service Layer)
**Location**: `src/server/services/websocket_service.py`
**Responsibilities**:
- Manage WebSocket connections
- Support room-based messaging
- Broadcast progress updates to clients
- Handle connection lifecycle
**Integration**:
WebSocketService subscribes to ProgressService events:
```python
async def lifespan(app: FastAPI):
# Get services
progress_service = get_progress_service()
ws_service = get_websocket_service()
# Define event handler
async def progress_event_handler(event) -> None:
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await ws_service.manager.broadcast_to_room(message, event.room)
# Subscribe to progress events
progress_service.subscribe("progress_updated", progress_event_handler)
```
## Data Flow Examples
### Example 1: Episode Download
1. **User triggers download** via API endpoint
2. **DownloadService** queues the download
3. **DownloadService** starts processing → calls `anime_service.download()`
4. **AnimeService** calls `series_app.download()`
5. **SeriesApp** fires `download_status` events:
- `started` → AnimeService → ProgressService → WebSocket → Client
- `progress` (multiple) → AnimeService → ProgressService → WebSocket → Client
- `completed` → AnimeService → ProgressService → WebSocket → Client
### Example 2: Library Scan
1. **User triggers scan** via API endpoint
2. **AnimeService** calls `series_app.rescan()`
3. **SeriesApp** fires `scan_status` events:
- `started` → AnimeService → ProgressService → WebSocket → Client
- `progress` (multiple) → AnimeService → ProgressService → WebSocket → Client
- `completed` → AnimeService → ProgressService → WebSocket → Client
### Example 3: Queue Management
1. **User adds items to queue** via API endpoint
2. **DownloadService** adds items to internal queue
3. **DownloadService** notifies via ProgressService:
```python
await progress_service.update_progress(
progress_id="download_queue",
message="Added 5 items to queue",
metadata={"queue_status": {...}},
force_broadcast=True,
)
```
4. **ProgressService** → WebSocket → Client receives queue update
## Benefits of This Architecture
### 1. **Single Source of Truth**
- All progress tracking goes through ProgressService
- Consistent progress reporting across the application
- Easy to monitor and debug
### 2. **Decoupling**
- Core logic (SeriesApp) doesn't know about web layer
- Services can be tested independently
- Easy to add new progress consumers (e.g., CLI, GUI)
### 3. **Type Safety**
- Strongly typed progress updates
- Enum-based progress types and statuses
- Clear data contracts
### 4. **Flexibility**
- Multiple subscribers can listen to progress events
- Room-based WebSocket messaging
- Metadata support for custom data
- Multiple concurrent progress operations
### 5. **Maintainability**
- Clear separation of concerns
- Single place to modify progress logic
- Easy to extend with new progress types or subscribers
### 6. **Scalability**
- Event-based architecture supports multiple consumers
- Isolated error handling per subscriber
- No single point of failure
## Progress IDs
Progress operations are identified by unique IDs:
- **Downloads**: `download_{serie_folder}_{season}_{episode}`
- **Scans**: `library_scan`
- **Queue**: `download_queue`
## WebSocket Messages
Clients receive progress updates in this format:
```json
{
"type": "download_progress",
"data": {
"id": "download_naruto_1_1",
"type": "download",
"status": "in_progress",
"title": "Downloading Naruto",
"message": "S01E01",
"percent": 45.5,
"current": 45,
"total": 100,
"metadata": {},
"started_at": "2025-11-07T10:00:00Z",
"updated_at": "2025-11-07T10:05:00Z"
}
}
```
## Configuration
### Startup (fastapi_app.py)
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize services
progress_service = get_progress_service()
ws_service = get_websocket_service()
# Define event handler
async def progress_event_handler(event) -> None:
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await ws_service.manager.broadcast_to_room(message, event.room)
# Subscribe to progress events
progress_service.subscribe("progress_updated", progress_event_handler)
```
### Service Initialization
```python
# AnimeService automatically subscribes to SeriesApp events
anime_service = AnimeService(series_app)
# DownloadService uses ProgressService for queue updates
download_service = DownloadService(anime_service)
```
## Migration Notes
### What Changed
**Before (Callback-based)**:
- ProgressService had a single `set_broadcast_callback()` method
- Only one consumer could receive updates
- Direct coupling between ProgressService and WebSocketService
**After (Event-based)**:
- ProgressService uses `subscribe()` and `unsubscribe()` methods
- Multiple consumers can subscribe to progress events
- Loose coupling - ProgressService doesn't know about subscribers
- Clean event flow: SeriesApp → AnimeService → ProgressService → Subscribers
### Removed
1. **ProgressService**:
- `set_broadcast_callback()` method
- `_broadcast_callback` attribute
- `_broadcast()` method
### Added
1. **ProgressService**:
- `ProgressEvent` dataclass to encapsulate event data
- `subscribe()` method for event subscription
- `unsubscribe()` method to remove handlers
- `_emit_event()` method for broadcasting to all subscribers
- `_event_handlers` dictionary to track subscribers
2. **fastapi_app.py**:
- Event handler function `progress_event_handler`
- Uses `subscribe()` instead of `set_broadcast_callback()`
### Benefits of Event-Based Design
1. **Multiple Subscribers**: Can now have multiple services listening to progress
```python
# WebSocket for real-time updates
progress_service.subscribe("progress_updated", websocket_handler)
# Metrics for analytics
progress_service.subscribe("progress_updated", metrics_handler)
# Logging for debugging
progress_service.subscribe("progress_updated", logging_handler)
```
2. **Isolated Error Handling**: If one subscriber fails, others continue working
3. **Dynamic Subscription**: Handlers can subscribe/unsubscribe at runtime
4. **Extensibility**: Easy to add new features without modifying ProgressService
## Testing
### Unit Tests
- Test each service independently
- Mock ProgressService for services that use it
- Verify event handler logic
### Integration Tests
- Test full flow: SeriesApp → AnimeService → ProgressService → WebSocket
- Verify progress updates reach clients
- Test error handling
### Example Test
```python
async def test_download_progress_flow():
# Setup
progress_service = ProgressService()
events_received = []
async def mock_event_handler(event):
events_received.append(event)
progress_service.subscribe("progress_updated", mock_event_handler)
# Execute
await progress_service.start_progress(
progress_id="test_download",
progress_type=ProgressType.DOWNLOAD,
title="Test"
)
# Verify
assert len(events_received) == 1
assert events_received[0].event_type == "download_progress"
assert events_received[0].progress.id == "test_download"
```
## Future Enhancements
1. **Progress Persistence**: Save progress to database for recovery
2. **Progress History**: Keep detailed history for analytics
3. **Rate Limiting**: Throttle progress updates to prevent spam
4. **Progress Aggregation**: Combine multiple progress operations
5. **Custom Rooms**: Allow clients to subscribe to specific progress types
## Related Documentation
- [WebSocket API](./websocket_api.md)
- [Download Service](./download_service.md)
- [Error Handling](./error_handling_validation.md)
- [API Implementation](./api_implementation_summary.md)

View File

@ -1,628 +0,0 @@
# Aniworld User Guide
Complete user guide for the Aniworld Download Manager web application.
## Table of Contents
1. [Getting Started](#getting-started)
2. [Installation](#installation)
3. [Initial Setup](#initial-setup)
4. [User Interface](#user-interface)
5. [Configuration](#configuration)
6. [Managing Anime](#managing-anime)
7. [Download Queue](#download-queue)
8. [Troubleshooting](#troubleshooting)
9. [Keyboard Shortcuts](#keyboard-shortcuts)
10. [FAQ](#faq)
## Getting Started
Aniworld is a modern web application for managing and downloading anime series. It provides:
- **Web-based Interface**: Access via any modern web browser
- **Real-time Updates**: Live download progress tracking
- **Queue Management**: Organize and prioritize downloads
- **Configuration Management**: Easy setup and configuration
- **Backup & Restore**: Automatic configuration backups
### System Requirements
- **OS**: Windows, macOS, or Linux
- **Browser**: Chrome, Firefox, Safari, or Edge (modern versions)
- **Internet**: Required for downloading anime
- **Storage**: Sufficient space for anime files (adjustable)
- **RAM**: Minimum 2GB recommended
## Installation
### Prerequisites
- Python 3.10 or higher
- Poetry (Python package manager)
- Git (for cloning the repository)
### Step-by-Step Installation
#### 1. Clone the Repository
```bash
git clone https://github.com/your-repo/aniworld.git
cd aniworld
```
#### 2. Create Python Environment
```bash
# Using conda (recommended)
conda create -n AniWorld python=3.10
conda activate AniWorld
# Or using venv
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
#### 3. Install Dependencies
```bash
# Using pip
pip install -r requirements.txt
# Or using poetry
poetry install
```
#### 4. Start the Application
```bash
# Using conda
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
# Or directly
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
```
#### 5. Access the Application
Open your browser and navigate to:
```
http://localhost:8000
```
## Initial Setup
### Setting Master Password
On first launch, you'll be prompted to set a master password:
1. **Navigate to Setup Page**: `http://localhost:8000/setup`
2. **Enter Password**: Choose a strong password (minimum 8 characters recommended)
3. **Confirm Password**: Re-enter the password for confirmation
4. **Save**: Click "Set Master Password"
The master password protects access to your anime library and download settings.
### Configuration
After setting the master password, configure the application:
1. **Login**: Use your master password to log in
2. **Go to Settings**: Click the settings icon in the navigation bar
3. **Configure Directories**:
- **Anime Directory**: Where anime series are stored
- **Download Directory**: Where downloads are saved
- **Cache Directory**: Temporary file storage (optional)
4. **Advanced Settings** (optional):
- **Session Timeout**: How long before auto-logout
- **Log Level**: Application logging detail level
- **Theme**: Light or dark mode preference
5. **Save**: Click "Save Configuration"
### Automatic Backups
The application automatically creates backups when you update configuration. You can:
- View all backups in Settings → Backups
- Manually create a backup anytime
- Restore previous configuration versions
- Delete old backups to save space
## User Interface
### Dashboard
The main dashboard shows:
- **Quick Stats**: Total anime, episodes, storage used
- **Recent Activity**: Latest downloads and actions
- **Quick Actions**: Add anime, manage queue, view settings
### Navigation
**Top Navigation Bar**:
- **Logo**: Return to dashboard
- **Anime**: Browse and manage anime library
- **Downloads**: View download queue and history
- **Settings**: Configure application
- **Account**: User menu (logout, profile)
### Theme
**Dark Mode / Light Mode**:
- Toggle theme in Settings
- Theme preference is saved automatically
- Default theme can be set in configuration
## Managing Anime
### Browsing Anime Library
1. **Click "Anime"** in navigation
2. **View Anime List**: Shows all anime with missing episodes
3. **Filter**: Filter by series status or search by name
### Adding New Anime
1. **Click "Add Anime"** button
2. **Search**: Enter anime title or key
3. **Select**: Choose anime from search results
4. **Confirm**: Click "Add to Library"
### Viewing Anime Details
1. **Click Anime Title** in the list
2. **View Information**: Episodes, status, total count
3. **Add Episodes**: Select specific episodes to download
### Managing Episodes
**View Episodes**:
- All seasons and episodes for the series
- Downloaded status indicators
- File size information
**Download Episodes**:
1. Select episodes to download
2. Click "Add to Queue"
3. Choose priority (Low, Normal, High)
4. Confirm
**Delete Episodes**:
1. Select downloaded episodes
2. Click "Delete"
3. Choose whether to keep or remove files
4. Confirm
## Download Queue
### Queue Status
The queue page shows:
- **Queue Stats**: Total items, status breakdown
- **Current Download**: What's downloading now
- **Progress**: Download speed and time remaining
- **Queue List**: All pending downloads
### Queue Management
### Add Episodes to Queue
1. Go to "Anime" or "Downloads"
2. Select anime and episodes
3. Click "Add to Queue"
4. Set priority and confirm
### Manage Queue Items
**Pause/Resume**:
- Click pause icon to pause individual download
- Resume when ready
**Prioritize**:
1. Click item in queue
2. Select "Increase Priority" or "Decrease Priority"
3. Items with higher priority download first
**Remove**:
1. Select item
2. Click "Remove" button
3. Confirm deletion
### Control Queue Processing
**Start Queue**: Begin downloading queued items
- Click "Start" button
- Downloads begin in priority order
**Pause Queue**: Pause all downloads temporarily
- Click "Pause" button
- Current download pauses
- Click "Resume" to continue
**Stop Queue**: Stop all downloads
- Click "Stop" button
- Current download stops
- Queue items remain
**Clear Completed**: Remove completed items from queue
- Click "Clear Completed"
- Frees up queue space
### Monitor Progress
**Real-time Updates**:
- Download speed (MB/s)
- Progress percentage
- Time remaining
- Current file size
**Status Indicators**:
- 🔵 Pending: Waiting to download
- 🟡 Downloading: Currently downloading
- 🟢 Completed: Successfully downloaded
- 🔴 Failed: Download failed
### Retry Failed Downloads
1. Find failed item in queue
2. Click "Retry" button
3. Item moves back to pending
4. Download restarts when queue processes
## Configuration
### Basic Settings
**Anime Directory**:
- Path where anime series are stored
- Must be readable and writable
- Can contain nested folders
**Download Directory**:
- Where new downloads are saved
- Should have sufficient free space
- Temporary files stored during download
**Session Timeout**:
- Minutes before automatic logout
- Default: 1440 (24 hours)
- Minimum: 15 minutes
### Advanced Settings
**Log Level**:
- DEBUG: Verbose logging (development)
- INFO: Standard information
- WARNING: Warnings and errors
- ERROR: Only errors
**Update Frequency**:
- How often to check for new episodes
- Default: Daily
- Options: Hourly, Daily, Weekly, Manual
**Provider Settings**:
- Anime provider configuration
- Streaming server preferences
- Retry attempts and timeouts
### Storage Management
**View Storage Statistics**:
- Total anime library size
- Available disk space
- Downloaded vs. pending size
**Manage Storage**:
1. Go to Settings → Storage
2. View breakdown by series
3. Delete old anime to free space
### Backup Management
**Create Backup**:
1. Go to Settings → Backups
2. Click "Create Backup"
3. Backup created with timestamp
**View Backups**:
- List of all configuration backups
- Creation date and time
- Size of each backup
**Restore from Backup**:
1. Click backup name
2. Review changes
3. Click "Restore"
4. Application reloads with restored config
**Delete Backup**:
1. Select backup
2. Click "Delete"
3. Confirm deletion
## Troubleshooting
### Common Issues
#### Can't Access Application
**Problem**: Browser shows "Connection Refused"
**Solutions**:
- Verify application is running: Check terminal for startup messages
- Check port: Application uses port 8000 by default
- Try different port: Modify configuration if 8000 is in use
- Firewall: Check if firewall is blocking port 8000
#### Login Issues
**Problem**: Can't log in or session expires
**Solutions**:
- Clear browser cookies: Settings → Clear browsing data
- Try incognito mode: May help with cache issues
- Reset master password: Delete `data/config.json` and restart
- Check session timeout: Verify in settings
#### Download Failures
**Problem**: Downloads keep failing
**Solutions**:
- Check internet connection: Ensure stable connection
- Verify provider: Check if anime provider is accessible
- View error logs: Go to Settings → Logs for details
- Retry download: Use "Retry" button on failed items
- Contact provider: Provider might be down or blocking access
#### Slow Downloads
**Problem**: Downloads are very slow
**Solutions**:
- Check bandwidth: Other applications might be using internet
- Provider issue: Provider might be throttling
- Try different quality: Lower quality might download faster
- Queue priority: Reduce queue size for faster downloads
- Hardware: Ensure sufficient CPU and disk performance
#### Application Crashes
**Problem**: Application stops working
**Solutions**:
- Check logs: View logs in Settings → Logs
- Restart application: Stop and restart the process
- Clear cache: Delete temporary files in Settings
- Reinstall: As last resort, reinstall application
### Error Messages
#### "Authentication Failed"
- Incorrect master password
- Session expired (need to log in again)
- Browser cookies cleared
#### "Configuration Error"
- Invalid directory path
- Insufficient permissions
- Disk space issues
#### "Download Error: Provider Error"
- Anime provider is down
- Content no longer available
- Streaming server error
#### "Database Error"
- Database file corrupted
- Disk write permission denied
- Low disk space
### Getting Help
**Check Application Logs**:
1. Go to Settings → Logs
2. Search for error messages
3. Check timestamp and context
**Review Documentation**:
- Check [API Reference](./api_reference.md)
- Review [Deployment Guide](./deployment.md)
- Consult inline code comments
**Community Support**:
- Check GitHub issues
- Ask on forums or Discord
- File bug report with logs
## Keyboard Shortcuts
### General
| Shortcut | Action |
| ------------------ | ------------------- |
| `Ctrl+S` / `Cmd+S` | Save settings |
| `Ctrl+L` / `Cmd+L` | Focus search |
| `Escape` | Close dialogs |
| `?` | Show shortcuts help |
### Anime Management
| Shortcut | Action |
| -------- | ------------- |
| `Ctrl+A` | Add new anime |
| `Ctrl+F` | Search anime |
| `Delete` | Remove anime |
| `Enter` | View details |
### Download Queue
| Shortcut | Action |
| -------------- | ------------------- |
| `Ctrl+D` | Add to queue |
| `Space` | Play/Pause queue |
| `Ctrl+Shift+P` | Pause all downloads |
| `Ctrl+Shift+S` | Stop all downloads |
### Navigation
| Shortcut | Action |
| -------- | --------------- |
| `Ctrl+1` | Go to Dashboard |
| `Ctrl+2` | Go to Anime |
| `Ctrl+3` | Go to Downloads |
| `Ctrl+4` | Go to Settings |
### Accessibility
| Shortcut | Action |
| ----------- | ------------------------- |
| `Tab` | Navigate between elements |
| `Shift+Tab` | Navigate backwards |
| `Alt+M` | Skip to main content |
| `Alt+H` | Show help |
## FAQ
### General Questions
**Q: Is Aniworld free?**
A: Yes, Aniworld is open-source and completely free to use.
**Q: Do I need internet connection?**
A: Yes, to download anime. Once downloaded, you can watch offline.
**Q: What formats are supported?**
A: Supports most video formats (MP4, MKV, AVI, etc.) depending on provider.
**Q: Can I use it on mobile?**
A: The web interface works on mobile browsers, but is optimized for desktop.
### Installation & Setup
**Q: Can I run multiple instances?**
A: Not recommended. Use single instance with same database.
**Q: Can I change installation directory?**
A: Yes, reconfigure paths in Settings → Directories.
**Q: What if I forget my master password?**
A: Delete `data/config.json` and restart (loses all settings).
### Downloads
**Q: How long do downloads take?**
A: Depends on file size and internet speed. Typically 5-30 minutes per episode.
**Q: Can I pause/resume downloads?**
A: Yes, pause individual items or entire queue.
**Q: What happens if download fails?**
A: Item remains in queue. Use "Retry" to attempt again.
**Q: Can I download multiple episodes simultaneously?**
A: Yes, configure concurrent downloads in settings.
### Storage
**Q: How much space do I need?**
A: Depends on anime count. Plan for 500MB-2GB per episode.
**Q: Where are files stored?**
A: In the configured "Anime Directory" in settings.
**Q: Can I move downloaded files?**
A: Yes, but update the path in configuration afterwards.
### Performance
**Q: Application is slow, what can I do?**
A: Reduce queue size, check disk space, restart application.
**Q: How do I free up storage?**
A: Go to Settings → Storage and delete anime you no longer need.
**Q: Is there a way to optimize database?**
A: Go to Settings → Maintenance and run database optimization.
### Support
**Q: Where can I report bugs?**
A: File issues on GitHub repository.
**Q: How do I contribute?**
A: See CONTRIBUTING.md for guidelines.
**Q: Where's the source code?**
A: Available on GitHub (link in application footer).
---
## Additional Resources
- [API Reference](./api_reference.md) - For developers
- [Deployment Guide](./deployment.md) - For system administrators
- [GitHub Repository](https://github.com/your-repo/aniworld)
- [Interactive API Documentation](http://localhost:8000/api/docs)
---
## Support
For additional help:
1. Check this user guide first
2. Review [Troubleshooting](#troubleshooting) section
3. Check application logs in Settings
4. File issue on GitHub
5. Contact community forums
---
**Last Updated**: October 22, 2025
**Version**: 1.0.0

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,7 @@ class SerieScanner:
raise ValueError(f"Base path is not a directory: {abs_path}") raise ValueError(f"Base path is not a directory: {abs_path}")
self.directory: str = abs_path self.directory: str = abs_path
self.folderDict: dict[str, Serie] = {} self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader self.loader: Loader = loader
self._callback_manager: CallbackManager = ( self._callback_manager: CallbackManager = (
callback_manager or CallbackManager() callback_manager or CallbackManager()
@ -80,8 +80,8 @@ class SerieScanner:
return self._callback_manager return self._callback_manager
def reinit(self) -> None: def reinit(self) -> None:
"""Reinitialize the folder dictionary.""" """Reinitialize the series dictionary (keyed by serie.key)."""
self.folderDict: dict[str, Serie] = {} self.keyDict: dict[str, Serie] = {}
def get_total_to_scan(self) -> int: def get_total_to_scan(self) -> int:
"""Get the total number of folders to scan. """Get the total number of folders to scan.
@ -187,12 +187,21 @@ class SerieScanner:
) )
serie.save_to_file(data_path) serie.save_to_file(data_path)
if serie.key in self.folderDict: # Store by key (primary identifier), not folder
if serie.key in self.keyDict:
logger.error( logger.error(
"Duplication found: %s", serie.key "Duplicate series found with key '%s' "
"(folder: '%s')",
serie.key,
folder
) )
else: else:
self.folderDict[serie.key] = serie self.keyDict[serie.key] = serie
logger.debug(
"Stored series with key '%s' (folder: '%s')",
serie.key,
folder
)
no_key_found_logger.info( no_key_found_logger.info(
"Saved Serie: '%s'", str(serie) "Saved Serie: '%s'", str(serie)
) )
@ -209,7 +218,7 @@ class SerieScanner:
error=nkfe, error=nkfe,
message=error_msg, message=error_msg,
recoverable=True, recoverable=True,
metadata={"folder": folder} metadata={"folder": folder, "key": None}
) )
) )
except Exception as e: except Exception as e:
@ -231,7 +240,7 @@ class SerieScanner:
error=e, error=e,
message=error_msg, message=error_msg,
recoverable=True, recoverable=True,
metadata={"folder": folder} metadata={"folder": folder, "key": None}
) )
) )
continue continue
@ -245,7 +254,7 @@ class SerieScanner:
message=f"Scan completed. Processed {counter} folders.", message=f"Scan completed. Processed {counter} folders.",
statistics={ statistics={
"total_folders": counter, "total_folders": counter,
"series_found": len(self.folderDict) "series_found": len(self.keyDict)
} }
) )
) )
@ -253,7 +262,7 @@ class SerieScanner:
logger.info( logger.info(
"Scan completed. Processed %d folders, found %d series", "Scan completed. Processed %d folders, found %d series",
counter, counter,
len(self.folderDict) len(self.keyDict)
) )
except Exception as e: except Exception as e:
@ -311,10 +320,15 @@ class SerieScanner:
"""Read serie data from file or key file. """Read serie data from file or key file.
Args: Args:
folder_name: Name of the folder containing serie data folder_name: Filesystem folder name
(used only to locate data files)
Returns: Returns:
Serie object if found, None otherwise Serie object with valid key if found, None otherwise
Note:
The returned Serie will have its 'key' as the primary identifier.
The 'folder' field is metadata only.
""" """
folder_path = os.path.join(self.directory, folder_name) folder_path = os.path.join(self.directory, folder_name)
key = None key = None

View File

@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
from events import Events from events import Events
from src.core.entities.SerieList import SerieList from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
from src.core.providers.provider_factory import Loaders from src.core.providers.provider_factory import Loaders
from src.core.SerieScanner import SerieScanner from src.core.SerieScanner import SerieScanner
@ -28,6 +29,7 @@ class DownloadStatusEventArgs:
season: int, season: int,
episode: int, episode: int,
status: str, status: str,
key: Optional[str] = None,
progress: float = 0.0, progress: float = 0.0,
message: Optional[str] = None, message: Optional[str] = None,
error: Optional[Exception] = None, error: Optional[Exception] = None,
@ -39,10 +41,14 @@ class DownloadStatusEventArgs:
Initialize download status event arguments. Initialize download status event arguments.
Args: Args:
serie_folder: Serie folder name serie_folder: Serie folder name (metadata only, used for
file paths)
season: Season number season: Season number
episode: Episode number episode: Episode number
status: Status message (e.g., "started", "progress", "completed", "failed") status: Status message (e.g., "started", "progress",
"completed", "failed")
key: Serie unique identifier (provider key, primary
identifier)
progress: Download progress (0.0 to 1.0) progress: Download progress (0.0 to 1.0)
message: Optional status message message: Optional status message
error: Optional error if status is "failed" error: Optional error if status is "failed"
@ -51,6 +57,7 @@ class DownloadStatusEventArgs:
item_id: Optional download queue item ID for tracking item_id: Optional download queue item ID for tracking
""" """
self.serie_folder = serie_folder self.serie_folder = serie_folder
self.key = key
self.season = season self.season = season
self.episode = episode self.episode = episode
self.status = status self.status = status
@ -61,6 +68,7 @@ class DownloadStatusEventArgs:
self.mbper_sec = mbper_sec self.mbper_sec = mbper_sec
self.item_id = item_id self.item_id = item_id
class ScanStatusEventArgs: class ScanStatusEventArgs:
"""Event arguments for scan status events.""" """Event arguments for scan status events."""
@ -70,6 +78,7 @@ class ScanStatusEventArgs:
total: int, total: int,
folder: str, folder: str,
status: str, status: str,
key: Optional[str] = None,
progress: float = 0.0, progress: float = 0.0,
message: Optional[str] = None, message: Optional[str] = None,
error: Optional[Exception] = None, error: Optional[Exception] = None,
@ -80,8 +89,11 @@ class ScanStatusEventArgs:
Args: Args:
current: Current item being scanned current: Current item being scanned
total: Total items to scan total: Total items to scan
folder: Current folder being scanned folder: Current folder being scanned (metadata only)
status: Status message (e.g., "started", "progress", "completed", "failed", "cancelled") status: Status message (e.g., "started", "progress",
"completed", "failed", "cancelled")
key: Serie unique identifier if applicable (provider key,
primary identifier)
progress: Scan progress (0.0 to 1.0) progress: Scan progress (0.0 to 1.0)
message: Optional status message message: Optional status message
error: Optional error if status is "failed" error: Optional error if status is "failed"
@ -89,11 +101,13 @@ class ScanStatusEventArgs:
self.current = current self.current = current
self.total = total self.total = total
self.folder = folder self.folder = folder
self.key = key
self.status = status self.status = status
self.progress = progress self.progress = progress
self.message = message self.message = message
self.error = error self.error = error
class SeriesApp: class SeriesApp:
""" """
Main application class for anime series management. Main application class for anime series management.
@ -135,10 +149,14 @@ class SeriesApp:
self.loader = self.loaders.GetLoader(key="aniworld.to") self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(directory_to_search, self.loader) self.serie_scanner = SerieScanner(directory_to_search, self.loader)
self.list = SerieList(self.directory_to_search) self.list = SerieList(self.directory_to_search)
# Synchronous init used during constructor to avoid awaiting in __init__ # Synchronous init used during constructor to avoid awaiting
# in __init__
self._init_list_sync() self._init_list_sync()
logger.info("SeriesApp initialized for directory: %s", directory_to_search) logger.info(
"SeriesApp initialized for directory: %s",
directory_to_search
)
@property @property
def download_status(self): def download_status(self):
@ -173,13 +191,20 @@ class SeriesApp:
def _init_list_sync(self) -> None: def _init_list_sync(self) -> None:
"""Synchronous initialization helper for constructor.""" """Synchronous initialization helper for constructor."""
self.series_list = self.list.GetMissingEpisode() self.series_list = self.list.GetMissingEpisode()
logger.debug("Loaded %d series with missing episodes", len(self.series_list)) logger.debug(
"Loaded %d series with missing episodes",
len(self.series_list)
)
async def _init_list(self) -> None: async def _init_list(self) -> None:
"""Initialize the series list with missing episodes (async).""" """Initialize the series list with missing episodes (async)."""
self.series_list = await asyncio.to_thread(self.list.GetMissingEpisode) self.series_list = await asyncio.to_thread(
logger.debug("Loaded %d series with missing episodes", len(self.series_list)) self.list.GetMissingEpisode
)
logger.debug(
"Loaded %d series with missing episodes",
len(self.series_list)
)
async def search(self, words: str) -> List[Dict[str, Any]]: async def search(self, words: str) -> List[Dict[str, Any]]:
""" """
@ -212,22 +237,37 @@ class SeriesApp:
Download an episode (async). Download an episode (async).
Args: Args:
serie_folder: Serie folder name serie_folder: Serie folder name (metadata only, used for
file path construction)
season: Season number season: Season number
episode: Episode number episode: Episode number
key: Serie key key: Serie unique identifier (provider key, primary
identifier for lookups)
language: Language preference language: Language preference
item_id: Optional download queue item ID for progress tracking item_id: Optional download queue item ID for progress
tracking
Returns: Returns:
True if download succeeded, False otherwise True if download succeeded, False otherwise
Note:
The 'key' parameter is the primary identifier for series
lookups. The 'serie_folder' parameter is only used for
filesystem operations.
""" """
logger.info("Starting download: %s S%02dE%02d", serie_folder, season, episode) logger.info(
"Starting download: %s (key: %s) S%02dE%02d",
serie_folder,
key,
season,
episode
)
# Fire download started event # Fire download started event
self._events.download_status( self._events.download_status(
DownloadStatusEventArgs( DownloadStatusEventArgs(
serie_folder=serie_folder, serie_folder=serie_folder,
key=key,
season=season, season=season,
episode=episode, episode=episode,
status="started", status="started",
@ -238,7 +278,9 @@ class SeriesApp:
try: try:
def download_callback(progress_info): def download_callback(progress_info):
logger.debug(f"wrapped_callback called with: {progress_info}") logger.debug(
"wrapped_callback called with: %s", progress_info
)
downloaded = progress_info.get('downloaded_bytes', 0) downloaded = progress_info.get('downloaded_bytes', 0)
total_bytes = ( total_bytes = (
@ -253,11 +295,15 @@ class SeriesApp:
self._events.download_status( self._events.download_status(
DownloadStatusEventArgs( DownloadStatusEventArgs(
serie_folder=serie_folder, serie_folder=serie_folder,
key=key,
season=season, season=season,
episode=episode, episode=episode,
status="progress", status="progress",
message="Download progress", message="Download progress",
progress=(downloaded / total_bytes) * 100 if total_bytes else 0, progress=(
(downloaded / total_bytes) * 100
if total_bytes else 0
),
eta=eta, eta=eta,
mbper_sec=mbper_sec, mbper_sec=mbper_sec,
item_id=item_id, item_id=item_id,
@ -277,13 +323,18 @@ class SeriesApp:
if download_success: if download_success:
logger.info( logger.info(
"Download completed: %s S%02dE%02d", serie_folder, season, episode "Download completed: %s (key: %s) S%02dE%02d",
serie_folder,
key,
season,
episode
) )
# Fire download completed event # Fire download completed event
self._events.download_status( self._events.download_status(
DownloadStatusEventArgs( DownloadStatusEventArgs(
serie_folder=serie_folder, serie_folder=serie_folder,
key=key,
season=season, season=season,
episode=episode, episode=episode,
status="completed", status="completed",
@ -294,13 +345,18 @@ class SeriesApp:
) )
else: else:
logger.warning( logger.warning(
"Download failed: %s S%02dE%02d", serie_folder, season, episode "Download failed: %s (key: %s) S%02dE%02d",
serie_folder,
key,
season,
episode
) )
# Fire download failed event # Fire download failed event
self._events.download_status( self._events.download_status(
DownloadStatusEventArgs( DownloadStatusEventArgs(
serie_folder=serie_folder, serie_folder=serie_folder,
key=key,
season=season, season=season,
episode=episode, episode=episode,
status="failed", status="failed",
@ -313,8 +369,9 @@ class SeriesApp:
except Exception as e: except Exception as e:
logger.error( logger.error(
"Download error: %s S%02dE%02d - %s", "Download error: %s (key: %s) S%02dE%02d - %s",
serie_folder, serie_folder,
key,
season, season,
episode, episode,
str(e), str(e),
@ -325,6 +382,7 @@ class SeriesApp:
self._events.download_status( self._events.download_status(
DownloadStatusEventArgs( DownloadStatusEventArgs(
serie_folder=serie_folder, serie_folder=serie_folder,
key=key,
season=season, season=season,
episode=episode, episode=episode,
status="failed", status="failed",
@ -347,7 +405,9 @@ class SeriesApp:
try: try:
# Get total items to scan # Get total items to scan
total_to_scan = await asyncio.to_thread(self.serie_scanner.get_total_to_scan) total_to_scan = await asyncio.to_thread(
self.serie_scanner.get_total_to_scan
)
logger.info("Total folders to scan: %d", total_to_scan) logger.info("Total folders to scan: %d", total_to_scan)
# Fire scan started event # Fire scan started event
@ -401,7 +461,10 @@ class SeriesApp:
folder="", folder="",
status="completed", status="completed",
progress=1.0, progress=1.0,
message=f"Scan completed. Found {len(self.series_list)} series with missing episodes.", message=(
f"Scan completed. Found {len(self.series_list)} "
"series with missing episodes."
),
) )
) )
@ -448,5 +511,28 @@ class SeriesApp:
return self.series_list return self.series_list
async def refresh_series_list(self) -> None: async def refresh_series_list(self) -> None:
"""Reload the cached series list from the underlying data store (async).""" """
Reload the cached series list from the underlying data store.
This is an async operation.
"""
await self._init_list() await self._init_list()
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
"""
Get a series by its unique provider key.
This is the primary method for series lookups within SeriesApp.
Args:
key: The unique provider identifier (e.g.,
"attack-on-titan")
Returns:
The Serie instance if found, None otherwise
Note:
This method uses the SerieList.get_by_key() method which
looks up series by their unique key, not by folder name.
"""
return self.list.get_by_key(key)

View File

@ -2,23 +2,37 @@
import logging import logging
import os import os
import warnings
from json import JSONDecodeError from json import JSONDecodeError
from typing import Dict, Iterable, List from typing import Dict, Iterable, List, Optional
from src.core.entities.series import Serie from src.core.entities.series import Serie
class SerieList: class SerieList:
"""Represents the collection of cached series stored on disk.""" """
Represents the collection of cached series stored on disk.
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
"""
def __init__(self, base_path: str) -> None: def __init__(self, base_path: str) -> None:
self.directory: str = base_path self.directory: str = base_path
self.folderDict: Dict[str, Serie] = {} # Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, Serie] = {}
self.load_series() self.load_series()
def add(self, serie: Serie) -> None: def add(self, serie: Serie) -> None:
"""Persist a new series if it is not already present.""" """
Persist a new series if it is not already present.
Uses serie.key for identification. The serie.folder is used for
filesystem operations only.
Args:
serie: The Serie instance to add
"""
if self.contains(serie.key): if self.contains(serie.key):
return return
@ -27,12 +41,20 @@ class SerieList:
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)
self.folderDict[serie.folder] = serie # Store by key, not folder
self.keyDict[serie.key] = serie
def contains(self, key: str) -> bool: def contains(self, key: str) -> bool:
"""Return True when a series identified by ``key`` already exists.""" """
Return True when a series identified by ``key`` already exists.
return any(value.key == key for value in self.folderDict.values())
Args:
key: The unique provider identifier for the series
Returns:
True if the series exists in the collection
"""
return key in self.keyDict
def load_series(self) -> None: def load_series(self) -> None:
"""Populate the in-memory map with metadata discovered on disk.""" """Populate the in-memory map with metadata discovered on disk."""
@ -61,11 +83,22 @@ class SerieList:
) )
def _load_data(self, anime_folder: str, data_path: str) -> None: def _load_data(self, anime_folder: str, data_path: str) -> None:
"""Load a single series metadata file into the in-memory collection.""" """
Load a single series metadata file into the in-memory collection.
Args:
anime_folder: The folder name (for logging only)
data_path: Path to the metadata file
"""
try: try:
self.folderDict[anime_folder] = Serie.load_from_file(data_path) serie = Serie.load_from_file(data_path)
logging.debug("Successfully loaded metadata for %s", anime_folder) # Store by key, not folder
self.keyDict[serie.key] = serie
logging.debug(
"Successfully loaded metadata for %s (key: %s)",
anime_folder,
serie.key
)
except (OSError, JSONDecodeError, KeyError, ValueError) as error: except (OSError, JSONDecodeError, KeyError, ValueError) as error:
logging.error( logging.error(
"Failed to load metadata for folder %s from %s: %s", "Failed to load metadata for folder %s from %s: %s",
@ -76,24 +109,63 @@ class SerieList:
def GetMissingEpisode(self) -> List[Serie]: def GetMissingEpisode(self) -> List[Serie]:
"""Return all series that still contain missing episodes.""" """Return all series that still contain missing episodes."""
return [ return [
serie serie
for serie in self.folderDict.values() for serie in self.keyDict.values()
if serie.episodeDict if serie.episodeDict
] ]
def get_missing_episodes(self) -> List[Serie]: def get_missing_episodes(self) -> List[Serie]:
"""PEP8-friendly alias for :meth:`GetMissingEpisode`.""" """PEP8-friendly alias for :meth:`GetMissingEpisode`."""
return self.GetMissingEpisode() return self.GetMissingEpisode()
def GetList(self) -> List[Serie]: def GetList(self) -> List[Serie]:
"""Return all series instances stored in the list.""" """Return all series instances stored in the list."""
return list(self.keyDict.values())
return list(self.folderDict.values())
def get_all(self) -> List[Serie]: def get_all(self) -> List[Serie]:
"""PEP8-friendly alias for :meth:`GetList`.""" """PEP8-friendly alias for :meth:`GetList`."""
return self.GetList() return self.GetList()
def get_by_key(self, key: str) -> Optional[Serie]:
"""
Get a series by its unique provider key.
This is the primary method for series lookup.
Args:
key: The unique provider identifier (e.g., "attack-on-titan")
Returns:
The Serie instance if found, None otherwise
"""
return self.keyDict.get(key)
def get_by_folder(self, folder: str) -> Optional[Serie]:
"""
Get a series by its folder name.
.. deprecated:: 2.0.0
Use :meth:`get_by_key` instead. Folder-based lookups will be
removed in version 3.0.0. The `folder` field is metadata only
and should not be used for identification.
This method is provided for backward compatibility only.
Prefer using get_by_key() for new code.
Args:
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
Returns:
The Serie instance if found, None otherwise
"""
warnings.warn(
"get_by_folder() is deprecated and will be removed in v3.0.0. "
"Use get_by_key() instead. The 'folder' field is metadata only.",
DeprecationWarning,
stacklevel=2
)
for serie in self.keyDict.values():
if serie.folder == folder:
return serie
return None

View File

@ -1,23 +1,82 @@
import json import json
class Serie: class Serie:
def __init__(self, key: str, name: str, site: str, folder: str, episodeDict: dict[int, list[int]]): """
self._key = key Represents an anime series with metadata and episode information.
The `key` property is the unique identifier for the series
(provider-assigned, URL-safe).
The `folder` property is the filesystem folder name
(metadata only, not used for lookups).
Args:
key: Unique series identifier from provider
(e.g., "attack-on-titan"). Cannot be empty.
name: Display name of the series
site: Provider site URL
folder: Filesystem folder name (metadata only,
e.g., "Attack on Titan (2013)")
episodeDict: Dictionary mapping season numbers to
lists of episode numbers
Raises:
ValueError: If key is None or empty string
"""
def __init__(
self,
key: str,
name: str,
site: str,
folder: str,
episodeDict: dict[int, list[int]]
):
if not key or not key.strip():
raise ValueError("Serie key cannot be None or empty")
self._key = key.strip()
self._name = name self._name = name
self._site = site self._site = site
self._folder = folder self._folder = folder
self._episodeDict = episodeDict self._episodeDict = episodeDict
def __str__(self): def __str__(self):
"""String representation of Serie object""" """String representation of Serie object"""
return f"Serie(key='{self.key}', name='{self.name}', site='{self.site}', folder='{self.folder}', episodeDict={self.episodeDict})" return (
f"Serie(key='{self.key}', name='{self.name}', "
f"site='{self.site}', folder='{self.folder}', "
f"episodeDict={self.episodeDict})"
)
@property @property
def key(self) -> str: def key(self) -> str:
"""
Unique series identifier (primary identifier for all lookups).
This is the provider-assigned, URL-safe identifier used
throughout the application for series identification,
lookups, and operations.
Returns:
str: The unique series key
"""
return self._key return self._key
@key.setter @key.setter
def key(self, value: str): def key(self, value: str):
self._key = value """
Set the unique series identifier.
Args:
value: New key value
Raises:
ValueError: If value is None or empty string
"""
if not value or not value.strip():
raise ValueError("Serie key cannot be None or empty")
self._key = value.strip()
@property @property
def name(self) -> str: def name(self) -> str:
@ -37,10 +96,26 @@ class Serie:
@property @property
def folder(self) -> str: def folder(self) -> str:
"""
Filesystem folder name (metadata only, not used for lookups).
This property contains the local directory name where the series
files are stored. It should NOT be used as an identifier for
series lookups - use `key` instead.
Returns:
str: The filesystem folder name
"""
return self._folder return self._folder
@folder.setter @folder.setter
def folder(self, value: str): def folder(self, value: str):
"""
Set the filesystem folder name.
Args:
value: Folder name for the series
"""
self._folder = value self._folder = value
@property @property
@ -58,25 +133,34 @@ class Serie:
"name": self.name, "name": self.name,
"site": self.site, "site": self.site,
"folder": self.folder, "folder": self.folder,
"episodeDict": {str(k): list(v) for k, v in self.episodeDict.items()} "episodeDict": {
str(k): list(v) for k, v in self.episodeDict.items()
}
} }
@staticmethod @staticmethod
def from_dict(data: dict): def from_dict(data: dict):
"""Create a Serie object from dictionary.""" """Create a Serie object from dictionary."""
episode_dict = {int(k): v for k, v in data["episodeDict"].items()} # Convert keys to int # Convert keys to int
return Serie(data["key"], data["name"], data["site"], data["folder"], episode_dict) episode_dict = {
int(k): v for k, v in data["episodeDict"].items()
}
return Serie(
data["key"],
data["name"],
data["site"],
data["folder"],
episode_dict
)
def save_to_file(self, filename: str): def save_to_file(self, filename: str):
"""Save Serie object to JSON file.""" """Save Serie object to JSON file."""
with open(filename, "w") as file: with open(filename, "w", encoding="utf-8") as file:
json.dump(self.to_dict(), file, indent=4) json.dump(self.to_dict(), file, indent=4)
@classmethod @classmethod
def load_from_file(cls, filename: str) -> "Serie": def load_from_file(cls, filename: str) -> "Serie":
"""Load Serie object from JSON file.""" """Load Serie object from JSON file."""
with open(filename, "r") as file: with open(filename, "r", encoding="utf-8") as file:
data = json.load(file) data = json.load(file)
return cls.from_dict(data) return cls.from_dict(data)

View File

@ -47,6 +47,8 @@ class ProgressContext:
percentage: Completion percentage (0.0 to 100.0) percentage: Completion percentage (0.0 to 100.0)
message: Human-readable progress message message: Human-readable progress message
details: Additional context-specific details details: Additional context-specific details
key: Provider-assigned series identifier (None when not applicable)
folder: Optional folder metadata for display purposes only
metadata: Extra metadata for specialized use cases metadata: Extra metadata for specialized use cases
""" """
@ -58,6 +60,8 @@ class ProgressContext:
percentage: float percentage: float
message: str message: str
details: Optional[str] = None details: Optional[str] = None
key: Optional[str] = None
folder: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@ -71,6 +75,8 @@ class ProgressContext:
"percentage": round(self.percentage, 2), "percentage": round(self.percentage, 2),
"message": self.message, "message": self.message,
"details": self.details, "details": self.details,
"key": self.key,
"folder": self.folder,
"metadata": self.metadata, "metadata": self.metadata,
} }
@ -87,6 +93,8 @@ class ErrorContext:
message: Human-readable error message message: Human-readable error message
recoverable: Whether the error is recoverable recoverable: Whether the error is recoverable
retry_count: Number of retry attempts made retry_count: Number of retry attempts made
key: Provider-assigned series identifier (None when not applicable)
folder: Optional folder metadata for display purposes only
metadata: Additional error context metadata: Additional error context
""" """
@ -96,6 +104,8 @@ class ErrorContext:
message: str message: str
recoverable: bool = False recoverable: bool = False
retry_count: int = 0 retry_count: int = 0
key: Optional[str] = None
folder: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@ -108,6 +118,8 @@ class ErrorContext:
"message": self.message, "message": self.message,
"recoverable": self.recoverable, "recoverable": self.recoverable,
"retry_count": self.retry_count, "retry_count": self.retry_count,
"key": self.key,
"folder": self.folder,
"metadata": self.metadata, "metadata": self.metadata,
} }
@ -124,6 +136,8 @@ class CompletionContext:
message: Human-readable completion message message: Human-readable completion message
result_data: Result data from the operation result_data: Result data from the operation
statistics: Operation statistics (duration, items processed, etc.) statistics: Operation statistics (duration, items processed, etc.)
key: Provider-assigned series identifier (None when not applicable)
folder: Optional folder metadata for display purposes only
metadata: Additional completion context metadata: Additional completion context
""" """
@ -133,6 +147,8 @@ class CompletionContext:
message: str message: str
result_data: Optional[Any] = None result_data: Optional[Any] = None
statistics: Dict[str, Any] = field(default_factory=dict) statistics: Dict[str, Any] = field(default_factory=dict)
key: Optional[str] = None
folder: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@ -143,6 +159,8 @@ class CompletionContext:
"success": self.success, "success": self.success,
"message": self.message, "message": self.message,
"statistics": self.statistics, "statistics": self.statistics,
"key": self.key,
"folder": self.folder,
"metadata": self.metadata, "metadata": self.metadata,
} }

View File

@ -208,8 +208,26 @@ class AniworldLoader(Loader):
language: str = "German Dub", language: str = "German Dub",
progress_callback=None progress_callback=None
) -> bool: ) -> bool:
"""Download episode to specified directory.""" """Download episode to specified directory.
logging.info(f"Starting download for S{season:02}E{episode:03} ({key}) in {language}")
Args:
base_directory: Base download directory path
serie_folder: Filesystem folder name (metadata only, used for
file path construction)
season: Season number
episode: Episode number
key: Series unique identifier from provider (used for
identification and API calls)
language: Audio language preference (default: German Dub)
progress_callback: Optional callback for download progress
Returns:
bool: True if download succeeded, False otherwise
"""
logging.info(
f"Starting download for S{season:02}E{episode:03} "
f"({key}) in {language}"
)
sanitized_anime_title = ''.join( sanitized_anime_title = ''.join(
char for char in self.get_title(key) char for char in self.get_title(key)
if char not in self.INVALID_PATH_CHARS if char not in self.INVALID_PATH_CHARS

View File

@ -349,7 +349,27 @@ class EnhancedAniWorldLoader(Loader):
language: str = "German Dub", language: str = "German Dub",
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
) -> bool: ) -> bool:
"""Download episode with comprehensive error handling.""" """Download episode with comprehensive error handling.
Args:
baseDirectory: Base download directory path
serieFolder: Filesystem folder name (metadata only, used for
file path construction)
season: Season number (0 for movies)
episode: Episode number
key: Series unique identifier from provider (used for
identification and API calls)
language: Audio language preference (default: German Dub)
progress_callback: Optional callback for download progress
updates
Returns:
bool: True if download succeeded, False otherwise
Raises:
DownloadError: If download fails after all retry attempts
ValueError: If required parameters are missing or invalid
"""
self.download_stats["total_downloads"] += 1 self.download_stats["total_downloads"] += 1
try: try:

View File

@ -1,10 +1,56 @@
"""Provider factory for managing anime content providers.
This module provides a factory class for accessing different anime content
providers (loaders). The factory uses provider identifiers (keys) to return
the appropriate provider instance.
Note: The 'key' parameter in this factory refers to the provider identifier
(e.g., 'aniworld.to'), not to be confused with series keys used within
providers to identify specific anime series.
"""
from typing import Dict
from .aniworld_provider import AniworldLoader from .aniworld_provider import AniworldLoader
from .base_provider import Loader from .base_provider import Loader
class Loaders:
def __init__(self): class Loaders:
self.dict = {"aniworld.to": AniworldLoader()} """Factory class for managing and retrieving anime content providers.
This factory maintains a registry of available providers and provides
access to them via provider keys. Each provider implements the Loader
interface for searching and downloading anime content.
Attributes:
dict: Dictionary mapping provider keys to provider instances.
Provider keys are site identifiers (e.g., 'aniworld.to').
"""
def __init__(self) -> None:
"""Initialize the provider factory with available providers.
Currently supports:
- 'aniworld.to': AniworldLoader for aniworld.to content
"""
self.dict: Dict[str, Loader] = {"aniworld.to": AniworldLoader()}
def GetLoader(self, key: str) -> Loader: def GetLoader(self, key: str) -> Loader:
"""Retrieve a provider instance by its provider key.
Args:
key: Provider identifier (e.g., 'aniworld.to').
This is the site/provider key, not a series key.
Returns:
Loader instance for the specified provider.
Raises:
KeyError: If the provider key is not found in the registry.
Note:
The 'key' parameter here identifies the provider/site, while
series-specific operations on the returned Loader use series
keys to identify individual anime series.
"""
return self.dict[key] return self.dict[key]

View File

@ -1,3 +1,5 @@
import logging
import warnings
from typing import Any, List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@ -11,6 +13,8 @@ from src.server.utils.dependencies import (
require_auth, require_auth,
) )
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/anime", tags=["anime"]) router = APIRouter(prefix="/api/anime", tags=["anime"])
@ -55,13 +59,46 @@ async def get_anime_status(
class AnimeSummary(BaseModel): class AnimeSummary(BaseModel):
"""Summary of an anime series with missing episodes.""" """Summary of an anime series with missing episodes.
key: str # Unique identifier (used as id in frontend)
name: str # Series name (can be empty) The `key` field is the unique provider-assigned identifier used for all
site: str # Provider site lookups and operations (URL-safe, e.g., "attack-on-titan").
folder: str # Local folder name
missing_episodes: dict # Episode dictionary: {season: [episode_numbers]} The `folder` field is metadata only for filesystem operations and display
link: Optional[str] = "" # Link to the series page (for adding new series) (e.g., "Attack on Titan (2013)") - not used for identification.
Attributes:
key: Unique series identifier (primary key for all operations)
name: Display name of the series
site: Provider site URL
folder: Filesystem folder name (metadata only)
missing_episodes: Episode dictionary mapping seasons to episode numbers
link: Optional link to the series page (used when adding new series)
"""
key: str = Field(
...,
description="Unique series identifier (primary key for all operations)"
)
name: str = Field(
...,
description="Display name of the series"
)
site: str = Field(
...,
description="Provider site URL"
)
folder: str = Field(
...,
description="Filesystem folder name (metadata, not for lookups)"
)
missing_episodes: dict = Field(
...,
description="Episode dictionary: {season: [episode_numbers]}"
)
link: Optional[str] = Field(
default="",
description="Link to the series page (for adding new series)"
)
class Config: class Config:
"""Pydantic model configuration.""" """Pydantic model configuration."""
@ -78,10 +115,52 @@ class AnimeSummary(BaseModel):
class AnimeDetail(BaseModel): class AnimeDetail(BaseModel):
id: str """Detailed information about a specific anime series.
title: str
episodes: List[str] The `key` field is the unique provider-assigned identifier used for all
description: Optional[str] = None lookups and operations (URL-safe, e.g., "attack-on-titan").
The `folder` field is metadata only for filesystem operations and display.
Attributes:
key: Unique series identifier (primary key for all operations)
title: Display name of the series
folder: Filesystem folder name (metadata only)
episodes: List of episode identifiers in "season-episode" format
description: Optional description of the series
"""
key: str = Field(
...,
description="Unique series identifier (primary key for all operations)"
)
title: str = Field(
...,
description="Display name of the series"
)
folder: str = Field(
default="",
description="Filesystem folder name (metadata, not for lookups)"
)
episodes: List[str] = Field(
...,
description="List of episode identifiers in 'season-episode' format"
)
description: Optional[str] = Field(
default=None,
description="Optional description of the series"
)
class Config:
"""Pydantic model configuration."""
json_schema_extra = {
"example": {
"key": "attack-on-titan",
"title": "Attack on Titan",
"folder": "Attack on Titan (2013)",
"episodes": ["1-1", "1-2", "1-3"],
"description": "Humans fight against giant humanoid Titans."
}
}
@router.get("/", response_model=List[AnimeSummary]) @router.get("/", response_model=List[AnimeSummary])
@ -96,19 +175,30 @@ async def list_anime(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""List library series that still have missing episodes. """List library series that still have missing episodes.
Returns AnimeSummary objects where `key` is the primary identifier
used for all operations. The `folder` field is metadata only and
should not be used for lookups.
Args: Args:
page: Page number for pagination (must be positive) page: Page number for pagination (must be positive)
per_page: Items per page (must be positive, max 1000) per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter (validated for security) sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter (validated for security) filter: Optional filter parameter (validated for security)
_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.
Returns: Returns:
List[AnimeSummary]: Summary entries describing missing content. List[AnimeSummary]: Summary entries with `key` as primary identifier.
Each entry includes:
- key: Unique series identifier (use for all operations)
- name: Display name
- site: Provider site
- folder: Filesystem folder name (metadata only)
- missing_episodes: Dict mapping seasons to episode numbers
Raises: Raises:
HTTPException: When the underlying lookup fails or params are invalid. HTTPException: When the underlying lookup fails or params invalid.
""" """
# Validate pagination parameters # Validate pagination parameters
if page is not None: if page is not None:
@ -336,12 +426,15 @@ async def search_anime_get(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Search the provider for additional series matching a query (GET). """Search the provider for additional series matching a query (GET).
Returns AnimeSummary objects where `key` is the primary identifier.
Use the `key` field for subsequent operations (add, download, etc.).
Args: Args:
query: Search term passed as query parameter query: Search term passed as query parameter
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -359,12 +452,15 @@ async def search_anime_post(
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Search the provider for additional series matching a query (POST). """Search the provider for additional series matching a query (POST).
Returns AnimeSummary objects where `key` is the primary identifier.
Use the `key` field for subsequent operations (add, download, etc.).
Args: Args:
request: Request containing the search query request: Request containing the search query
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier.
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -376,14 +472,28 @@ async def _perform_search(
query: str, query: str,
series_app: Optional[Any], series_app: Optional[Any],
) -> List[AnimeSummary]: ) -> List[AnimeSummary]:
"""Internal function to perform the search logic. """Search for anime series matching the given query.
This internal function performs the actual search logic, extracting
results from the provider and converting them to AnimeSummary objects.
The returned summaries use `key` as the primary identifier. The `key`
is extracted from the result's key field (preferred) or derived from
the link URL if not available. The `folder` field is metadata only.
Args: Args:
query: Search term query: Search term (will be validated and sanitized)
series_app: Optional SeriesApp instance. series_app: Optional SeriesApp instance for search.
Returns: Returns:
List[AnimeSummary]: Discovered matches returned from the provider. List[AnimeSummary]: Discovered matches with `key` as identifier
and `folder` as metadata. Each summary includes:
- key: Unique series identifier (primary)
- name: Display name
- site: Provider site
- folder: Filesystem folder name (metadata)
- link: URL to series page
- missing_episodes: Episode dictionary
Raises: Raises:
HTTPException: When provider communication fails or query is invalid. HTTPException: When provider communication fails or query is invalid.
@ -406,7 +516,8 @@ async def _perform_search(
summaries: List[AnimeSummary] = [] summaries: List[AnimeSummary] = []
for match in matches: for match in matches:
if isinstance(match, dict): if isinstance(match, dict):
identifier = match.get("key") or match.get("id") or "" # Extract key (primary identifier)
key = match.get("key") or match.get("id") or ""
title = match.get("title") or match.get("name") or "" title = match.get("title") or match.get("name") or ""
site = match.get("site") or "" site = match.get("site") or ""
folder = match.get("folder") or "" folder = match.get("folder") or ""
@ -416,17 +527,38 @@ async def _perform_search(
or match.get("missing") or match.get("missing")
or {} or {}
) )
# If key is empty, try to extract from link
if not key and link:
if "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
elif link and "/" not in link:
# Link is just a slug (e.g., "attack-on-titan")
key = link
else: else:
identifier = getattr(match, "key", getattr(match, "id", "")) # Extract key (primary identifier)
title = getattr(match, "title", getattr(match, "name", "")) key = getattr(match, "key", "") or getattr(match, "id", "")
title = getattr(match, "title", "") or getattr(
match, "name", ""
)
site = getattr(match, "site", "") site = getattr(match, "site", "")
folder = getattr(match, "folder", "") folder = getattr(match, "folder", "")
link = getattr(match, "link", getattr(match, "url", "")) link = getattr(match, "link", "") or getattr(
match, "url", ""
)
missing = getattr(match, "missing_episodes", {}) missing = getattr(match, "missing_episodes", {})
# If key is empty, try to extract from link
if not key and link:
if "/anime/stream/" in link:
key = link.split("/anime/stream/")[-1].split("/")[0]
elif link and "/" not in link:
# Link is just a slug (e.g., "attack-on-titan")
key = link
summaries.append( summaries.append(
AnimeSummary( AnimeSummary(
key=identifier, key=key,
name=title, name=title,
site=site, site=site,
folder=folder, folder=folder,
@ -453,16 +585,23 @@ async def add_series(
) -> dict: ) -> dict:
"""Add a new series to the library. """Add a new series to the library.
Extracts the series `key` from the provided link URL.
The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata along with a
filesystem-friendly `folder` name derived from the name.
Args: Args:
request: Request containing the series link and name request: Request containing the series link and name.
- link: URL to the series (e.g., aniworld.to/anime/stream/key)
- name: Display name for the 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
Returns: Returns:
Dict[str, Any]: Status payload with success message Dict[str, Any]: Status payload with success message and key
Raises: Raises:
HTTPException: If adding the series fails HTTPException: If adding the series fails or link is invalid
""" """
try: try:
# Validate inputs # Validate inputs
@ -485,16 +624,39 @@ async def add_series(
detail="Series list functionality not available", detail="Series list functionality not available",
) )
# Extract key from link URL
# Expected format: https://aniworld.to/anime/stream/{key}
link = request.link.strip()
key = link
# Try to extract key from URL path
if "/anime/stream/" in link:
# Extract everything after /anime/stream/
key = link.split("/anime/stream/")[-1].split("/")[0].strip()
elif "/" in link:
# Fallback: use last path segment
key = link.rstrip("/").split("/")[-1].strip()
# Validate extracted key
if not key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not extract series key from link",
)
# Create folder from name (filesystem-friendly)
folder = request.name.strip()
# Create a new Serie object # Create a new Serie object
# Following the pattern from CLI: # key: unique identifier extracted from link
# Serie(key, name, site, folder, episodeDict) # name: display name from request
# The key and folder are both the link in this case # folder: filesystem folder name (derived from name)
# episodeDict is empty {} for a new series # episodeDict: empty for new series
serie = Serie( serie = Serie(
key=request.link.strip(), key=key,
name=request.name.strip(), name=request.name.strip(),
site="aniworld.to", site="aniworld.to",
folder=request.name.strip(), folder=folder,
episodeDict={} episodeDict={}
) )
@ -507,7 +669,9 @@ async def add_series(
return { return {
"status": "success", "status": "success",
"message": f"Successfully added series: {request.name}" "message": f"Successfully added series: {request.name}",
"key": key,
"folder": folder
} }
except HTTPException: except HTTPException:
raise raise
@ -525,12 +689,18 @@ async def get_anime(
) -> AnimeDetail: ) -> AnimeDetail:
"""Return detailed information about a specific series. """Return detailed information about a specific series.
The `anime_id` parameter should be the series `key` (primary identifier).
For backward compatibility, lookups by `folder` are also supported but
deprecated. The `key` is checked first, then `folder` as fallback.
Args: Args:
anime_id: Provider key or folder name of the requested series. anime_id: Series `key` (primary) or `folder` (deprecated fallback).
series_app: Optional SeriesApp instance provided via dependency. series_app: Optional SeriesApp instance provided via dependency.
Returns: Returns:
AnimeDetail: Detailed series metadata including episode list. AnimeDetail: Detailed series metadata including episode list.
Response includes `key` as the primary identifier and
`folder` as metadata.
Raises: Raises:
HTTPException: If the anime cannot be located or retrieval fails. HTTPException: If the anime cannot be located or retrieval fails.
@ -545,12 +715,34 @@ async def get_anime(
series = series_app.list.GetList() series = series_app.list.GetList()
found = None found = None
# Primary lookup: search by key first (preferred)
for serie in series: for serie in series:
matches_key = getattr(serie, "key", None) == anime_id if getattr(serie, "key", None) == anime_id:
matches_folder = getattr(serie, "folder", None) == anime_id
if matches_key or matches_folder:
found = serie found = serie
break break
# Fallback lookup: search by folder (backward compatibility)
if not found:
for serie in series:
if getattr(serie, "folder", None) == anime_id:
found = serie
# Log deprecation warning for folder-based lookup
key = getattr(serie, "key", "unknown")
logger.warning(
"Folder-based lookup for '%s' is deprecated. "
"Use series key '%s' instead. Folder-based lookups "
"will be removed in v3.0.0.",
anime_id,
key
)
warnings.warn(
f"Folder-based lookup for '{anime_id}' is deprecated. "
f"Use series key '{key}' instead.",
DeprecationWarning,
stacklevel=2
)
break
if not found: if not found:
raise HTTPException( raise HTTPException(
@ -564,9 +756,11 @@ async def get_anime(
for episode in episode_numbers: for episode in episode_numbers:
episodes.append(f"{season}-{episode}") episodes.append(f"{season}-{episode}")
# Return AnimeDetail with key as the primary identifier
return AnimeDetail( return AnimeDetail(
id=getattr(found, "key", getattr(found, "folder", "")), key=getattr(found, "key", ""),
title=getattr(found, "name", ""), title=getattr(found, "name", ""),
folder=getattr(found, "folder", ""),
episodes=episodes, episodes=episodes,
description=getattr(found, "description", None), description=getattr(found, "description", None),
) )

View File

@ -74,7 +74,12 @@ async def add_to_queue(
Requires authentication. Requires authentication.
Args: Args:
request: Download request with serie info, episodes, and priority request: Download request containing:
- serie_id: Series key (primary identifier, 'attack-on-titan')
- serie_folder: Filesystem folder name for storing downloads
- serie_name: Display name for the series
- episodes: List of episodes to download
- priority: Queue priority level
Returns: Returns:
DownloadResponse: Status and list of created download item IDs DownloadResponse: Status and list of created download item IDs

View File

@ -2,6 +2,14 @@
This module provides WebSocket endpoints for clients to connect and receive This module provides WebSocket endpoints for clients to connect and receive
real-time updates about downloads, queue status, and system events. real-time updates about downloads, queue status, and system events.
Series Identifier Convention:
- `key`: Primary identifier for series (provider-assigned, URL-safe)
e.g., "attack-on-titan"
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
All series-related WebSocket events include `key` as the primary identifier
in their data payload. The `folder` field is optional for display purposes.
""" """
from __future__ import annotations from __future__ import annotations
@ -58,19 +66,25 @@ async def websocket_endpoint(
} }
``` ```
Server message format: Server message format (series-related events include 'key' identifier):
```json ```json
{ {
"type": "download_progress", "type": "download_progress",
"timestamp": "2025-10-17T10:30:00.000Z", "timestamp": "2025-10-17T10:30:00.000Z",
"data": { "data": {
"download_id": "abc123", "download_id": "abc123",
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"percent": 45.2, "percent": 45.2,
"speed_mbps": 2.5, "speed_mbps": 2.5,
"eta_seconds": 180 "eta_seconds": 180
} }
} }
``` ```
Note:
- `key` is the primary series identifier (provider-assigned, URL-safe)
- `folder` is optional display metadata
""" """
connection_id = str(uuid.uuid4()) connection_id = str(uuid.uuid4())
user_id: Optional[str] = None user_id: Optional[str] = None

View File

@ -38,20 +38,31 @@ class AnimeSeries(Base, TimestampMixin):
Represents an anime series with metadata, provider information, Represents an anime series with metadata, provider information,
and links to episodes. Corresponds to the core Serie class. and links to episodes. Corresponds to the core Serie class.
Series Identifier Convention:
- `key`: PRIMARY IDENTIFIER - Unique, provider-assigned, URL-safe
(e.g., "attack-on-titan"). Used for all lookups and operations.
- `folder`: METADATA ONLY - Filesystem folder name for display
(e.g., "Attack on Titan (2013)"). Never used for identification.
- `id`: Internal database primary key for relationships.
Attributes: Attributes:
id: Primary key id: Database primary key (internal use for relationships)
key: Unique identifier used by provider key: Unique provider key - PRIMARY IDENTIFIER for all lookups
name: Series name name: Display name of the series
site: Provider site URL site: Provider site URL
folder: Local filesystem path folder: Filesystem folder name (metadata only, not for lookups)
description: Optional series description description: Optional series description
status: Current status (ongoing, completed, etc.) status: Current status (ongoing, completed, etc.)
total_episodes: Total number of episodes total_episodes: Total number of episodes
cover_url: URL to series cover image cover_url: URL to series cover image
episodes: Relationship to Episode models episodes: Relationship to Episode models (via id foreign key)
download_items: Relationship to DownloadQueueItem models download_items: Relationship to DownloadQueueItem models (via id foreign key)
created_at: Creation timestamp (from TimestampMixin) created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin) updated_at: Last update timestamp (from TimestampMixin)
Note:
All database relationships use `id` (primary key), not `key` or `folder`.
Use `get_by_key()` in AnimeSeriesService for lookups.
""" """
__tablename__ = "anime_series" __tablename__ = "anime_series"
@ -63,7 +74,7 @@ class AnimeSeries(Base, TimestampMixin):
# Core identification # Core identification
key: Mapped[str] = mapped_column( key: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True, String(255), unique=True, nullable=False, index=True,
doc="Unique provider key" doc="Unique provider key - PRIMARY IDENTIFIER for all lookups"
) )
name: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(
String(500), nullable=False, index=True, String(500), nullable=False, index=True,
@ -75,7 +86,7 @@ class AnimeSeries(Base, TimestampMixin):
) )
folder: Mapped[str] = mapped_column( folder: Mapped[str] = mapped_column(
String(1000), nullable=False, String(1000), nullable=False,
doc="Local filesystem path" doc="Filesystem folder name - METADATA ONLY, not for lookups"
) )
# Metadata # Metadata

View File

@ -43,6 +43,11 @@ class AnimeSeriesService:
Provides methods for creating, reading, updating, and deleting anime series Provides methods for creating, reading, updating, and deleting anime series
with support for both async and sync database sessions. with support for both async and sync database sessions.
Series Identifier Convention:
- Use `get_by_key()` for lookups by provider key (primary identifier)
- Use `get_by_id()` for lookups by database primary key (internal)
- Never use `folder` for identification - it's metadata only
""" """
@staticmethod @staticmethod
@ -115,12 +120,19 @@ class AnimeSeriesService:
async def get_by_key(db: AsyncSession, key: str) -> Optional[AnimeSeries]: async def get_by_key(db: AsyncSession, key: str) -> Optional[AnimeSeries]:
"""Get anime series by provider key. """Get anime series by provider key.
This is the PRIMARY lookup method for series identification.
Use this method instead of get_by_id() when looking up by
the provider-assigned unique key.
Args: Args:
db: Database session db: Database session
key: Unique provider key key: Unique provider key (e.g., "attack-on-titan")
Returns: Returns:
AnimeSeries instance or None if not found AnimeSeries instance or None if not found
Note:
Do NOT use folder for lookups - it's metadata only.
""" """
result = await db.execute( result = await db.execute(
select(AnimeSeries).where(AnimeSeries.key == key) select(AnimeSeries).where(AnimeSeries.key == key)

View File

@ -1,9 +1,23 @@
"""Anime Pydantic models for the Aniworld web application.
This module defines request/response models used by the anime API
and services. Models are focused on serialization, validation,
and OpenAPI documentation.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
"""
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field, HttpUrl from pydantic import BaseModel, Field, HttpUrl, field_validator
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
class EpisodeInfo(BaseModel): class EpisodeInfo(BaseModel):
@ -31,10 +45,30 @@ class MissingEpisodeInfo(BaseModel):
class AnimeSeriesResponse(BaseModel): class AnimeSeriesResponse(BaseModel):
"""Response model for a series with metadata and episodes.""" """Response model for a series with metadata and episodes.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
This is the unique key used for all lookups and operations.
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
Used only for display and filesystem operations.
"""
id: str = Field(..., description="Unique series identifier") key: str = Field(
...,
description=(
"Series key (primary identifier) - provider-assigned URL-safe "
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
title: str = Field(..., description="Series title") title: str = Field(..., description="Series title")
folder: Optional[str] = Field(
None,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
)
)
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles") alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
description: Optional[str] = Field(None, description="Short series description") description: Optional[str] = Field(None, description="Short series description")
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known") total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
@ -42,20 +76,56 @@ class AnimeSeriesResponse(BaseModel):
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges") missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL") thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
@field_validator('key', mode='before')
@classmethod
def normalize_key(cls, v: str) -> str:
"""Normalize key to lowercase."""
if isinstance(v, str):
return v.lower().strip()
return v
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
"""Request payload for searching series.""" """Request payload for searching series."""
query: str = Field(..., min_length=1) query: str = Field(..., min_length=1, description="Search query string")
limit: int = Field(10, ge=1, le=100) limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
include_adult: bool = Field(False) include_adult: bool = Field(False, description="Include adult content in results")
class SearchResult(BaseModel): class SearchResult(BaseModel):
"""Search result item for a series discovery endpoint.""" """Search result item for a series discovery endpoint.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
This is the unique key used for all lookups and operations.
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
Used only for display and filesystem operations.
"""
id: str key: str = Field(
title: str ...,
snippet: Optional[str] = None description=(
thumbnail: Optional[HttpUrl] = None "Series key (primary identifier) - provider-assigned URL-safe "
score: Optional[float] = None "key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
title: str = Field(..., description="Series title")
folder: Optional[str] = Field(
None,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
)
)
snippet: Optional[str] = Field(None, description="Short description or snippet")
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
@field_validator('key', mode='before')
@classmethod
def normalize_key(cls, v: str) -> str:
"""Normalize key to lowercase."""
if isinstance(v, str):
return v.lower().strip()
return v

View File

@ -63,14 +63,33 @@ class DownloadProgress(BaseModel):
class DownloadItem(BaseModel): class DownloadItem(BaseModel):
"""Represents a single download item in the queue.""" """Represents a single download item in the queue.
Note on identifiers:
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used for
all lookups and identification. This is the primary identifier.
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
used only for filesystem operations. This is metadata, not an identifier.
"""
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 key (primary identifier) - provider-assigned URL-safe "
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
serie_folder: str = Field(
...,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
)
)
serie_name: str = Field(
..., min_length=1, description="Series display name"
) )
serie_name: str = Field(..., min_length=1, description="Series name")
episode: EpisodeIdentifier = Field( episode: EpisodeIdentifier = Field(
..., description="Episode identification" ..., description="Episode identification"
) )
@ -107,6 +126,14 @@ class DownloadItem(BaseModel):
None, description="Source URL for download" None, description="Source URL for download"
) )
@field_validator('serie_id', mode='before')
@classmethod
def normalize_serie_id(cls, v: str) -> str:
"""Normalize serie_id (key) to lowercase and stripped."""
if isinstance(v, str):
return v.lower().strip()
return v
class QueueStatus(BaseModel): class QueueStatus(BaseModel):
"""Overall status of the download queue system.""" """Overall status of the download queue system."""
@ -158,14 +185,31 @@ 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.
Note on identifiers:
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used as
the primary identifier for all operations. This is the unique key.
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
used only for storing downloaded files. This is metadata.
"""
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 key (primary identifier) - provider-assigned URL-safe "
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
serie_folder: str = Field(
...,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
)
) )
serie_name: str = Field( serie_name: str = Field(
..., min_length=1, description="Series name for display" ..., min_length=1, description="Series display name"
) )
episodes: List[EpisodeIdentifier] = Field( episodes: List[EpisodeIdentifier] = Field(
..., description="List of episodes to download" ..., description="List of episodes to download"
@ -182,6 +226,14 @@ class DownloadRequest(BaseModel):
return v.upper() return v.upper()
return v return v
@field_validator('serie_id', mode='before')
@classmethod
def normalize_serie_id(cls, v: str) -> str:
"""Normalize serie_id (key) to lowercase and stripped."""
if isinstance(v, str):
return v.lower().strip()
return v
class DownloadResponse(BaseModel): class DownloadResponse(BaseModel):
"""Response after adding items to the download queue.""" """Response after adding items to the download queue."""

View File

@ -3,6 +3,15 @@
This module defines message models for WebSocket communication between This module defines message models for WebSocket communication between
the server and clients. Models ensure type safety and provide validation the server and clients. Models ensure type safety and provide validation
for real-time updates. for real-time updates.
Series Identifier Convention:
- `key`: Primary identifier for series (provider-assigned, URL-safe)
e.g., "attack-on-titan"
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
All series-related WebSocket events should include `key` as the primary
identifier in their data payload. The `folder` field is optional and
used for display purposes only.
""" """
from __future__ import annotations from __future__ import annotations
@ -65,7 +74,16 @@ class WebSocketMessage(BaseModel):
class DownloadProgressMessage(BaseModel): class DownloadProgressMessage(BaseModel):
"""Download progress update message.""" """Download progress update message.
Data payload should include:
- download_id: Unique download identifier
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- percent: Download progress percentage
- speed_mbps: Download speed
- eta_seconds: Estimated time remaining
"""
type: WebSocketMessageType = Field( type: WebSocketMessageType = Field(
default=WebSocketMessageType.DOWNLOAD_PROGRESS, default=WebSocketMessageType.DOWNLOAD_PROGRESS,
@ -77,12 +95,22 @@ class DownloadProgressMessage(BaseModel):
) )
data: Dict[str, Any] = Field( data: Dict[str, Any] = Field(
..., ...,
description="Progress data including download_id, percent, speed, eta", description=(
"Progress data including download_id, key (series identifier), "
"folder (display), percent, speed_mbps, eta_seconds"
),
) )
class DownloadCompleteMessage(BaseModel): class DownloadCompleteMessage(BaseModel):
"""Download completion message.""" """Download completion message.
Data payload should include:
- download_id: Unique download identifier
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- file_path: Path to downloaded file
"""
type: WebSocketMessageType = Field( type: WebSocketMessageType = Field(
default=WebSocketMessageType.DOWNLOAD_COMPLETE, default=WebSocketMessageType.DOWNLOAD_COMPLETE,
@ -93,12 +121,23 @@ class DownloadCompleteMessage(BaseModel):
description="ISO 8601 timestamp", description="ISO 8601 timestamp",
) )
data: Dict[str, Any] = Field( data: Dict[str, Any] = Field(
..., description="Completion data including download_id, file_path" ...,
description=(
"Completion data including download_id, key (series identifier), "
"folder (display), file_path"
),
) )
class DownloadFailedMessage(BaseModel): class DownloadFailedMessage(BaseModel):
"""Download failure message.""" """Download failure message.
Data payload should include:
- download_id: Unique download identifier
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- error_message: Description of the failure
"""
type: WebSocketMessageType = Field( type: WebSocketMessageType = Field(
default=WebSocketMessageType.DOWNLOAD_FAILED, default=WebSocketMessageType.DOWNLOAD_FAILED,
@ -109,7 +148,11 @@ class DownloadFailedMessage(BaseModel):
description="ISO 8601 timestamp", description="ISO 8601 timestamp",
) )
data: Dict[str, Any] = Field( data: Dict[str, Any] = Field(
..., description="Error data including download_id, error_message" ...,
description=(
"Error data including download_id, key (series identifier), "
"folder (display), error_message"
),
) )

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from functools import lru_cache from functools import lru_cache
from typing import List, Optional from typing import Optional
import structlog import structlog
@ -23,9 +23,13 @@ class AnimeServiceError(Exception):
class AnimeService: class AnimeService:
"""Wraps SeriesApp for use in the FastAPI web layer. """Wraps SeriesApp for use in the FastAPI web layer.
This service provides a clean interface to anime operations, using 'key'
as the primary series identifier (provider-assigned, URL-safe) and 'folder'
as metadata only (filesystem folder name for display purposes).
- SeriesApp methods are now async, no need for threadpool - SeriesApp methods are now async, no need for threadpool
- Subscribes to SeriesApp events for progress tracking - Subscribes to SeriesApp events for progress tracking
- Exposes async methods - Exposes async methods using 'key' for all series identification
- Adds simple in-memory caching for read operations - Adds simple in-memory caching for read operations
""" """
@ -51,8 +55,12 @@ class AnimeService:
def _on_download_status(self, args) -> None: def _on_download_status(self, args) -> None:
"""Handle download status events from SeriesApp. """Handle download status events from SeriesApp.
Events include both 'key' (primary identifier) and 'serie_folder'
(metadata for display and filesystem operations).
Args: Args:
args: DownloadStatusEventArgs from SeriesApp args: DownloadStatusEventArgs from SeriesApp containing key,
serie_folder, season, episode, status, and progress info
""" """
try: try:
# Get event loop - try running loop first, then stored loop # Get event loop - try running loop first, then stored loop
@ -74,7 +82,10 @@ class AnimeService:
progress_id = ( progress_id = (
args.item_id args.item_id
if args.item_id if args.item_id
else f"download_{args.serie_folder}_{args.season}_{args.episode}" else (
f"download_{args.serie_folder}_"
f"{args.season}_{args.episode}"
)
) )
# Map SeriesApp download events to progress service # Map SeriesApp download events to progress service
@ -85,7 +96,11 @@ class AnimeService:
progress_type=ProgressType.DOWNLOAD, progress_type=ProgressType.DOWNLOAD,
title=f"Downloading {args.serie_folder}", title=f"Downloading {args.serie_folder}",
message=f"S{args.season:02d}E{args.episode:02d}", message=f"S{args.season:02d}E{args.episode:02d}",
metadata={"item_id": args.item_id} if args.item_id else None, metadata=(
{"item_id": args.item_id}
if args.item_id
else None
),
), ),
loop loop
) )
@ -136,8 +151,12 @@ class AnimeService:
def _on_scan_status(self, args) -> None: def _on_scan_status(self, args) -> None:
"""Handle scan status events from SeriesApp. """Handle scan status events from SeriesApp.
Events include both 'key' (primary identifier) and 'folder'
(metadata for display purposes).
Args: Args:
args: ScanStatusEventArgs from SeriesApp args: ScanStatusEventArgs from SeriesApp containing key,
folder, current, total, status, and progress info
""" """
try: try:
scan_id = "library_scan" scan_id = "library_scan"
@ -206,22 +225,33 @@ class AnimeService:
logger.error("Error handling scan status event", error=str(exc)) logger.error("Error handling scan status event", error=str(exc))
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def _cached_list_missing(self) -> List[dict]: def _cached_list_missing(self) -> list[dict]:
# Synchronous cached call - SeriesApp.series_list is populated # Synchronous cached call - SeriesApp.series_list is populated
# during initialization # during initialization
try: try:
series = self._app.series_list series = self._app.series_list
# normalize to simple dicts # normalize to simple dicts
return [ result: list[dict] = []
s.to_dict() if hasattr(s, "to_dict") else s for s in series:
for s in series if hasattr(s, "to_dict"):
] result.append(s.to_dict())
else:
result.append(s) # type: ignore
return result
except Exception: except Exception:
logger.exception("Failed to get missing episodes list") logger.exception("Failed to get missing episodes list")
raise raise
async def list_missing(self) -> List[dict]: async def list_missing(self) -> list[dict]:
"""Return list of series with missing episodes.""" """Return list of series with missing episodes.
Each series dictionary includes 'key' as the primary identifier
and 'folder' as metadata for display purposes.
Returns:
List of series dictionaries with 'key', 'name', 'site',
'folder', and 'episodeDict' fields
"""
try: try:
# series_list is already populated, just access it # series_list is already populated, just access it
return self._cached_list_missing() return self._cached_list_missing()
@ -231,14 +261,15 @@ class AnimeService:
logger.exception("list_missing failed") logger.exception("list_missing failed")
raise AnimeServiceError("Failed to list missing series") from exc raise AnimeServiceError("Failed to list missing series") from exc
async def search(self, query: str) -> List[dict]: async def search(self, query: str) -> list[dict]:
"""Search for series using underlying loader. """Search for series using underlying provider.
Args: Args:
query: Search query string query: Search query string
Returns: Returns:
List of search results as dictionaries List of search results as dictionaries, each containing 'key'
as the primary identifier and other metadata fields
""" """
if not query: if not query:
return [] return []
@ -251,10 +282,14 @@ class AnimeService:
raise AnimeServiceError("Search failed") from exc raise AnimeServiceError("Search failed") from exc
async def rescan(self) -> None: async def rescan(self) -> None:
"""Trigger a re-scan. """Trigger a re-scan of the anime library directory.
The SeriesApp now handles progress tracking via events which are Scans the filesystem for anime series and updates the series list.
The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers. forwarded to the ProgressService through event handlers.
All series are identified by their 'key' (provider identifier),
with 'folder' stored as metadata.
""" """
try: try:
# Store event loop for event handlers # Store event loop for event handlers
@ -281,19 +316,30 @@ class AnimeService:
key: str, key: str,
item_id: Optional[str] = None, item_id: Optional[str] = None,
) -> bool: ) -> bool:
"""Start a download. """Start a download for a specific episode.
The SeriesApp now handles progress tracking via events which are The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers. forwarded to the ProgressService through event handlers.
Args: Args:
serie_folder: Serie folder name serie_folder: Serie folder name (metadata only, used for
filesystem operations and display)
season: Season number season: Season number
episode: Episode number episode: Episode number
key: Serie key key: Serie unique identifier (primary identifier for series
lookup, provider-assigned)
item_id: Optional download queue item ID for tracking item_id: Optional download queue item ID for tracking
Returns True on success or raises AnimeServiceError on failure. Returns:
True on success
Raises:
AnimeServiceError: If download fails
Note:
The 'key' parameter is the primary identifier used for all
series lookups. The 'serie_folder' is only used for filesystem
path construction and display purposes.
""" """
try: try:
# Store event loop for event handlers # Store event loop for event handlers

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

@ -51,6 +51,10 @@ class ProgressUpdate:
percent: Completion percentage (0-100) percent: Completion percentage (0-100)
current: Current progress value current: Current progress value
total: Total progress value total: Total progress value
key: Optional series identifier (provider key, e.g., 'attack-on-titan')
Used as the primary identifier for series-related operations
folder: Optional series folder name (e.g., 'Attack on Titan (2013)')
Used for display and filesystem operations only
metadata: Additional metadata metadata: Additional metadata
started_at: When operation started started_at: When operation started
updated_at: When last updated updated_at: When last updated
@ -64,13 +68,20 @@ class ProgressUpdate:
percent: float = 0.0 percent: float = 0.0
current: int = 0 current: int = 0
total: int = 0 total: int = 0
key: Optional[str] = None
folder: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert progress update to dictionary.""" """Convert progress update to dictionary.
return {
Returns:
Dictionary representation with all fields including optional
key (series identifier) and folder (display metadata).
"""
result = {
"id": self.id, "id": self.id,
"type": self.type.value, "type": self.type.value,
"status": self.status.value, "status": self.status.value,
@ -84,6 +95,14 @@ class ProgressUpdate:
"started_at": self.started_at.isoformat(), "started_at": self.started_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
# Include optional series identifier fields
if self.key is not None:
result["key"] = self.key
if self.folder is not None:
result["folder"] = self.folder
return result
@dataclass @dataclass
@ -220,6 +239,8 @@ class ProgressService:
title: str, title: str,
total: int = 0, total: int = 0,
message: str = "", message: str = "",
key: Optional[str] = None,
folder: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
) -> ProgressUpdate: ) -> ProgressUpdate:
"""Start a new progress operation. """Start a new progress operation.
@ -230,6 +251,10 @@ class ProgressService:
title: Human-readable title title: Human-readable title
total: Total items/bytes to process total: Total items/bytes to process
message: Initial message message: Initial message
key: Optional series identifier (provider key)
Used as primary identifier for series-related operations
folder: Optional series folder name
Used for display and filesystem operations only
metadata: Additional metadata metadata: Additional metadata
Returns: Returns:
@ -251,6 +276,8 @@ class ProgressService:
title=title, title=title,
message=message, message=message,
total=total, total=total,
key=key,
folder=folder,
metadata=metadata or {}, metadata=metadata or {},
) )
@ -261,6 +288,8 @@ class ProgressService:
progress_id=progress_id, progress_id=progress_id,
type=progress_type.value, type=progress_type.value,
title=title, title=title,
key=key,
folder=folder,
) )
# Emit event to subscribers # Emit event to subscribers
@ -281,6 +310,8 @@ class ProgressService:
current: Optional[int] = None, current: Optional[int] = None,
total: Optional[int] = None, total: Optional[int] = None,
message: Optional[str] = None, message: Optional[str] = None,
key: Optional[str] = None,
folder: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
force_broadcast: bool = False, force_broadcast: bool = False,
) -> ProgressUpdate: ) -> ProgressUpdate:
@ -291,6 +322,8 @@ class ProgressService:
current: Current progress value current: Current progress value
total: Updated total value total: Updated total value
message: Updated message message: Updated message
key: Optional series identifier (provider key)
folder: Optional series folder name
metadata: Additional metadata to merge metadata: Additional metadata to merge
force_broadcast: Force broadcasting even for small changes force_broadcast: Force broadcasting even for small changes
@ -316,6 +349,10 @@ class ProgressService:
update.total = total update.total = total
if message is not None: if message is not None:
update.message = message update.message = message
if key is not None:
update.key = key
if folder is not None:
update.folder = folder
if metadata: if metadata:
update.metadata.update(metadata) update.metadata.update(metadata)

View File

@ -0,0 +1,660 @@
"""Scan service for managing anime library scan operations.
This module provides a service layer for scanning the anime library directory,
identifying missing episodes, and broadcasting scan progress updates.
All scan operations use 'key' as the primary series identifier.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
import structlog
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionCallback,
CompletionContext,
ErrorCallback,
ErrorContext,
OperationType,
ProgressCallback,
ProgressContext,
ProgressPhase,
)
from src.server.services.progress_service import (
ProgressService,
ProgressStatus,
ProgressType,
get_progress_service,
)
logger = structlog.get_logger(__name__)
class ScanServiceError(Exception):
"""Service-level exception for scan operations."""
class ScanProgress:
"""Represents the current state of a scan operation.
Attributes:
scan_id: Unique identifier for this scan operation
status: Current status (started, in_progress, completed, failed)
current: Number of folders processed
total: Total number of folders to process
percentage: Completion percentage
message: Human-readable progress message
key: Current series key being scanned (if applicable)
folder: Current folder being scanned (metadata only)
started_at: When the scan started
updated_at: When the progress was last updated
series_found: Number of series found with missing episodes
errors: List of error messages encountered
"""
def __init__(self, scan_id: str):
"""Initialize scan progress.
Args:
scan_id: Unique identifier for this scan
"""
self.scan_id = scan_id
self.status = "started"
self.current = 0
self.total = 0
self.percentage = 0.0
self.message = "Initializing scan..."
self.key: Optional[str] = None
self.folder: Optional[str] = None
self.started_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
self.series_found = 0
self.errors: List[str] = []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization.
Returns:
Dictionary representation with 'key' as primary identifier
and 'folder' as metadata only.
"""
result = {
"scan_id": self.scan_id,
"status": self.status,
"current": self.current,
"total": self.total,
"percentage": round(self.percentage, 2),
"message": self.message,
"started_at": self.started_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"series_found": self.series_found,
"errors": self.errors,
}
# Include optional series identifiers
if self.key is not None:
result["key"] = self.key
if self.folder is not None:
result["folder"] = self.folder
return result
class ScanServiceProgressCallback(ProgressCallback):
"""Callback implementation for forwarding scan progress to ScanService.
This callback receives progress events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_progress(self, context: ProgressContext) -> None:
"""Handle progress update from SerieScanner.
Args:
context: Progress context with key and folder information
"""
self._scan_progress.current = context.current
self._scan_progress.total = context.total
self._scan_progress.percentage = context.percentage
self._scan_progress.message = context.message
self._scan_progress.key = context.key
self._scan_progress.folder = context.folder
self._scan_progress.updated_at = datetime.now(timezone.utc)
if context.phase == ProgressPhase.STARTING:
self._scan_progress.status = "started"
elif context.phase == ProgressPhase.IN_PROGRESS:
self._scan_progress.status = "in_progress"
elif context.phase == ProgressPhase.COMPLETED:
self._scan_progress.status = "completed"
elif context.phase == ProgressPhase.FAILED:
self._scan_progress.status = "failed"
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_progress_update(self._scan_progress),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanServiceErrorCallback(ErrorCallback):
"""Callback implementation for handling scan errors.
This callback receives error events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_error(self, context: ErrorContext) -> None:
"""Handle error from SerieScanner.
Args:
context: Error context with key and folder information
"""
error_msg = context.message
if context.folder:
error_msg = f"[{context.folder}] {error_msg}"
self._scan_progress.errors.append(error_msg)
self._scan_progress.updated_at = datetime.now(timezone.utc)
logger.warning(
"Scan error",
key=context.key,
folder=context.folder,
error=str(context.error),
recoverable=context.recoverable,
)
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_scan_error(
self._scan_progress,
context,
),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanServiceCompletionCallback(CompletionCallback):
"""Callback implementation for handling scan completion.
This callback receives completion events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_completion(self, context: CompletionContext) -> None:
"""Handle completion from SerieScanner.
Args:
context: Completion context with statistics
"""
self._scan_progress.status = "completed" if context.success else "failed"
self._scan_progress.message = context.message
self._scan_progress.updated_at = datetime.now(timezone.utc)
if context.statistics:
self._scan_progress.series_found = context.statistics.get(
"series_found", 0
)
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_scan_completion(
self._scan_progress,
context,
),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanService:
"""Manages anime library scan operations.
Features:
- Trigger library scans
- Track scan progress in real-time
- Use 'key' as primary series identifier
- Broadcast scan progress via WebSocket
- Handle scan errors gracefully
- Provide scan history and statistics
All operations use 'key' (provider-assigned, URL-safe identifier)
as the primary series identifier. 'folder' is used only as metadata
for display and filesystem operations.
"""
def __init__(
self,
progress_service: Optional[ProgressService] = None,
):
"""Initialize the scan service.
Args:
progress_service: Optional progress service for tracking
"""
self._progress_service = progress_service or get_progress_service()
# Current scan state
self._current_scan: Optional[ScanProgress] = None
self._is_scanning = False
# Scan history (limited size)
self._scan_history: List[ScanProgress] = []
self._max_history_size = 10
# Event handlers for scan events
self._scan_event_handlers: List[
Callable[[Dict[str, Any]], None]
] = []
# Lock for thread-safe operations
self._lock = asyncio.Lock()
logger.info("ScanService initialized")
def subscribe_to_scan_events(
self,
handler: Callable[[Dict[str, Any]], None],
) -> None:
"""Subscribe to scan events.
Args:
handler: Function to call when scan events occur.
Receives a dictionary with event data including
'key' as the primary identifier.
"""
self._scan_event_handlers.append(handler)
logger.debug("Scan event handler subscribed")
def unsubscribe_from_scan_events(
self,
handler: Callable[[Dict[str, Any]], None],
) -> None:
"""Unsubscribe from scan events.
Args:
handler: Handler function to remove
"""
try:
self._scan_event_handlers.remove(handler)
logger.debug("Scan event handler unsubscribed")
except ValueError:
logger.warning("Handler not found for unsubscribe")
async def _emit_scan_event(self, event_data: Dict[str, Any]) -> None:
"""Emit scan event to all subscribers.
Args:
event_data: Event data to broadcast, includes 'key' as
primary identifier and 'folder' as metadata
"""
for handler in self._scan_event_handlers:
try:
if asyncio.iscoroutinefunction(handler):
await handler(event_data)
else:
handler(event_data)
except Exception as e:
logger.error(
"Scan event handler error",
error=str(e),
)
@property
def is_scanning(self) -> bool:
"""Check if a scan is currently in progress."""
return self._is_scanning
@property
def current_scan(self) -> Optional[ScanProgress]:
"""Get the current scan progress."""
return self._current_scan
async def start_scan(
self,
scanner_factory: Callable[..., Any],
) -> str:
"""Start a new library scan.
Args:
scanner_factory: Factory function that creates a SerieScanner.
The factory should accept a callback_manager parameter.
Returns:
Scan ID for tracking
Raises:
ScanServiceError: If a scan is already in progress
Note:
The scan uses 'key' as the primary identifier for all series.
The 'folder' field is included only as metadata for display.
"""
async with self._lock:
if self._is_scanning:
raise ScanServiceError("A scan is already in progress")
self._is_scanning = True
scan_id = str(uuid.uuid4())
scan_progress = ScanProgress(scan_id)
self._current_scan = scan_progress
logger.info("Starting library scan", scan_id=scan_id)
# Start progress tracking
try:
await self._progress_service.start_progress(
progress_id=f"scan_{scan_id}",
progress_type=ProgressType.SCAN,
title="Library Scan",
message="Initializing scan...",
)
except Exception as e:
logger.error("Failed to start progress tracking", error=str(e))
# Emit scan started event
await self._emit_scan_event({
"type": "scan_started",
"scan_id": scan_id,
"message": "Library scan started",
})
return scan_id
def create_callback_manager(
self,
scan_progress: Optional[ScanProgress] = None,
) -> CallbackManager:
"""Create a callback manager for scan operations.
Args:
scan_progress: Optional scan progress to use. If None,
uses current scan progress.
Returns:
CallbackManager configured with scan callbacks
"""
progress = scan_progress or self._current_scan
if not progress:
progress = ScanProgress(str(uuid.uuid4()))
self._current_scan = progress
callback_manager = CallbackManager()
# Register callbacks
callback_manager.register_progress_callback(
ScanServiceProgressCallback(self, progress)
)
callback_manager.register_error_callback(
ScanServiceErrorCallback(self, progress)
)
callback_manager.register_completion_callback(
ScanServiceCompletionCallback(self, progress)
)
return callback_manager
async def _handle_progress_update(
self,
scan_progress: ScanProgress,
) -> None:
"""Handle a scan progress update.
Args:
scan_progress: Updated scan progress with 'key' as identifier
"""
# Update progress service
try:
await self._progress_service.update_progress(
progress_id=f"scan_{scan_progress.scan_id}",
current=scan_progress.current,
total=scan_progress.total,
message=scan_progress.message,
key=scan_progress.key,
folder=scan_progress.folder,
)
except Exception as e:
logger.debug("Progress update skipped", error=str(e))
# Emit progress event with key as primary identifier
await self._emit_scan_event({
"type": "scan_progress",
"data": scan_progress.to_dict(),
})
async def _handle_scan_error(
self,
scan_progress: ScanProgress,
error_context: ErrorContext,
) -> None:
"""Handle a scan error.
Args:
scan_progress: Current scan progress
error_context: Error context with key and folder metadata
"""
# Emit error event with key as primary identifier
await self._emit_scan_event({
"type": "scan_error",
"scan_id": scan_progress.scan_id,
"key": error_context.key,
"folder": error_context.folder,
"error": str(error_context.error),
"message": error_context.message,
"recoverable": error_context.recoverable,
})
async def _handle_scan_completion(
self,
scan_progress: ScanProgress,
completion_context: CompletionContext,
) -> None:
"""Handle scan completion.
Args:
scan_progress: Final scan progress
completion_context: Completion context with statistics
"""
async with self._lock:
self._is_scanning = False
# Add to history
self._scan_history.append(scan_progress)
if len(self._scan_history) > self._max_history_size:
self._scan_history.pop(0)
# Complete progress tracking
try:
if completion_context.success:
await self._progress_service.complete_progress(
progress_id=f"scan_{scan_progress.scan_id}",
message=completion_context.message,
)
else:
await self._progress_service.fail_progress(
progress_id=f"scan_{scan_progress.scan_id}",
error_message=completion_context.message,
)
except Exception as e:
logger.debug("Progress completion skipped", error=str(e))
# Emit completion event
await self._emit_scan_event({
"type": "scan_completed" if completion_context.success else "scan_failed",
"scan_id": scan_progress.scan_id,
"success": completion_context.success,
"message": completion_context.message,
"statistics": completion_context.statistics,
"data": scan_progress.to_dict(),
})
logger.info(
"Scan completed",
scan_id=scan_progress.scan_id,
success=completion_context.success,
series_found=scan_progress.series_found,
errors_count=len(scan_progress.errors),
)
async def cancel_scan(self) -> bool:
"""Cancel the current scan if one is in progress.
Returns:
True if scan was cancelled, False if no scan in progress
"""
async with self._lock:
if not self._is_scanning:
return False
self._is_scanning = False
if self._current_scan:
self._current_scan.status = "cancelled"
self._current_scan.message = "Scan cancelled by user"
self._current_scan.updated_at = datetime.now(timezone.utc)
# Add to history
self._scan_history.append(self._current_scan)
if len(self._scan_history) > self._max_history_size:
self._scan_history.pop(0)
# Emit cancellation event
if self._current_scan:
await self._emit_scan_event({
"type": "scan_cancelled",
"scan_id": self._current_scan.scan_id,
"message": "Scan cancelled by user",
})
# Update progress service
try:
await self._progress_service.fail_progress(
progress_id=f"scan_{self._current_scan.scan_id}",
error_message="Scan cancelled by user",
)
except Exception as e:
logger.debug("Progress cancellation skipped", error=str(e))
logger.info("Scan cancelled")
return True
async def get_scan_status(self) -> Dict[str, Any]:
"""Get the current scan status.
Returns:
Dictionary with scan status information, including 'key'
as the primary series identifier for any current scan.
"""
return {
"is_scanning": self._is_scanning,
"current_scan": (
self._current_scan.to_dict() if self._current_scan else None
),
}
async def get_scan_history(
self,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Get scan history.
Args:
limit: Maximum number of history entries to return
Returns:
List of scan history entries, newest first.
Each entry includes 'key' as the primary identifier.
"""
history = self._scan_history[-limit:]
history.reverse() # Newest first
return [scan.to_dict() for scan in history]
# Module-level singleton instance
_scan_service: Optional[ScanService] = None
def get_scan_service() -> ScanService:
"""Get the singleton ScanService instance.
Returns:
The ScanService singleton
"""
global _scan_service
if _scan_service is None:
_scan_service = ScanService()
return _scan_service
def reset_scan_service() -> None:
"""Reset the singleton ScanService instance.
Primarily used for testing to ensure clean state.
"""
global _scan_service
_scan_service = None

View File

@ -3,6 +3,15 @@
This module provides a comprehensive WebSocket manager for handling This module provides a comprehensive WebSocket manager for handling
real-time updates, connection management, room-based messaging, and real-time updates, connection management, room-based messaging, and
broadcast functionality for the Aniworld web application. broadcast functionality for the Aniworld web application.
Series Identifier Convention:
- `key`: Primary identifier for series (provider-assigned, URL-safe)
e.g., "attack-on-titan"
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
All broadcast methods that handle series-related data should include `key`
as the primary identifier in the message payload. The `folder` field is
optional and used for display purposes only.
""" """
from __future__ import annotations from __future__ import annotations
@ -363,6 +372,16 @@ class WebSocketService:
Args: Args:
download_id: The download item identifier download_id: The download item identifier
progress_data: Progress information (percent, speed, etc.) progress_data: Progress information (percent, speed, etc.)
Should include 'key' (series identifier) and
optionally 'folder' (display name)
Note:
The progress_data should include:
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- percent: Download progress percentage
- speed_mbps: Download speed
- eta_seconds: Estimated time remaining
""" """
message = { message = {
"type": "download_progress", "type": "download_progress",
@ -382,6 +401,14 @@ class WebSocketService:
Args: Args:
download_id: The download item identifier download_id: The download item identifier
result_data: Download result information result_data: Download result information
Should include 'key' (series identifier) and
optionally 'folder' (display name)
Note:
The result_data should include:
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- file_path: Path to the downloaded file
""" """
message = { message = {
"type": "download_complete", "type": "download_complete",
@ -401,6 +428,14 @@ class WebSocketService:
Args: Args:
download_id: The download item identifier download_id: The download item identifier
error_data: Error information error_data: Error information
Should include 'key' (series identifier) and
optionally 'folder' (display name)
Note:
The error_data should include:
- key: Series identifier (primary, e.g., 'attack-on-titan')
- folder: Series folder name (optional, display only)
- error_message: Description of the failure
""" """
message = { message = {
"type": "download_failed", "type": "download_failed",
@ -412,7 +447,9 @@ class WebSocketService:
} }
await self._manager.broadcast_to_room(message, "downloads") await self._manager.broadcast_to_room(message, "downloads")
async def broadcast_queue_status(self, status_data: Dict[str, Any]) -> None: async def broadcast_queue_status(
self, status_data: Dict[str, Any]
) -> None:
"""Broadcast queue status update to all clients. """Broadcast queue status update to all clients.
Args: Args:

View File

@ -3,13 +3,25 @@ Template integration utilities for FastAPI application.
This module provides utilities for template rendering with common context This module provides utilities for template rendering with common context
and helper functions. and helper functions.
Series Identifier Convention:
- `key`: Primary identifier for all series operations
(URL-safe, e.g., "attack-on-titan")
- `folder`: Metadata only for filesystem operations and display
(e.g., "Attack on Titan (2013)")
All template helpers that handle series data use `key` for identification and
provide `folder` as display metadata only.
""" """
import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastapi import Request from fastapi import Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
# Configure templates directory # Configure templates directory
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@ -82,15 +94,124 @@ def validate_template_exists(template_name: str) -> bool:
def list_available_templates() -> list[str]: def list_available_templates() -> list[str]:
""" """
Get list of all available template files. Get list of all available template files.
Returns: Returns:
List of template file names List of template file names
""" """
if not TEMPLATES_DIR.exists(): if not TEMPLATES_DIR.exists():
return [] return []
return [ return [
f.name f.name
for f in TEMPLATES_DIR.glob("*.html") for f in TEMPLATES_DIR.glob("*.html")
if f.is_file() if f.is_file()
] ]
def prepare_series_context(
series_data: List[Dict[str, Any]],
sort_by: str = "name"
) -> List[Dict[str, Any]]:
"""
Prepare series data for template rendering.
This function ensures series data follows the identifier convention:
- `key` is used as the primary identifier for all operations
- `folder` is included as metadata for display purposes
Args:
series_data: List of series dictionaries from the API
sort_by: Field to sort by ("name", "key", or "folder")
Returns:
List of series dictionaries prepared for template use
Raises:
ValueError: If series_data contains items without required 'key' field
Example:
>>> series = [
... {"key": "attack-on-titan", "name": "Attack on Titan",
... "folder": "Attack on Titan (2013)"},
... {"key": "one-piece", "name": "One Piece",
... "folder": "One Piece (1999)"}
... ]
>>> prepared = prepare_series_context(series, sort_by="name")
"""
if not series_data:
return []
prepared = []
for series in series_data:
if "key" not in series:
logger.warning(
"Series data missing 'key' field: %s",
series.get("name", "unknown")
)
continue
prepared_item = {
"key": series["key"],
"name": series.get("name", series["key"]),
"folder": series.get("folder", ""),
**{k: v for k, v in series.items()
if k not in ("key", "name", "folder")}
}
prepared.append(prepared_item)
# Sort by specified field
if sort_by in ("name", "key", "folder"):
prepared.sort(key=lambda x: x.get(sort_by, "").lower())
return prepared
def get_series_by_key(
series_data: List[Dict[str, Any]],
key: str
) -> Optional[Dict[str, Any]]:
"""
Find a series in the data by its key.
Uses `key` as the identifier (not `folder`) following the project
identifier convention.
Args:
series_data: List of series dictionaries
key: The unique series key to search for
Returns:
The series dictionary if found, None otherwise
Example:
>>> series = [{"key": "attack-on-titan", "name": "Attack on Titan"}]
>>> result = get_series_by_key(series, "attack-on-titan")
>>> result["name"]
'Attack on Titan'
"""
for series in series_data:
if series.get("key") == key:
return series
return None
def filter_series_by_missing_episodes(
series_data: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Filter series to only include those with missing episodes.
Args:
series_data: List of series dictionaries with 'missing_episodes' field
Returns:
Filtered list containing only series with missing episodes
Note:
Identification uses `key`, not `folder`.
"""
return [
series for series in series_data
if series.get("missing_episodes")
and any(episodes for episodes in series["missing_episodes"].values())
]

View File

@ -6,6 +6,7 @@ utilities for ensuring data integrity across the application.
""" """
import re import re
import warnings
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -438,6 +439,120 @@ def validate_series_name(name: str) -> str:
return name.strip() return name.strip()
def validate_series_key(key: str) -> str:
"""
Validate series key format.
Series keys are unique, provider-assigned, URL-safe identifiers.
They should be lowercase, use hyphens for word separation, and contain
only alphanumeric characters and hyphens.
Valid examples:
- "attack-on-titan"
- "one-piece"
- "naruto"
Invalid examples:
- "Attack On Titan" (uppercase, spaces)
- "attack_on_titan" (underscores)
- "attack on titan" (spaces)
- "" (empty)
Args:
key: Series key to validate
Returns:
Validated key (trimmed)
Raises:
ValueError: If key is invalid
"""
if not key or not isinstance(key, str):
raise ValueError("Series key must be a non-empty string")
key = key.strip()
if not key:
raise ValueError("Series key cannot be empty")
if len(key) > 255:
raise ValueError("Series key must be 255 characters or less")
# Key must be lowercase, alphanumeric with hyphens only
# Pattern: starts with letter/number, can contain letters, numbers, hyphens
# Cannot start or end with hyphen, no consecutive hyphens
if not re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', key):
raise ValueError(
"Series key must be lowercase, URL-safe, and use hyphens "
"for word separation (e.g., 'attack-on-titan'). "
"No spaces, underscores, or uppercase letters allowed."
)
return key
def validate_series_key_or_folder(
identifier: str, allow_folder: bool = True
) -> tuple[str, bool]:
"""
Validate an identifier that could be either a series key or folder.
.. deprecated:: 2.0.0
Folder-based identification is deprecated. Use series `key` only.
This function will require key format only in v3.0.0.
This function provides backward compatibility during the transition
from folder-based to key-based identification.
Args:
identifier: The identifier to validate (key or folder)
allow_folder: Whether to allow folder-style identifiers (default: True)
Returns:
Tuple of (validated_identifier, is_key) where is_key indicates
whether the identifier is a valid key format.
Raises:
ValueError: If identifier is empty or invalid
"""
if not identifier or not isinstance(identifier, str):
raise ValueError("Identifier must be a non-empty string")
identifier = identifier.strip()
if not identifier:
raise ValueError("Identifier cannot be empty")
# Try to validate as key first
try:
validate_series_key(identifier)
return identifier, True
except ValueError:
pass
# If not a valid key, check if folder format is allowed
if not allow_folder:
raise ValueError(
f"Invalid series key format: '{identifier}'. "
"Keys must be lowercase with hyphens (e.g., 'attack-on-titan')."
)
# Emit deprecation warning for folder-based identification
warnings.warn(
f"Folder-based identification for '{identifier}' is deprecated. "
"Use series key (lowercase with hyphens) instead. "
"Folder-based identification will be removed in v3.0.0.",
DeprecationWarning,
stacklevel=2
)
# Validate as folder (more permissive)
if len(identifier) > 1000:
raise ValueError("Identifier too long (max 1000 characters)")
return identifier, False
def validate_backup_name(name: str) -> str: def validate_backup_name(name: str) -> str:
""" """
Validate backup file name. Validate backup file name.

View File

@ -6,8 +6,8 @@
class AniWorldApp { class AniWorldApp {
constructor() { constructor() {
this.socket = null; this.socket = null;
this.selectedSeries = new Set(); this.selectedSeries = new Set(); // Uses 'key' as identifier
this.seriesData = []; this.seriesData = []; // Series objects with 'key' as primary identifier
this.filteredSeriesData = []; this.filteredSeriesData = [];
this.isConnected = false; this.isConnected = false;
this.isDownloading = false; this.isDownloading = false;
@ -674,26 +674,27 @@ class AniWorldApp {
grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join(''); grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join('');
// Bind checkbox events // Bind checkbox events - uses 'key' as identifier
grid.querySelectorAll('.series-checkbox').forEach(checkbox => { grid.querySelectorAll('.series-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', (e) => { checkbox.addEventListener('change', (e) => {
this.toggleSerieSelection(e.target.dataset.folder, e.target.checked); this.toggleSerieSelection(e.target.dataset.key, e.target.checked);
}); });
}); });
} }
createSerieCard(serie) { createSerieCard(serie) {
const isSelected = this.selectedSeries.has(serie.folder); // Use 'key' as the primary identifier for selection and data operations
const isSelected = this.selectedSeries.has(serie.key);
const hasMissingEpisodes = serie.missing_episodes > 0; const hasMissingEpisodes = serie.missing_episodes > 0;
const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes
return ` return `
<div class="series-card ${isSelected ? 'selected' : ''} ${hasMissingEpisodes ? 'has-missing' : 'complete'}" <div class="series-card ${isSelected ? 'selected' : ''} ${hasMissingEpisodes ? 'has-missing' : 'complete'}"
data-folder="${serie.folder}"> data-key="${serie.key}" data-folder="${serie.folder}">
<div class="series-card-header"> <div class="series-card-header">
<input type="checkbox" <input type="checkbox"
class="series-checkbox" class="series-checkbox"
data-folder="${serie.folder}" data-key="${serie.key}"
${isSelected ? 'checked' : ''} ${isSelected ? 'checked' : ''}
${!canBeSelected ? 'disabled' : ''}> ${!canBeSelected ? 'disabled' : ''}>
<div class="series-info"> <div class="series-info">
@ -718,20 +719,21 @@ class AniWorldApp {
`; `;
} }
toggleSerieSelection(folder, selected) { toggleSerieSelection(key, selected) {
// Only allow selection of series with missing episodes // Only allow selection of series with missing episodes
const serie = this.seriesData.find(s => s.folder === folder); // Use 'key' as the primary identifier for lookup and selection
const serie = this.seriesData.find(s => s.key === key);
if (!serie || serie.missing_episodes === 0) { if (!serie || serie.missing_episodes === 0) {
// Uncheck the checkbox if it was checked for a complete series // Uncheck the checkbox if it was checked for a complete series
const checkbox = document.querySelector(`input[data-folder="${folder}"]`); const checkbox = document.querySelector(`input[data-key="${key}"]`);
if (checkbox) checkbox.checked = false; if (checkbox) checkbox.checked = false;
return; return;
} }
if (selected) { if (selected) {
this.selectedSeries.add(folder); this.selectedSeries.add(key);
} else { } else {
this.selectedSeries.delete(folder); this.selectedSeries.delete(key);
} }
this.updateSelectionUI(); this.updateSelectionUI();
@ -742,45 +744,47 @@ class AniWorldApp {
const selectAllBtn = document.getElementById('select-all'); const selectAllBtn = document.getElementById('select-all');
// Get series that can be selected (have missing episodes) // Get series that can be selected (have missing episodes)
// Use 'key' as the primary identifier for selection tracking
const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData; const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0); const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
const selectableFolders = selectableSeries.map(serie => serie.folder); const selectableKeys = selectableSeries.map(serie => serie.key);
downloadBtn.disabled = this.selectedSeries.size === 0; downloadBtn.disabled = this.selectedSeries.size === 0;
const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder)); const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
if (this.selectedSeries.size === 0) { if (this.selectedSeries.size === 0) {
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>'; selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
} else if (allSelectableSelected && selectableFolders.length > 0) { } else if (allSelectableSelected && selectableKeys.length > 0) {
selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>'; selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>';
} else { } else {
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>'; selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
} }
// Update card appearances // Update card appearances using 'key' as identifier
document.querySelectorAll('.series-card').forEach(card => { document.querySelectorAll('.series-card').forEach(card => {
const folder = card.dataset.folder; const key = card.dataset.key;
const isSelected = this.selectedSeries.has(folder); const isSelected = this.selectedSeries.has(key);
card.classList.toggle('selected', isSelected); card.classList.toggle('selected', isSelected);
}); });
} }
toggleSelectAll() { toggleSelectAll() {
// Get series that can be selected (have missing episodes) // Get series that can be selected (have missing episodes)
// Use 'key' as the primary identifier for selection
const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData; const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0); const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
const selectableFolders = selectableSeries.map(serie => serie.folder); const selectableKeys = selectableSeries.map(serie => serie.key);
const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder)); const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
if (allSelectableSelected && this.selectedSeries.size > 0) { if (allSelectableSelected && this.selectedSeries.size > 0) {
// Deselect all selectable series // Deselect all selectable series
selectableFolders.forEach(folder => this.selectedSeries.delete(folder)); selectableKeys.forEach(key => this.selectedSeries.delete(key));
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false); document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false);
} else { } else {
// Select all selectable series // Select all selectable series
selectableFolders.forEach(folder => this.selectedSeries.add(folder)); selectableKeys.forEach(key => this.selectedSeries.add(key));
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true); document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true);
} }
@ -887,33 +891,35 @@ class AniWorldApp {
} }
async downloadSelected() { async downloadSelected() {
console.log('=== downloadSelected v1.1 - DEBUG VERSION ==='); console.log('=== downloadSelected v1.2 - Using key as primary identifier ===');
if (this.selectedSeries.size === 0) { if (this.selectedSeries.size === 0) {
this.showToast('No series selected', 'warning'); this.showToast('No series selected', 'warning');
return; return;
} }
try { try {
const folders = Array.from(this.selectedSeries); // selectedSeries now contains 'key' values (not folder)
const selectedKeys = Array.from(this.selectedSeries);
console.log('=== Starting download for selected series ==='); console.log('=== Starting download for selected series ===');
console.log('Selected folders:', folders); console.log('Selected keys:', selectedKeys);
console.log('seriesData:', this.seriesData); console.log('seriesData:', this.seriesData);
let totalEpisodesAdded = 0; let totalEpisodesAdded = 0;
let failedSeries = []; let failedSeries = [];
// For each selected series, get its missing episodes and add to queue // For each selected series, get its missing episodes and add to queue
for (const folder of folders) { // Use 'key' to find the series in seriesData
const serie = this.seriesData.find(s => s.folder === folder); for (const key of selectedKeys) {
const serie = this.seriesData.find(s => s.key === key);
if (!serie || !serie.episodeDict) { if (!serie || !serie.episodeDict) {
console.error('Serie not found or has no episodeDict:', folder, serie); console.error('Serie not found or has no episodeDict for key:', key, serie);
failedSeries.push(folder); failedSeries.push(key);
continue; continue;
} }
// Validate required fields // Validate required fields
if (!serie.key) { if (!serie.key) {
console.error('Serie missing key:', serie); console.error('Serie missing key:', serie);
failedSeries.push(folder); failedSeries.push(key);
continue; continue;
} }
@ -957,7 +963,7 @@ class AniWorldApp {
}); });
if (!response) { if (!response) {
failedSeries.push(folder); failedSeries.push(key);
continue; continue;
} }
@ -973,14 +979,14 @@ class AniWorldApp {
totalEpisodesAdded += episodes.length; totalEpisodesAdded += episodes.length;
} else { } else {
console.error('Failed to add to queue:', data); console.error('Failed to add to queue:', data);
failedSeries.push(folder); failedSeries.push(key);
} }
} }
// Show result message // Show result message
console.log('=== Download request complete ==='); console.log('=== Download request complete ===');
console.log('Total episodes added:', totalEpisodesAdded); console.log('Total episodes added:', totalEpisodesAdded);
console.log('Failed series:', failedSeries); console.log('Failed series (keys):', failedSeries);
if (totalEpisodesAdded > 0) { if (totalEpisodesAdded > 0) {
const message = failedSeries.length > 0 const message = failedSeries.length > 0
@ -989,7 +995,7 @@ class AniWorldApp {
this.showToast(message, 'success'); this.showToast(message, 'success');
} else { } else {
const errorDetails = failedSeries.length > 0 const errorDetails = failedSeries.length > 0
? `Failed series: ${failedSeries.join(', ')}` ? `Failed series (keys): ${failedSeries.join(', ')}`
: 'No episodes were added. Check browser console for details.'; : 'No episodes were added. Check browser console for details.';
console.error('Failed to add episodes. Details:', errorDetails); console.error('Failed to add episodes. Details:', errorDetails);
this.showToast('Failed to add episodes to queue. Check console for details.', 'error'); this.showToast('Failed to add episodes to queue. Check console for details.', 'error');

View File

@ -10,10 +10,25 @@ from src.server.services.auth_service import auth_service
class FakeSerie: class FakeSerie:
"""Mock Serie object for testing.""" """Mock Serie object for testing.
Note on identifiers:
- key: Provider-assigned URL-safe identifier (e.g., 'attack-on-titan')
- folder: Filesystem folder name for metadata only (e.g., 'Attack on Titan (2013)')
The 'key' is the primary identifier used for all lookups and operations.
The 'folder' is metadata only, not used for identification.
"""
def __init__(self, key, name, folder, episodeDict=None): def __init__(self, key, name, folder, episodeDict=None):
"""Initialize fake serie.""" """Initialize fake serie.
Args:
key: Provider-assigned URL-safe key (primary identifier)
name: Display name for the series
folder: Filesystem folder name (metadata only)
episodeDict: Dictionary of missing episodes
"""
self.key = key self.key = key
self.name = name self.name = name
self.folder = folder self.folder = folder
@ -28,8 +43,9 @@ class FakeSeriesApp:
"""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._items = [ self._items = [
FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}), # Using realistic key values (URL-safe, lowercase, hyphenated)
FakeSerie("2", "Complete Show", "complete_show", {}), FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}),
] ]
def GetMissingEpisode(self): def GetMissingEpisode(self):
@ -120,9 +136,15 @@ def test_list_anime_direct_call():
def test_get_anime_detail_direct_call(): def test_get_anime_detail_direct_call():
"""Test get_anime function directly.""" """Test get_anime function directly.
Uses the series key (test-show-key) for lookup, not the folder name.
"""
fake = FakeSeriesApp() fake = FakeSeriesApp()
result = asyncio.run(anime_module.get_anime("1", series_app=fake)) # Use the series key (primary identifier) for lookup
result = asyncio.run(
anime_module.get_anime("test-show-key", series_app=fake)
)
assert result.title == "Test Show" assert result.title == "Test Show"
assert "1-1" in result.episodes assert "1-1" in result.episodes

View File

@ -146,7 +146,8 @@ async def test_get_queue_status_unauthorized(mock_download_service):
async def test_add_to_queue(authenticated_client, mock_download_service): async def test_add_to_queue(authenticated_client, mock_download_service):
"""Test POST /api/queue/add endpoint.""" """Test POST /api/queue/add endpoint."""
request_data = { request_data = {
"serie_id": "series-1", "serie_id": "test-anime",
"serie_folder": "Test Anime (2024)",
"serie_name": "Test Anime", "serie_name": "Test Anime",
"episodes": [ "episodes": [
{"season": 1, "episode": 1}, {"season": 1, "episode": 1},
@ -175,7 +176,8 @@ async def test_add_to_queue_with_high_priority(
): ):
"""Test adding items with HIGH priority.""" """Test adding items with HIGH priority."""
request_data = { request_data = {
"serie_id": "series-1", "serie_id": "test-anime",
"serie_folder": "Test Anime (2024)",
"serie_name": "Test Anime", "serie_name": "Test Anime",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "high", "priority": "high",
@ -198,7 +200,8 @@ async def test_add_to_queue_empty_episodes(
): ):
"""Test adding empty episodes list returns 400.""" """Test adding empty episodes list returns 400."""
request_data = { request_data = {
"serie_id": "series-1", "serie_id": "test-anime",
"serie_folder": "Test Anime (2024)",
"serie_name": "Test Anime", "serie_name": "Test Anime",
"episodes": [], "episodes": [],
"priority": "normal", "priority": "normal",
@ -221,7 +224,8 @@ async def test_add_to_queue_service_error(
) )
request_data = { request_data = {
"serie_id": "series-1", "serie_id": "test-anime",
"serie_folder": "Test Anime (2024)",
"serie_name": "Test Anime", "serie_name": "Test Anime",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal", "priority": "normal",

View File

@ -45,9 +45,14 @@ async def auth_headers(client: AsyncClient):
@pytest.fixture @pytest.fixture
def sample_download_request(): def sample_download_request():
"""Sample download request for testing.""" """Sample download request for testing.
Note: serie_id is the primary identifier (key) used for all lookups.
serie_folder is metadata only used for filesystem operations.
"""
return { return {
"serie_id": "test-series", "serie_id": "test-series-key", # Provider key (primary identifier)
"serie_folder": "Test Series (2024)", # Filesystem folder (metadata)
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [ "episodes": [
{"season": 1, "episode": 1}, {"season": 1, "episode": 1},
@ -100,6 +105,11 @@ class TestQueueDisplay:
) )
assert add_response.status_code == 201 assert add_response.status_code == 201
# Get the added item IDs from response
add_data = add_response.json()
added_ids = add_data.get("added_items", [])
assert len(added_ids) > 0, "No items were added"
# Get queue status # Get queue status
response = await client.get( response = await client.get(
"/api/queue/status", "/api/queue/status",
@ -112,20 +122,83 @@ class TestQueueDisplay:
pending = data["status"]["pending_queue"] pending = data["status"]["pending_queue"]
assert len(pending) > 0 assert len(pending) > 0
item = pending[0]
# Find the item we just added by ID
item = next((i for i in pending if i["id"] in added_ids), None)
assert item is not None, f"Could not find added item in queue. Added IDs: {added_ids}"
# Verify required fields for display # Verify required fields for display
assert "id" in item assert "id" in item
assert "serie_id" in item # Key - primary identifier
assert "serie_folder" in item # Metadata for filesystem ops
assert "serie_name" in item assert "serie_name" in item
assert "episode" in item assert "episode" in item
assert "priority" in item assert "priority" in item
assert "added_at" in item assert "added_at" in item
# Verify serie_id (key) matches what we sent
assert item["serie_id"] == sample_download_request["serie_id"]
# Verify episode structure # Verify episode structure
episode = item["episode"] episode = item["episode"]
assert "season" in episode assert "season" in episode
assert "episode" in episode assert "episode" in episode
@pytest.mark.asyncio
async def test_queue_item_uses_key_as_identifier(
self, client: AsyncClient, auth_headers: dict
):
"""Test that queue items use serie_id (key) as primary identifier.
Verifies that:
- serie_id is the provider-assigned key (URL-safe identifier)
- serie_folder is metadata only (not used for identification)
- Both fields are present in queue item responses
"""
# Add an item with explicit key and folder
request = {
"serie_id": "my-test-anime-key", # Provider key (primary ID)
"serie_folder": "My Test Anime (2024)", # Display name/folder
"serie_name": "My Test Anime",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
add_response = await client.post(
"/api/queue/add",
json=request,
headers=auth_headers
)
assert add_response.status_code == 201
# Get queue status
response = await client.get(
"/api/queue/status",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
pending = data["status"]["pending_queue"]
# Find our item by key
matching_items = [
item for item in pending
if item["serie_id"] == "my-test-anime-key"
]
assert len(matching_items) >= 1, "Item should be findable by key"
item = matching_items[0]
# Verify key is used as identifier
assert item["serie_id"] == "my-test-anime-key"
# Verify folder is preserved as metadata
assert item["serie_folder"] == "My Test Anime (2024)"
# Verify serie_name is also present
assert item["serie_name"] == "My Test Anime"
class TestQueueReordering: class TestQueueReordering:
"""Test queue reordering functionality.""" """Test queue reordering functionality."""
@ -158,8 +231,9 @@ class TestQueueReordering:
response = await client.post( response = await client.post(
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": f"test-{i}", "serie_id": f"reorder-test-key-{i}", # Key (primary ID)
"serie_name": f"Test Series {i}", "serie_folder": f"Reorder Test {i} (2024)", # Metadata
"serie_name": f"Reorder Test {i}",
"episodes": [{"season": 1, "episode": i+1}], "episodes": [{"season": 1, "episode": i+1}],
"priority": "normal" "priority": "normal"
}, },
@ -412,7 +486,8 @@ class TestBulkOperations:
add_response = await client.post( add_response = await client.post(
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": f"bulk-test-{i}", "serie_id": f"bulk-test-key-{i}", # Key (primary ID)
"serie_folder": f"Bulk Test {i} (2024)", # Metadata
"serie_name": f"Bulk Test {i}", "serie_name": f"Bulk Test {i}",
"episodes": [{"season": 1, "episode": i+1}], "episodes": [{"season": 1, "episode": i+1}],
"priority": "normal" "priority": "normal"

View File

@ -252,7 +252,8 @@ class TestFrontendDownloadAPI:
response = await authenticated_client.post( response = await authenticated_client.post(
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test_anime", "serie_id": "test-anime",
"serie_folder": "Test Anime (2024)",
"serie_name": "Test Anime", "serie_name": "Test Anime",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"

View File

@ -133,6 +133,7 @@ class TestDownloadFlowEndToEnd:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series-1", "serie_id": "test-series-1",
"serie_folder": "Test Anime Series (2024)",
"serie_name": "Test Anime Series", "serie_name": "Test Anime Series",
"episodes": [ "episodes": [
{"season": 1, "episode": 1, "title": "Episode 1"}, {"season": 1, "episode": 1, "title": "Episode 1"},
@ -158,6 +159,7 @@ class TestDownloadFlowEndToEnd:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series-2", "serie_id": "test-series-2",
"serie_folder": "Another Series (2024)",
"serie_name": "Another Series", "serie_name": "Another Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "high" "priority": "high"
@ -194,6 +196,7 @@ class TestDownloadFlowEndToEnd:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": f"series-{priority}", "serie_id": f"series-{priority}",
"serie_folder": f"Series {priority.title()} (2024)",
"serie_name": f"Series {priority.title()}", "serie_name": f"Series {priority.title()}",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": priority "priority": priority
@ -208,6 +211,7 @@ class TestDownloadFlowEndToEnd:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [], "episodes": [],
"priority": "normal" "priority": "normal"
@ -224,6 +228,7 @@ class TestDownloadFlowEndToEnd:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "invalid" "priority": "invalid"
@ -267,6 +272,7 @@ class TestQueueItemOperations:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -301,6 +307,7 @@ class TestDownloadProgressTracking:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -358,6 +365,7 @@ class TestErrorHandlingAndRetries:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "invalid-series", "serie_id": "invalid-series",
"serie_folder": "Invalid Series (2024)",
"serie_name": "Invalid Series", "serie_name": "Invalid Series",
"episodes": [{"season": 99, "episode": 99}], "episodes": [{"season": 99, "episode": 99}],
"priority": "normal" "priority": "normal"
@ -374,6 +382,7 @@ class TestErrorHandlingAndRetries:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -412,6 +421,7 @@ class TestAuthenticationRequirements:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "test-series", "serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -444,6 +454,7 @@ class TestConcurrentOperations:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": f"series-{i}", "serie_id": f"series-{i}",
"serie_folder": f"Series {i} (2024)",
"serie_name": f"Series {i}", "serie_name": f"Series {i}",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -488,6 +499,7 @@ class TestQueuePersistence:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "persistent-series", "serie_id": "persistent-series",
"serie_folder": "Persistent Series (2024)",
"serie_name": "Persistent Series", "serie_name": "Persistent Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -524,6 +536,7 @@ class TestWebSocketIntegrationWithDownloads:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "ws-series", "serie_id": "ws-series",
"serie_folder": "WebSocket Series (2024)",
"serie_name": "WebSocket Series", "serie_name": "WebSocket Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "normal" "priority": "normal"
@ -546,6 +559,7 @@ class TestCompleteDownloadWorkflow:
"/api/queue/add", "/api/queue/add",
json={ json={
"serie_id": "workflow-series", "serie_id": "workflow-series",
"serie_folder": "Workflow Test Series (2024)",
"serie_name": "Workflow Test Series", "serie_name": "Workflow Test Series",
"episodes": [{"season": 1, "episode": 1}], "episodes": [{"season": 1, "episode": 1}],
"priority": "high" "priority": "high"

View File

@ -124,9 +124,10 @@ class TestDownloadProgressIntegration:
) )
# Add download to queue # Add download to queue
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_service.add_to_queue( await download_service.add_to_queue(
serie_id="integration_test", serie_id="integration-test-key",
serie_folder="test_folder", serie_folder="Integration Test Anime (2024)",
serie_name="Integration Test Anime", serie_name="Integration Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -197,9 +198,10 @@ class TestDownloadProgressIntegration:
) )
# Add and start download # Add and start download
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_service.add_to_queue( await download_service.add_to_queue(
serie_id="client_test", serie_id="client-test-key",
serie_folder="test_folder", serie_folder="Client Test Anime (2024)",
serie_name="Client Test Anime", serie_name="Client Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -273,9 +275,10 @@ class TestDownloadProgressIntegration:
) )
# Start download # Start download
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_service.add_to_queue( await download_service.add_to_queue(
serie_id="multi_client_test", serie_id="multi-client-test-key",
serie_folder="test_folder", serie_folder="Multi Client Test (2024)",
serie_name="Multi Client Test", serie_name="Multi Client Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -320,9 +323,10 @@ class TestDownloadProgressIntegration:
progress_service.subscribe("progress_updated", capture_broadcast) progress_service.subscribe("progress_updated", capture_broadcast)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_service.add_to_queue( await download_service.add_to_queue(
serie_id="structure_test", serie_id="structure-test-key",
serie_folder="test_folder", serie_folder="Structure Test (2024)",
serie_name="Structure Test", serie_name="Structure Test",
episodes=[EpisodeIdentifier(season=2, episode=3)], episodes=[EpisodeIdentifier(season=2, episode=3)],
) )
@ -382,9 +386,10 @@ class TestDownloadProgressIntegration:
) )
# Start download after disconnect # Start download after disconnect
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_service.add_to_queue( await download_service.add_to_queue(
serie_id="disconnect_test", serie_id="disconnect-test-key",
serie_folder="test_folder", serie_folder="Disconnect Test (2024)",
serie_name="Disconnect Test", serie_name="Disconnect Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )

View File

@ -0,0 +1,523 @@
"""Integration tests for series identifier consistency.
This module verifies that the 'key' identifier is used consistently
across all layers of the application (API, services, database, WebSocket).
The identifier standardization ensures:
- 'key' is the primary identifier (provider-assigned, URL-safe)
- 'folder' is metadata only (not used for lookups)
- Consistent identifier usage throughout the codebase
"""
import asyncio
from datetime import datetime, timezone
from typing import Any, Dict, List
from unittest.mock import AsyncMock, Mock
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.download import (
DownloadItem,
DownloadPriority,
DownloadStatus,
EpisodeIdentifier,
)
from src.server.services.anime_service import AnimeService
from src.server.services.auth_service import auth_service
from src.server.services.download_service import DownloadService
from src.server.services.progress_service import ProgressService
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""
original_hash = auth_service._hash
auth_service._hash = None
auth_service._failed.clear()
yield
auth_service._hash = original_hash
auth_service._failed.clear()
@pytest.fixture
async def client():
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
# Setup master password
await client.post(
"/api/auth/setup",
json={"master_password": "TestPassword123!"}
)
# Login to get token
response = await client.post(
"/api/auth/login",
json={"password": "TestPassword123!"}
)
token = response.json()["access_token"]
# Add token to default headers
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_series_app():
"""Mock SeriesApp for testing."""
app_mock = Mock()
app_mock.series_list = []
app_mock.search = Mock(return_value=[])
app_mock.ReScan = Mock()
app_mock.download = Mock(return_value=True)
return app_mock
@pytest.fixture
def progress_service():
"""Create a ProgressService instance for testing."""
return ProgressService()
@pytest.fixture
async def download_service(mock_series_app, progress_service, tmp_path):
"""Create a DownloadService with dependencies."""
import uuid
persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json"
anime_service = AnimeService(
series_app=mock_series_app,
progress_service=progress_service,
)
anime_service.download = AsyncMock(return_value=True)
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
persistence_path=str(persistence_path),
)
yield service
await service.stop()
class TestAPIIdentifierConsistency:
"""Test that API endpoints use 'key' as the primary identifier."""
@pytest.mark.asyncio
async def test_queue_add_returns_key_in_response(
self, authenticated_client
):
"""Test that adding to queue uses key as identifier.
Verifies:
- Request accepts serie_id (key) as primary identifier
- serie_folder is accepted as metadata
- Response reflects correct identifiers
"""
request_data = {
"serie_id": "attack-on-titan", # Key (primary identifier)
"serie_folder": "Attack on Titan (2013)", # Metadata only
"serie_name": "Attack on Titan",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
response = await authenticated_client.post(
"/api/queue/add",
json=request_data
)
assert response.status_code == 201
data = response.json()
# Verify response structure
assert data["status"] == "success"
assert len(data.get("added_items", [])) > 0
@pytest.mark.asyncio
async def test_queue_status_contains_key_identifier(
self, authenticated_client
):
"""Test that queue status returns key as identifier.
Verifies:
- Queue items have serie_id (key) as identifier
- Queue items have serie_folder as metadata
- Both fields are present and distinct
"""
import uuid
# Add an item first with unique key
unique_suffix = str(uuid.uuid4())[:8]
unique_key = f"one-piece-{unique_suffix}"
unique_folder = f"One Piece ({unique_suffix})"
await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": unique_key,
"serie_folder": unique_folder,
"serie_name": "One Piece",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
# Get queue status
response = await authenticated_client.get("/api/queue/status")
assert response.status_code == 200
data = response.json()
# Navigate to pending queue
pending = data["status"]["pending_queue"]
assert len(pending) > 0
# Find the item we just added by key
matching_items = [
item for item in pending if item["serie_id"] == unique_key
]
assert len(matching_items) == 1, (
f"Expected to find item with key {unique_key}"
)
item = matching_items[0]
# Verify identifier structure in queue item
assert "serie_id" in item, "Queue item must have serie_id (key)"
assert "serie_folder" in item, "Queue item must have serie_folder"
# Verify key format (lowercase, hyphenated)
assert item["serie_id"] == unique_key
# Verify folder is preserved as metadata
assert item["serie_folder"] == unique_folder
# Verify both are present but different
assert item["serie_id"] != item["serie_folder"]
@pytest.mark.asyncio
async def test_key_used_for_lookup_not_folder(
self, authenticated_client
):
"""Test that lookups use key, not folder.
Verifies:
- Items can be identified by serie_id (key)
- Multiple items with same folder but different keys are distinct
"""
import uuid
unique_suffix = str(uuid.uuid4())[:8]
# Add two items with different keys but similar folders
key1 = f"naruto-original-{unique_suffix}"
key2 = f"naruto-shippuden-{unique_suffix}"
shared_folder = f"Naruto Series ({unique_suffix})"
await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": key1,
"serie_folder": shared_folder,
"serie_name": "Naruto",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": key2,
"serie_folder": shared_folder, # Same folder
"serie_name": "Naruto Shippuden",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
# Get queue status
response = await authenticated_client.get("/api/queue/status")
data = response.json()
pending = data["status"]["pending_queue"]
# Both items should be present (same folder doesn't cause collision)
serie_ids = [item["serie_id"] for item in pending]
assert key1 in serie_ids
assert key2 in serie_ids
class TestServiceIdentifierConsistency:
"""Test that services use 'key' as the primary identifier."""
@pytest.mark.asyncio
async def test_download_service_uses_key(self, download_service):
"""Test that DownloadService uses key as identifier.
Verifies:
- Items are stored with serie_id (key)
- Items can be retrieved by key
- Queue operations use key consistently
"""
# Add item to queue
item_ids = await download_service.add_to_queue(
serie_id="my-hero-academia",
serie_folder="My Hero Academia (2016)",
serie_name="My Hero Academia",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 1
# Verify item is stored correctly
pending = download_service._pending_queue
assert len(pending) == 1
item = pending[0]
assert item.serie_id == "my-hero-academia"
assert item.serie_folder == "My Hero Academia (2016)"
@pytest.mark.asyncio
async def test_download_item_normalizes_key(self, download_service):
"""Test that serie_id is normalized (lowercase, stripped).
Verifies:
- Key is converted to lowercase
- Whitespace is stripped
"""
# Add item with uppercase key
item_ids = await download_service.add_to_queue(
serie_id=" DEMON-SLAYER ",
serie_folder="Demon Slayer (2019)",
serie_name="Demon Slayer",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 1
# Verify key is normalized
item = download_service._pending_queue[0]
assert item.serie_id == "demon-slayer"
@pytest.mark.asyncio
async def test_queue_persistence_uses_key(
self, download_service, tmp_path
):
"""Test that persisted queue data uses key as identifier.
Verifies:
- Persisted data contains serie_id (key)
- Data can be restored with correct identifiers
"""
import json
# Add item to queue
await download_service.add_to_queue(
serie_id="jujutsu-kaisen",
serie_folder="Jujutsu Kaisen (2020)",
serie_name="Jujutsu Kaisen",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
# Read persisted data
persistence_path = download_service._persistence_path
with open(persistence_path, "r") as f:
data = json.load(f)
# Verify persisted data structure
assert "pending" in data
assert len(data["pending"]) == 1
persisted_item = data["pending"][0]
assert persisted_item["serie_id"] == "jujutsu-kaisen"
assert persisted_item["serie_folder"] == "Jujutsu Kaisen (2020)"
class TestWebSocketIdentifierConsistency:
"""Test that WebSocket events use 'key' in their payloads."""
@pytest.mark.asyncio
async def test_progress_events_include_key(
self, download_service, progress_service
):
"""Test that progress events include key identifier.
Verifies:
- Progress events contain key information
- Events use consistent identifier structure
"""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict(),
"room": event.room,
})
progress_service.subscribe("progress_updated", mock_event_handler)
# Add item to trigger events
await download_service.add_to_queue(
serie_id="spy-x-family",
serie_folder="Spy x Family (2022)",
serie_name="Spy x Family",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
# Verify events were emitted
assert len(broadcasts) >= 1
# Check queue progress events for metadata
queue_events = [
b for b in broadcasts if b["type"] == "queue_progress"
]
# Verify metadata structure includes identifier info
for event in queue_events:
metadata = event["data"].get("metadata", {})
# Queue events should track items by their identifiers
if "added_ids" in metadata:
assert len(metadata["added_ids"]) > 0
class TestIdentifierValidation:
"""Test identifier validation and edge cases."""
@pytest.mark.asyncio
async def test_key_format_validation(self, authenticated_client):
"""Test that key format is validated correctly.
Verifies:
- Valid keys are accepted (lowercase, hyphenated)
- Keys are normalized on input
"""
import uuid
unique_suffix = str(uuid.uuid4())[:8]
# Valid key format
response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": f"valid-key-format-{unique_suffix}",
"serie_folder": f"Valid Key ({unique_suffix})",
"serie_name": "Valid Key",
"episodes": [{"season": 1, "episode": 1}],
"priority": "normal"
}
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_folder_not_used_for_identification(
self, download_service
):
"""Test that folder changes don't affect identification.
Verifies:
- Same key with different folder is same series
- Folder is metadata only, not identity
"""
# Add item
await download_service.add_to_queue(
serie_id="chainsaw-man",
serie_folder="Chainsaw Man (2022)",
serie_name="Chainsaw Man",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
# Add another episode for same key, different folder
await download_service.add_to_queue(
serie_id="chainsaw-man",
serie_folder="Chainsaw Man Updated (2022)", # Different folder
serie_name="Chainsaw Man",
episodes=[EpisodeIdentifier(season=1, episode=2)],
priority=DownloadPriority.NORMAL,
)
# Both should be added (same key, different episodes)
assert len(download_service._pending_queue) == 2
# Verify both use the same key
keys = [item.serie_id for item in download_service._pending_queue]
assert all(k == "chainsaw-man" for k in keys)
class TestEndToEndIdentifierFlow:
"""End-to-end tests for identifier consistency across layers."""
@pytest.mark.asyncio
async def test_complete_flow_with_key(
self, authenticated_client
):
"""Test complete flow uses key consistently.
Verifies:
- API -> Service -> Storage uses key
- All responses contain correct identifiers
"""
import uuid
# Use unique key to avoid conflicts with other tests
unique_suffix = str(uuid.uuid4())[:8]
unique_key = f"bleach-tybw-{unique_suffix}"
unique_folder = f"Bleach: TYBW ({unique_suffix})"
# 1. Add to queue via API
add_response = await authenticated_client.post(
"/api/queue/add",
json={
"serie_id": unique_key,
"serie_folder": unique_folder,
"serie_name": "Bleach: TYBW",
"episodes": [{"season": 1, "episode": 1}],
"priority": "high"
}
)
assert add_response.status_code == 201
# 2. Verify in queue status
status_response = await authenticated_client.get("/api/queue/status")
assert status_response.status_code == 200
status_data = status_response.json()
pending = status_data["status"]["pending_queue"]
# Find our item by key
items = [
i for i in pending
if i["serie_id"] == unique_key
]
assert len(items) == 1, (
f"Expected exactly 1 item with key {unique_key}, "
f"found {len(items)}"
)
item = items[0]
# 3. Verify identifier consistency
assert item["serie_id"] == unique_key
assert item["serie_folder"] == unique_folder
assert item["serie_name"] == "Bleach: TYBW"
# 4. Verify key and folder are different
assert item["serie_id"] != item["serie_folder"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -106,9 +106,10 @@ class TestWebSocketDownloadIntegration:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add item to queue # Add item to queue
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
item_ids = await download_svc.add_to_queue( item_ids = await download_svc.add_to_queue(
serie_id="test_serie", serie_id="test-serie-key",
serie_folder="test_serie", serie_folder="Test Anime (2024)",
serie_name="Test Anime", serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH, priority=DownloadPriority.HIGH,
@ -142,9 +143,10 @@ class TestWebSocketDownloadIntegration:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add items # Add items
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
item_ids = await download_svc.add_to_queue( item_ids = await download_svc.add_to_queue(
serie_id="test", serie_id="test-queue-ops-key",
serie_folder="test", serie_folder="Test Queue Ops (2024)",
serie_name="Test", serie_name="Test",
episodes=[ episodes=[
EpisodeIdentifier(season=1, episode=i) EpisodeIdentifier(season=1, episode=i)
@ -193,9 +195,10 @@ class TestWebSocketDownloadIntegration:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add an item to initialize the queue progress # Add an item to initialize the queue progress
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test", serie_id="test-start-stop-key",
serie_folder="test", serie_folder="Test Start Stop (2024)",
serie_name="Test", serie_name="Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -226,9 +229,10 @@ class TestWebSocketDownloadIntegration:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Initialize the download queue progress by adding an item # Initialize the download queue progress by adding an item
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test", serie_id="test-init-key",
serie_folder="test", serie_folder="Test Init (2024)",
serie_name="Test Init", serie_name="Test Init",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -240,9 +244,9 @@ class TestWebSocketDownloadIntegration:
completed_item = DownloadItem( completed_item = DownloadItem(
id="test_completed", id="test_completed",
serie_id="test", serie_id="test-completed-key",
serie_name="Test", serie_name="Test",
serie_folder="Test", serie_folder="Test (2024)",
episode=EpisodeIdentifier(season=1, episode=1), episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.COMPLETED, status=DownloadStatus.COMPLETED,
priority=DownloadPriority.NORMAL, priority=DownloadPriority.NORMAL,
@ -463,9 +467,10 @@ class TestWebSocketEndToEnd:
progress_service.subscribe("progress_updated", capture_event) progress_service.subscribe("progress_updated", capture_event)
# Add items to queue # Add items to queue
# Note: serie_id uses provider key format (URL-safe, lowercase)
item_ids = await download_svc.add_to_queue( item_ids = await download_svc.add_to_queue(
serie_id="test", serie_id="test-e2e-key",
serie_folder="test", serie_folder="Test Anime (2024)",
serie_name="Test Anime", serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH, priority=DownloadPriority.HIGH,

View File

@ -1,3 +1,9 @@
"""Unit tests for anime Pydantic models.
This module tests all anime-related models including validation,
serialization, and field constraints.
"""
import pytest
from pydantic import ValidationError from pydantic import ValidationError
from src.server.models.anime import ( from src.server.models.anime import (
@ -9,101 +15,139 @@ from src.server.models.anime import (
) )
def test_episode_info_basic(): class TestEpisodeInfo:
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500) """Tests for EpisodeInfo model."""
assert ep.episode_number == 1
assert ep.title == "Pilot" def test_episode_info_basic(self):
assert ep.duration_seconds == 1500 """Test creating a basic episode info."""
assert ep.available is True ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
assert ep.episode_number == 1
assert ep.title == "Pilot"
assert ep.duration_seconds == 1500
assert ep.available is True
def test_episode_info_without_optional_fields(self):
"""Test episode info with only required fields."""
ep = EpisodeInfo(episode_number=5)
assert ep.episode_number == 5
assert ep.title is None
assert ep.duration_seconds is None
assert ep.available is True
def test_invalid_episode_number(self):
"""Test that episode number must be positive."""
with pytest.raises(ValidationError):
EpisodeInfo(episode_number=0)
def test_missing_episode_count(): class TestMissingEpisodeInfo:
m = MissingEpisodeInfo(from_episode=5, to_episode=7) """Tests for MissingEpisodeInfo model."""
assert m.count == 3
def test_missing_episode_count(self):
"""Test count property calculation."""
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
assert m.count == 3
def test_single_missing_episode(self):
"""Test count for single missing episode."""
m = MissingEpisodeInfo(from_episode=5, to_episode=5)
assert m.count == 1
def test_anime_series_response(): class TestAnimeSeriesResponse:
ep = EpisodeInfo(episode_number=1, title="Ep1") """Tests for AnimeSeriesResponse model."""
series = AnimeSeriesResponse(
id="series-123",
title="My Anime",
episodes=[ep],
total_episodes=12,
)
assert series.id == "series-123" def test_anime_series_response_with_key(self):
assert series.episodes[0].title == "Ep1" """Test creating series response with key as identifier."""
ep = EpisodeInfo(episode_number=1, title="Ep1")
series = AnimeSeriesResponse(
key="attack-on-titan",
title="Attack on Titan",
folder="Attack on Titan (2013)",
episodes=[ep],
total_episodes=12,
)
assert series.key == "attack-on-titan"
assert series.title == "Attack on Titan"
assert series.folder == "Attack on Titan (2013)"
assert series.episodes[0].title == "Ep1"
def test_key_normalization(self):
"""Test that key is normalized to lowercase."""
series = AnimeSeriesResponse(
key="ATTACK-ON-TITAN",
title="Attack on Titan"
)
assert series.key == "attack-on-titan"
def test_key_whitespace_stripped(self):
"""Test that key whitespace is stripped."""
series = AnimeSeriesResponse(
key=" attack-on-titan ",
title="Attack on Titan"
)
assert series.key == "attack-on-titan"
def test_folder_is_optional(self):
"""Test that folder is optional metadata."""
series = AnimeSeriesResponse(
key="my-anime",
title="My Anime"
)
assert series.folder is None
def test_search_request_validation(): class TestSearchRequest:
# valid """Tests for SearchRequest model."""
req = SearchRequest(query="naruto", limit=5)
assert req.query == "naruto"
# invalid: empty query def test_search_request_validation(self):
try: """Test valid search request."""
SearchRequest(query="", limit=5) req = SearchRequest(query="naruto", limit=5)
raised = False assert req.query == "naruto"
except ValidationError: assert req.limit == 5
raised = True
assert raised def test_search_request_empty_query_rejected(self):
"""Test that empty query is rejected."""
with pytest.raises(ValidationError):
SearchRequest(query="", limit=5)
def test_search_request_defaults(self):
"""Test default values."""
req = SearchRequest(query="test")
assert req.limit == 10
assert req.include_adult is False
def test_search_result_optional_fields(): class TestSearchResult:
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9) """Tests for SearchResult model."""
assert res.score == 0.9
from pydantic import ValidationError def test_search_result_with_key(self):
"""Test search result with key as identifier."""
res = SearchResult(
key="naruto",
title="Naruto",
folder="Naruto (2002)",
snippet="A ninja story",
score=0.9
)
assert res.key == "naruto"
assert res.title == "Naruto"
assert res.folder == "Naruto (2002)"
assert res.score == 0.9
from src.server.models.anime import ( def test_key_normalization(self):
AnimeSeriesResponse, """Test that key is normalized to lowercase."""
EpisodeInfo, res = SearchResult(key="NARUTO", title="Naruto")
MissingEpisodeInfo, assert res.key == "naruto"
SearchRequest,
SearchResult,
)
def test_folder_is_optional(self):
"""Test that folder is optional metadata."""
res = SearchResult(key="test", title="Test")
assert res.folder is None
def test_episode_info_basic(): def test_optional_fields(self):
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500) """Test optional fields."""
assert ep.episode_number == 1 res = SearchResult(key="s1", title="T1", snippet="snip", score=0.9)
assert ep.title == "Pilot" assert res.score == 0.9
assert ep.duration_seconds == 1500 assert res.snippet == "snip"
assert ep.available is True
def test_missing_episode_count():
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
assert m.count == 3
def test_anime_series_response():
ep = EpisodeInfo(episode_number=1, title="Ep1")
series = AnimeSeriesResponse(
id="series-123",
title="My Anime",
episodes=[ep],
total_episodes=12,
)
assert series.id == "series-123"
assert series.episodes[0].title == "Ep1"
def test_search_request_validation():
# valid
req = SearchRequest(query="naruto", limit=5)
assert req.query == "naruto"
# invalid: empty query
try:
SearchRequest(query="", limit=5)
raised = False
except ValidationError:
raised = True
assert raised
def test_search_result_optional_fields():
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
assert res.score == 0.9

View File

@ -34,6 +34,8 @@ class TestProgressContext(unittest.TestCase):
percentage=50.0, percentage=50.0,
message="Downloading...", message="Downloading...",
details="Episode 5", details="Episode 5",
key="attack-on-titan",
folder="Attack on Titan (2013)",
metadata={"series": "Test"} metadata={"series": "Test"}
) )
@ -45,6 +47,8 @@ class TestProgressContext(unittest.TestCase):
self.assertEqual(context.percentage, 50.0) self.assertEqual(context.percentage, 50.0)
self.assertEqual(context.message, "Downloading...") self.assertEqual(context.message, "Downloading...")
self.assertEqual(context.details, "Episode 5") self.assertEqual(context.details, "Episode 5")
self.assertEqual(context.key, "attack-on-titan")
self.assertEqual(context.folder, "Attack on Titan (2013)")
self.assertEqual(context.metadata, {"series": "Test"}) self.assertEqual(context.metadata, {"series": "Test"})
def test_progress_context_to_dict(self): def test_progress_context_to_dict(self):
@ -69,6 +73,8 @@ class TestProgressContext(unittest.TestCase):
self.assertEqual(result["percentage"], 100.0) self.assertEqual(result["percentage"], 100.0)
self.assertEqual(result["message"], "Scan complete") self.assertEqual(result["message"], "Scan complete")
self.assertIsNone(result["details"]) self.assertIsNone(result["details"])
self.assertIsNone(result["key"])
self.assertIsNone(result["folder"])
self.assertEqual(result["metadata"], {}) self.assertEqual(result["metadata"], {})
def test_progress_context_default_metadata(self): def test_progress_context_default_metadata(self):
@ -100,6 +106,8 @@ class TestErrorContext(unittest.TestCase):
message="Download failed", message="Download failed",
recoverable=True, recoverable=True,
retry_count=2, retry_count=2,
key="jujutsu-kaisen",
folder="Jujutsu Kaisen",
metadata={"attempt": 3} metadata={"attempt": 3}
) )
@ -109,6 +117,8 @@ class TestErrorContext(unittest.TestCase):
self.assertEqual(context.message, "Download failed") self.assertEqual(context.message, "Download failed")
self.assertTrue(context.recoverable) self.assertTrue(context.recoverable)
self.assertEqual(context.retry_count, 2) self.assertEqual(context.retry_count, 2)
self.assertEqual(context.key, "jujutsu-kaisen")
self.assertEqual(context.folder, "Jujutsu Kaisen")
self.assertEqual(context.metadata, {"attempt": 3}) self.assertEqual(context.metadata, {"attempt": 3})
def test_error_context_to_dict(self): def test_error_context_to_dict(self):
@ -131,6 +141,8 @@ class TestErrorContext(unittest.TestCase):
self.assertEqual(result["message"], "Scan error occurred") self.assertEqual(result["message"], "Scan error occurred")
self.assertFalse(result["recoverable"]) self.assertFalse(result["recoverable"])
self.assertEqual(result["retry_count"], 0) self.assertEqual(result["retry_count"], 0)
self.assertIsNone(result["key"])
self.assertIsNone(result["folder"])
self.assertEqual(result["metadata"], {}) self.assertEqual(result["metadata"], {})
@ -146,6 +158,8 @@ class TestCompletionContext(unittest.TestCase):
message="Download completed successfully", message="Download completed successfully",
result_data={"file": "episode.mp4"}, result_data={"file": "episode.mp4"},
statistics={"size": 1024, "time": 60}, statistics={"size": 1024, "time": 60},
key="bleach",
folder="Bleach (2004)",
metadata={"quality": "HD"} metadata={"quality": "HD"}
) )
@ -155,6 +169,8 @@ class TestCompletionContext(unittest.TestCase):
self.assertEqual(context.message, "Download completed successfully") self.assertEqual(context.message, "Download completed successfully")
self.assertEqual(context.result_data, {"file": "episode.mp4"}) self.assertEqual(context.result_data, {"file": "episode.mp4"})
self.assertEqual(context.statistics, {"size": 1024, "time": 60}) self.assertEqual(context.statistics, {"size": 1024, "time": 60})
self.assertEqual(context.key, "bleach")
self.assertEqual(context.folder, "Bleach (2004)")
self.assertEqual(context.metadata, {"quality": "HD"}) self.assertEqual(context.metadata, {"quality": "HD"})
def test_completion_context_to_dict(self): def test_completion_context_to_dict(self):
@ -173,6 +189,8 @@ class TestCompletionContext(unittest.TestCase):
self.assertFalse(result["success"]) self.assertFalse(result["success"])
self.assertEqual(result["message"], "Scan failed") self.assertEqual(result["message"], "Scan failed")
self.assertEqual(result["statistics"], {}) self.assertEqual(result["statistics"], {})
self.assertIsNone(result["key"])
self.assertIsNone(result["folder"])
self.assertEqual(result["metadata"], {}) self.assertEqual(result["metadata"], {})

View File

@ -171,27 +171,55 @@ class TestDownloadItem:
def test_valid_download_item(self): def test_valid_download_item(self):
"""Test creating a valid download item.""" """Test creating a valid download item."""
episode = EpisodeIdentifier(season=1, episode=5) episode = EpisodeIdentifier(season=1, episode=5)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="download_123", id="download_123",
serie_id="serie_456", serie_id="test-serie-key",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=episode, episode=episode,
status=DownloadStatus.PENDING, status=DownloadStatus.PENDING,
priority=DownloadPriority.HIGH priority=DownloadPriority.HIGH
) )
assert item.id == "download_123" assert item.id == "download_123"
assert item.serie_id == "serie_456" assert item.serie_id == "test-serie-key"
assert item.serie_name == "Test Series" assert item.serie_name == "Test Series"
assert item.episode == episode assert item.episode == episode
assert item.status == DownloadStatus.PENDING assert item.status == DownloadStatus.PENDING
assert item.priority == DownloadPriority.HIGH assert item.priority == DownloadPriority.HIGH
def test_download_item_defaults(self): def test_serie_id_normalized_to_lowercase(self):
"""Test default values for download item.""" """Test that serie_id (key) is normalized to lowercase."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="ATTACK-ON-TITAN",
serie_folder="Test Folder",
serie_name="Test",
episode=episode
)
assert item.serie_id == "attack-on-titan"
def test_serie_id_whitespace_stripped(self):
"""Test that serie_id whitespace is stripped."""
episode = EpisodeIdentifier(season=1, episode=1)
item = DownloadItem(
id="test_id",
serie_id=" attack-on-titan ",
serie_folder="Test Folder",
serie_name="Test",
episode=episode
)
assert item.serie_id == "attack-on-titan"
def test_download_item_defaults(self):
"""Test default values for download item."""
episode = EpisodeIdentifier(season=1, episode=1)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem(
id="test_id",
serie_id="default-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -208,9 +236,11 @@ class TestDownloadItem:
"""Test download item with progress information.""" """Test download item with progress information."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
progress = DownloadProgress(percent=50.0, downloaded_mb=100.0) progress = DownloadProgress(percent=50.0, downloaded_mb=100.0)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="progress-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
progress=progress progress=progress
@ -222,9 +252,11 @@ class TestDownloadItem:
"""Test download item with timestamp fields.""" """Test download item with timestamp fields."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="timestamp-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
started_at=now, started_at=now,
@ -239,7 +271,8 @@ class TestDownloadItem:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DownloadItem( DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="empty-name-test-key",
serie_folder="Test Folder (2024)",
serie_name="", serie_name="",
episode=episode episode=episode
) )
@ -250,7 +283,8 @@ class TestDownloadItem:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DownloadItem( DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="retry-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode, episode=episode,
retry_count=-1 retry_count=-1
@ -260,9 +294,11 @@ class TestDownloadItem:
"""Test that added_at is automatically set.""" """Test that added_at is automatically set."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
before = datetime.now(timezone.utc) before = datetime.now(timezone.utc)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="auto-added-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -276,9 +312,11 @@ class TestQueueStatus:
def test_valid_queue_status(self): def test_valid_queue_status(self):
"""Test creating valid queue status.""" """Test creating valid queue status."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="queue-status-test-key",
serie_folder="Test Folder (2024)",
serie_name="Test", serie_name="Test",
episode=episode episode=episode
) )
@ -373,32 +411,63 @@ class TestDownloadRequest:
"""Test creating a valid download request.""" """Test creating a valid download request."""
episode1 = EpisodeIdentifier(season=1, episode=1) episode1 = EpisodeIdentifier(season=1, episode=1)
episode2 = EpisodeIdentifier(season=1, episode=2) episode2 = EpisodeIdentifier(season=1, episode=2)
# Note: serie_id uses provider key format (URL-safe, lowercase)
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="test-series-key",
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
) )
assert request.serie_id == "serie_123" assert request.serie_id == "test-series-key"
assert request.serie_name == "Test Series" assert request.serie_name == "Test Series"
assert len(request.episodes) == 2 assert len(request.episodes) == 2
assert request.priority == DownloadPriority.HIGH assert request.priority == DownloadPriority.HIGH
def test_serie_id_normalized_to_lowercase(self):
"""Test that serie_id (key) is normalized to lowercase."""
episode = EpisodeIdentifier(season=1, episode=1)
request = DownloadRequest(
serie_id="ATTACK-ON-TITAN",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episodes=[episode]
)
assert request.serie_id == "attack-on-titan"
def test_serie_id_whitespace_stripped(self):
"""Test that serie_id whitespace is stripped."""
episode = EpisodeIdentifier(season=1, episode=1)
request = DownloadRequest(
serie_id=" attack-on-titan ",
serie_folder="Test Series (2023)",
serie_name="Test Series",
episodes=[episode]
)
assert request.serie_id == "attack-on-titan"
def test_download_request_default_priority(self): def test_download_request_default_priority(self):
"""Test default priority for download request.""" """Test default priority for download request."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
# Note: serie_id uses provider key format (URL-safe, lowercase)
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="default-priority-test-key",
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
# Note: serie_id uses provider key format (URL-safe, lowercase)
request = DownloadRequest( request = DownloadRequest(
serie_id="serie_123", serie_id="empty-episodes-test-key",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episodes=[] episodes=[]
) )
@ -408,8 +477,10 @@ class TestDownloadRequest:
"""Test that empty serie name is rejected.""" """Test that empty serie name is rejected."""
episode = EpisodeIdentifier(season=1, episode=1) episode = EpisodeIdentifier(season=1, episode=1)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
# Note: serie_id uses provider key format (URL-safe, lowercase)
DownloadRequest( DownloadRequest(
serie_id="serie_123", serie_id="empty-name-request-key",
serie_folder="Test Series (2023)",
serie_name="", serie_name="",
episodes=[episode] episodes=[episode]
) )
@ -453,7 +524,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 == []
@ -509,23 +583,28 @@ class TestModelSerialization:
def test_download_item_to_dict(self): def test_download_item_to_dict(self):
"""Test serializing download item to dict.""" """Test serializing download item to dict."""
episode = EpisodeIdentifier(season=1, episode=5, title="Test") episode = EpisodeIdentifier(season=1, episode=5, title="Test")
# Note: serie_id uses provider key format (URL-safe, lowercase)
item = DownloadItem( item = DownloadItem(
id="test_id", id="test_id",
serie_id="serie_id", serie_id="serialization-test-key",
serie_folder="Test Series (2023)",
serie_name="Test Series", serie_name="Test Series",
episode=episode episode=episode
) )
data = item.model_dump() data = item.model_dump()
assert data["id"] == "test_id" assert data["id"] == "test_id"
assert data["serie_id"] == "serialization-test-key"
assert data["serie_name"] == "Test Series" assert data["serie_name"] == "Test Series"
assert data["episode"]["season"] == 1 assert data["episode"]["season"] == 1
assert data["episode"]["episode"] == 5 assert data["episode"]["episode"] == 5
def test_download_item_from_dict(self): def test_download_item_from_dict(self):
"""Test deserializing download item from dict.""" """Test deserializing download item from dict."""
# Note: serie_id uses provider key format (URL-safe, lowercase)
data = { data = {
"id": "test_id", "id": "test_id",
"serie_id": "serie_id", "serie_id": "deserialize-test-key",
"serie_folder": "Test Series (2023)",
"serie_name": "Test Series", "serie_name": "Test Series",
"episode": { "episode": {
"season": 1, "season": 1,
@ -535,6 +614,7 @@ class TestModelSerialization:
} }
item = DownloadItem(**data) item = DownloadItem(**data)
assert item.id == "test_id" assert item.id == "test_id"
assert item.serie_id == "deserialize-test-key"
assert item.serie_name == "Test Series" assert item.serie_name == "Test Series"
assert item.episode.season == 1 assert item.episode.season == 1

View File

@ -39,20 +39,23 @@ def mock_series_app():
class MockDownloadArgs: class MockDownloadArgs:
def __init__( def __init__(
self, status, serie_folder, season, episode, self, status, serie_folder, season, episode,
progress=None, message=None, error=None key=None, progress=None, message=None, error=None,
item_id=None
): ):
self.status = status self.status = status
self.serie_folder = serie_folder self.serie_folder = serie_folder
self.key = key
self.season = season self.season = season
self.episode = episode self.episode = episode
self.progress = progress self.progress = progress
self.message = message self.message = message
self.error = error self.error = error
self.item_id = item_id
# Trigger started event # Trigger started event
if app.download_status: if app.download_status:
app.download_status(MockDownloadArgs( app.download_status(MockDownloadArgs(
"started", serie_folder, season, episode "started", serie_folder, season, episode, key=key
)) ))
# Simulate progress updates # Simulate progress updates
@ -62,6 +65,7 @@ def mock_series_app():
await asyncio.sleep(0.01) # Small delay await asyncio.sleep(0.01) # Small delay
app.download_status(MockDownloadArgs( app.download_status(MockDownloadArgs(
"progress", serie_folder, season, episode, "progress", serie_folder, season, episode,
key=key,
progress=progress, progress=progress,
message=f"Downloading... {progress}%" message=f"Downloading... {progress}%"
)) ))
@ -69,10 +73,12 @@ def mock_series_app():
# Trigger completed event # Trigger completed event
if app.download_status: if app.download_status:
app.download_status(MockDownloadArgs( app.download_status(MockDownloadArgs(
"completed", serie_folder, season, episode "completed", serie_folder, season, episode, key=key
)) ))
return True return True
return True
app.download = Mock(side_effect=mock_download) app.download = Mock(side_effect=mock_download)
return app return app
@ -141,9 +147,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add item to queue # Add item to queue
# Note: serie_id uses provider key format (URL-safe, lowercase)
item_ids = await download_svc.add_to_queue( item_ids = await download_svc.add_to_queue(
serie_id="test_serie_1", serie_id="test-serie-1-key",
serie_folder="test_serie_1", serie_folder="Test Anime (2024)",
serie_name="Test Anime", serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL, priority=DownloadPriority.NORMAL,
@ -191,9 +198,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add item with specific episode info # Add item with specific episode info
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_2", serie_id="test-serie-2-key",
serie_folder="test_serie_2", serie_folder="My Test Anime (2024)",
serie_name="My Test Anime", serie_name="My Test Anime",
episodes=[EpisodeIdentifier(season=2, episode=5)], episodes=[EpisodeIdentifier(season=2, episode=5)],
priority=DownloadPriority.HIGH, priority=DownloadPriority.HIGH,
@ -213,8 +221,9 @@ class TestDownloadProgressWebSocket:
# Verify progress info is included # Verify progress info is included
data = progress_broadcasts[0]["data"] data = progress_broadcasts[0]["data"]
assert "id" in data assert "id" in data
# ID should contain folder name: download_test_serie_2_2_5 # ID contains folder name: download_My Test Anime (2024)_2_5
assert "test_serie_2" in data["id"] # Check for folder name substring (case-insensitive)
assert "my test anime" in data["id"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_progress_percent_increases(self, download_service): async def test_progress_percent_increases(self, download_service):
@ -230,9 +239,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_3", serie_id="test-serie-3-key",
serie_folder="test_serie_3", serie_folder="Progress Test (2024)",
serie_name="Progress Test", serie_name="Progress Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -271,9 +281,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_4", serie_id="test-serie-4-key",
serie_folder="test_serie_4", serie_folder="Speed Test (2024)",
serie_name="Speed Test", serie_name="Speed Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -299,9 +310,10 @@ class TestDownloadProgressWebSocket:
download_svc, progress_svc = download_service download_svc, progress_svc = download_service
# Don't subscribe to any events # Don't subscribe to any events
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_5", serie_id="test-serie-5-key",
serie_folder="test_serie_5", serie_folder="No Broadcast Test (2024)",
serie_name="No Broadcast Test", serie_name="No Broadcast Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -328,9 +340,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", failing_handler) progress_svc.subscribe("progress_updated", failing_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_6", serie_id="test-serie-6-key",
serie_folder="test_serie_6", serie_folder="Error Handling Test (2024)",
serie_name="Error Handling Test", serie_name="Error Handling Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=1)],
) )
@ -363,9 +376,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Add multiple episodes # Add multiple episodes
# Note: serie_id uses provider key format (URL-safe, lowercase)
item_ids = await download_svc.add_to_queue( item_ids = await download_svc.add_to_queue(
serie_id="test_serie_7", serie_id="test-serie-7-key",
serie_folder="test_serie_7", serie_folder="Multi Episode Test (2024)",
serie_name="Multi Episode Test", serie_name="Multi Episode Test",
episodes=[ episodes=[
EpisodeIdentifier(season=1, episode=1), EpisodeIdentifier(season=1, episode=1),
@ -412,9 +426,10 @@ class TestDownloadProgressWebSocket:
progress_svc.subscribe("progress_updated", mock_event_handler) progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue( await download_svc.add_to_queue(
serie_id="test_serie_8", serie_id="test-serie-8-key",
serie_folder="test_serie_8", serie_folder="Model Test (2024)",
serie_name="Model Test", serie_name="Model Test",
episodes=[EpisodeIdentifier(season=1, episode=1)], episodes=[EpisodeIdentifier(season=1, episode=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,

View File

@ -508,3 +508,94 @@ class TestProgressService:
assert progress.metadata["initial"] == "value" assert progress.metadata["initial"] == "value"
assert progress.metadata["additional"] == "data" assert progress.metadata["additional"] == "data"
assert progress.metadata["speed"] == 1.5 assert progress.metadata["speed"] == 1.5
@pytest.mark.asyncio
async def test_progress_with_key_and_folder(self, service):
"""Test progress tracking with series key and folder."""
# Start progress with key and folder
update = await service.start_progress(
progress_id="download-series-1",
progress_type=ProgressType.DOWNLOAD,
title="Downloading Attack on Titan",
key="attack-on-titan",
folder="Attack on Titan (2013)",
total=100,
)
assert update.key == "attack-on-titan"
assert update.folder == "Attack on Titan (2013)"
# Verify to_dict includes key and folder
dict_repr = update.to_dict()
assert dict_repr["key"] == "attack-on-titan"
assert dict_repr["folder"] == "Attack on Titan (2013)"
# Update progress and verify key/folder are preserved
updated = await service.update_progress(
progress_id="download-series-1",
current=50,
)
assert updated.key == "attack-on-titan"
assert updated.folder == "Attack on Titan (2013)"
@pytest.mark.asyncio
async def test_progress_update_key_and_folder(self, service):
"""Test updating key and folder in existing progress."""
# Start without key/folder
await service.start_progress(
progress_id="test-1",
progress_type=ProgressType.SCAN,
title="Test Scan",
)
# Update with key and folder
updated = await service.update_progress(
progress_id="test-1",
key="one-piece",
folder="One Piece (1999)",
current=10,
)
assert updated.key == "one-piece"
assert updated.folder == "One Piece (1999)"
# Verify to_dict includes the fields
dict_repr = updated.to_dict()
assert dict_repr["key"] == "one-piece"
assert dict_repr["folder"] == "One Piece (1999)"
def test_progress_update_to_dict_without_key_folder(self):
"""Test to_dict doesn't include key/folder if not set."""
update = ProgressUpdate(
id="test-1",
type=ProgressType.SYSTEM,
status=ProgressStatus.STARTED,
title="System Task",
)
result = update.to_dict()
# key and folder should not be in dict if not set
assert "key" not in result
assert "folder" not in result
def test_progress_update_creation_with_key_folder(self):
"""Test creating progress update with key and folder."""
update = ProgressUpdate(
id="test-1",
type=ProgressType.DOWNLOAD,
status=ProgressStatus.STARTED,
title="Test Download",
key="naruto",
folder="Naruto (2002)",
total=100,
)
assert update.key == "naruto"
assert update.folder == "Naruto (2002)"
# Verify to_dict includes them
result = update.to_dict()
assert result["key"] == "naruto"
assert result["folder"] == "Naruto (2002)"

View File

@ -0,0 +1,739 @@
"""Unit tests for ScanService.
This module contains comprehensive tests for the scan service,
including scan lifecycle, progress callbacks, event handling,
and key-based identification.
"""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.server.services.scan_service import (
ScanProgress,
ScanService,
ScanServiceCompletionCallback,
ScanServiceError,
ScanServiceErrorCallback,
ScanServiceProgressCallback,
get_scan_service,
reset_scan_service,
)
class TestScanProgress:
"""Test ScanProgress class."""
def test_scan_progress_creation(self):
"""Test creating a scan progress instance."""
progress = ScanProgress("scan-123")
assert progress.scan_id == "scan-123"
assert progress.status == "started"
assert progress.current == 0
assert progress.total == 0
assert progress.percentage == 0.0
assert progress.message == "Initializing scan..."
assert progress.key is None
assert progress.folder is None
assert progress.series_found == 0
assert progress.errors == []
assert isinstance(progress.started_at, datetime)
assert isinstance(progress.updated_at, datetime)
def test_scan_progress_to_dict_basic(self):
"""Test converting scan progress to dictionary without key/folder."""
progress = ScanProgress("scan-123")
progress.current = 5
progress.total = 10
progress.percentage = 50.0
progress.status = "in_progress"
progress.message = "Scanning..."
result = progress.to_dict()
assert result["scan_id"] == "scan-123"
assert result["status"] == "in_progress"
assert result["current"] == 5
assert result["total"] == 10
assert result["percentage"] == 50.0
assert result["message"] == "Scanning..."
assert result["series_found"] == 0
assert result["errors"] == []
assert "started_at" in result
assert "updated_at" in result
# key and folder should not be present when None
assert "key" not in result
assert "folder" not in result
def test_scan_progress_to_dict_with_key_and_folder(self):
"""Test converting scan progress to dictionary with key and folder."""
progress = ScanProgress("scan-123")
progress.key = "attack-on-titan"
progress.folder = "Attack on Titan (2013)"
progress.series_found = 5
result = progress.to_dict()
assert result["key"] == "attack-on-titan"
assert result["folder"] == "Attack on Titan (2013)"
assert result["series_found"] == 5
def test_scan_progress_to_dict_with_errors(self):
"""Test scan progress with error messages."""
progress = ScanProgress("scan-123")
progress.errors = ["Error 1", "Error 2"]
result = progress.to_dict()
assert result["errors"] == ["Error 1", "Error 2"]
class TestScanServiceProgressCallback:
"""Test ScanServiceProgressCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_progress_update = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_progress_updates_progress(self, mock_service, scan_progress):
"""Test that on_progress updates scan progress correctly."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.IN_PROGRESS,
current=5,
total=10,
percentage=50.0,
message="Scanning: Test Folder",
key="test-series",
folder="Test Folder",
)
# Call directly - no event loop needed since we handle RuntimeError
callback.on_progress(context)
assert scan_progress.current == 5
assert scan_progress.total == 10
assert scan_progress.percentage == 50.0
assert scan_progress.message == "Scanning: Test Folder"
assert scan_progress.key == "test-series"
assert scan_progress.folder == "Test Folder"
assert scan_progress.status == "in_progress"
def test_on_progress_starting_phase(self, mock_service, scan_progress):
"""Test progress callback with STARTING phase."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing...",
)
callback.on_progress(context)
assert scan_progress.status == "started"
def test_on_progress_completed_phase(self, mock_service, scan_progress):
"""Test progress callback with COMPLETED phase."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.COMPLETED,
current=10,
total=10,
percentage=100.0,
message="Scan completed",
)
callback.on_progress(context)
assert scan_progress.status == "completed"
class TestScanServiceErrorCallback:
"""Test ScanServiceErrorCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_scan_error = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_error_adds_error_message(self, mock_service, scan_progress):
"""Test that on_error adds error to scan progress."""
callback = ScanServiceErrorCallback(mock_service, scan_progress)
error = ValueError("Test error")
context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
error=error,
message="Failed to process folder",
recoverable=True,
key="test-series",
folder="Test Folder",
)
callback.on_error(context)
assert len(scan_progress.errors) == 1
assert "[Test Folder]" in scan_progress.errors[0]
assert "Failed to process folder" in scan_progress.errors[0]
def test_on_error_without_folder(self, mock_service, scan_progress):
"""Test error callback without folder information."""
callback = ScanServiceErrorCallback(mock_service, scan_progress)
error = ValueError("Test error")
context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
error=error,
message="Generic error",
recoverable=False,
)
callback.on_error(context)
assert len(scan_progress.errors) == 1
assert scan_progress.errors[0] == "Generic error"
class TestScanServiceCompletionCallback:
"""Test ScanServiceCompletionCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_scan_completion = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_completion_success(self, mock_service, scan_progress):
"""Test completion callback with success."""
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
success=True,
message="Scan completed successfully",
statistics={"series_found": 10, "total_folders": 15},
)
callback.on_completion(context)
assert scan_progress.status == "completed"
assert scan_progress.message == "Scan completed successfully"
assert scan_progress.series_found == 10
def test_on_completion_failure(self, mock_service, scan_progress):
"""Test completion callback with failure."""
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
success=False,
message="Scan failed: critical error",
)
callback.on_completion(context)
assert scan_progress.status == "failed"
assert scan_progress.message == "Scan failed: critical error"
class TestScanService:
"""Test ScanService class."""
@pytest.fixture
def mock_progress_service(self):
"""Create a mock progress service."""
service = MagicMock()
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def service(self, mock_progress_service):
"""Create a ScanService instance for each test."""
return ScanService(progress_service=mock_progress_service)
@pytest.mark.asyncio
async def test_service_initialization(self, service):
"""Test ScanService initialization."""
assert service.is_scanning is False
assert service.current_scan is None
assert service._scan_history == []
@pytest.mark.asyncio
async def test_start_scan(self, service):
"""Test starting a new scan."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
assert scan_id is not None
assert len(scan_id) > 0
assert service.is_scanning is True
assert service.current_scan is not None
assert service.current_scan.scan_id == scan_id
@pytest.mark.asyncio
async def test_start_scan_while_scanning(self, service):
"""Test starting scan while another is in progress raises error."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
with pytest.raises(ScanServiceError, match="already in progress"):
await service.start_scan(scanner_factory)
@pytest.mark.asyncio
async def test_cancel_scan(self, service):
"""Test cancelling a scan in progress."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
result = await service.cancel_scan()
assert result is True
assert service.is_scanning is False
assert service.current_scan.status == "cancelled"
assert len(service._scan_history) == 1
@pytest.mark.asyncio
async def test_cancel_scan_no_scan_in_progress(self, service):
"""Test cancelling when no scan is in progress."""
result = await service.cancel_scan()
assert result is False
@pytest.mark.asyncio
async def test_get_scan_status(self, service):
"""Test getting scan status."""
status = await service.get_scan_status()
assert status["is_scanning"] is False
assert status["current_scan"] is None
@pytest.mark.asyncio
async def test_get_scan_status_while_scanning(self, service):
"""Test getting scan status while scanning."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
status = await service.get_scan_status()
assert status["is_scanning"] is True
assert status["current_scan"] is not None
assert status["current_scan"]["scan_id"] == scan_id
@pytest.mark.asyncio
async def test_get_scan_history_empty(self, service):
"""Test getting scan history when empty."""
history = await service.get_scan_history()
assert history == []
@pytest.mark.asyncio
async def test_get_scan_history_with_entries(self, service):
"""Test getting scan history with entries."""
# Start and cancel multiple scans to populate history
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
await service.cancel_scan()
await service.start_scan(scanner_factory)
await service.cancel_scan()
history = await service.get_scan_history()
assert len(history) == 2
# Should be newest first
assert history[0]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_get_scan_history_limit(self, service):
"""Test scan history respects limit."""
scanner_factory = MagicMock()
# Create 3 history entries
for _ in range(3):
await service.start_scan(scanner_factory)
await service.cancel_scan()
history = await service.get_scan_history(limit=2)
assert len(history) == 2
@pytest.mark.asyncio
async def test_subscribe_to_scan_events(self, service):
"""Test subscribing to scan events."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
assert handler in service._scan_event_handlers
@pytest.mark.asyncio
async def test_unsubscribe_from_scan_events(self, service):
"""Test unsubscribing from scan events."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
service.unsubscribe_from_scan_events(handler)
assert handler not in service._scan_event_handlers
@pytest.mark.asyncio
async def test_emit_scan_event(self, service):
"""Test emitting scan events to handlers."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
await service._emit_scan_event({
"type": "scan_progress",
"key": "test-series",
"folder": "Test Folder",
})
handler.assert_called_once_with({
"type": "scan_progress",
"key": "test-series",
"folder": "Test Folder",
})
@pytest.mark.asyncio
async def test_emit_scan_event_sync_handler(self, service):
"""Test emitting scan events to sync handlers."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
await service._emit_scan_event({
"type": "scan_progress",
"data": {"key": "test-series"},
})
handler.assert_called_once()
@pytest.mark.asyncio
async def test_create_callback_manager(self, service):
"""Test creating a callback manager."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
callback_manager = service.create_callback_manager()
assert callback_manager is not None
assert isinstance(callback_manager, CallbackManager)
@pytest.mark.asyncio
async def test_create_callback_manager_no_current_scan(self, service):
"""Test creating callback manager without current scan."""
callback_manager = service.create_callback_manager()
assert callback_manager is not None
assert service.current_scan is not None
@pytest.mark.asyncio
async def test_handle_progress_update(
self, service, mock_progress_service
):
"""Test handling progress update."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
scan_progress.current = 5
scan_progress.total = 10
scan_progress.percentage = 50.0
scan_progress.message = "Processing..."
scan_progress.key = "test-series"
scan_progress.folder = "Test Folder"
await service._handle_progress_update(scan_progress)
mock_progress_service.update_progress.assert_called_once()
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
assert call_kwargs["key"] == "test-series"
assert call_kwargs["folder"] == "Test Folder"
@pytest.mark.asyncio
async def test_handle_scan_error(self, service):
"""Test handling scan error."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
error_context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id=scan_progress.scan_id,
error=ValueError("Test error"),
message="Test error message",
recoverable=True,
key="test-series",
folder="Test Folder",
)
await service._handle_scan_error(scan_progress, error_context)
# Handler is called twice: once for start, once for error
assert handler.call_count == 2
# Get the error event (second call)
error_event = handler.call_args_list[1][0][0]
assert error_event["type"] == "scan_error"
assert error_event["key"] == "test-series"
assert error_event["folder"] == "Test Folder"
@pytest.mark.asyncio
async def test_handle_scan_completion_success(
self, service, mock_progress_service
):
"""Test handling successful scan completion."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
scan_progress = service.current_scan
completion_context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id=scan_id,
success=True,
message="Scan completed",
statistics={"series_found": 5, "total_folders": 10},
)
await service._handle_scan_completion(
scan_progress, completion_context
)
assert service.is_scanning is False
assert len(service._scan_history) == 1
mock_progress_service.complete_progress.assert_called_once()
# Handler is called twice: once for start, once for completion
assert handler.call_count == 2
# Get the completion event (second call)
completion_event = handler.call_args_list[1][0][0]
assert completion_event["type"] == "scan_completed"
assert completion_event["success"] is True
@pytest.mark.asyncio
async def test_handle_scan_completion_failure(
self, service, mock_progress_service
):
"""Test handling failed scan completion."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
scan_progress = service.current_scan
completion_context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id=scan_id,
success=False,
message="Scan failed: critical error",
)
await service._handle_scan_completion(
scan_progress, completion_context
)
assert service.is_scanning is False
mock_progress_service.fail_progress.assert_called_once()
# Handler is called twice: once for start, once for failure
assert handler.call_count == 2
# Get the failure event (second call)
failure_event = handler.call_args_list[1][0][0]
assert failure_event["type"] == "scan_failed"
assert failure_event["success"] is False
class TestScanServiceSingleton:
"""Test ScanService singleton functions."""
def test_get_scan_service_returns_singleton(self):
"""Test that get_scan_service returns a singleton."""
reset_scan_service()
service1 = get_scan_service()
service2 = get_scan_service()
assert service1 is service2
def test_reset_scan_service(self):
"""Test that reset_scan_service clears the singleton."""
reset_scan_service()
service1 = get_scan_service()
reset_scan_service()
service2 = get_scan_service()
assert service1 is not service2
class TestScanServiceKeyIdentification:
"""Test that ScanService uses key as primary identifier."""
@pytest.fixture
def mock_progress_service(self):
"""Create a mock progress service."""
service = MagicMock()
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def service(self, mock_progress_service):
"""Create a ScanService instance."""
return ScanService(progress_service=mock_progress_service)
@pytest.mark.asyncio
async def test_progress_update_includes_key(
self, service, mock_progress_service
):
"""Test that progress updates include key as primary identifier."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
scan_progress.key = "attack-on-titan"
scan_progress.folder = "Attack on Titan (2013)"
await service._handle_progress_update(scan_progress)
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
assert call_kwargs["key"] == "attack-on-titan"
assert call_kwargs["folder"] == "Attack on Titan (2013)"
@pytest.mark.asyncio
async def test_scan_event_includes_key(self, service):
"""Test that scan events include key as primary identifier."""
events_received = []
async def capture_event(event):
events_received.append(event)
service.subscribe_to_scan_events(capture_event)
await service._emit_scan_event({
"type": "scan_progress",
"key": "my-hero-academia",
"folder": "My Hero Academia (2016)",
"data": {"status": "in_progress"},
})
assert len(events_received) == 1
assert events_received[0]["key"] == "my-hero-academia"
assert events_received[0]["folder"] == "My Hero Academia (2016)"
@pytest.mark.asyncio
async def test_error_event_includes_key(self, service):
"""Test that error events include key as primary identifier."""
events_received = []
async def capture_event(event):
events_received.append(event)
service.subscribe_to_scan_events(capture_event)
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
error_context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id=scan_progress.scan_id,
error=ValueError("Test"),
message="Error message",
key="demon-slayer",
folder="Demon Slayer (2019)",
)
await service._handle_scan_error(scan_progress, error_context)
assert len(events_received) == 2 # Started + error
error_event = events_received[1]
assert error_event["type"] == "scan_error"
assert error_event["key"] == "demon-slayer"
assert error_event["folder"] == "Demon Slayer (2019)"
@pytest.mark.asyncio
async def test_scan_status_includes_key(self, service):
"""Test that scan status includes key in current scan."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
service.current_scan.key = "one-piece"
service.current_scan.folder = "One Piece (1999)"
status = await service.get_scan_status()
assert status["current_scan"]["key"] == "one-piece"
assert status["current_scan"]["folder"] == "One Piece (1999)"
@pytest.mark.asyncio
async def test_scan_history_includes_key(self, service):
"""Test that scan history includes key in entries."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
service.current_scan.key = "naruto"
service.current_scan.folder = "Naruto (2002)"
await service.cancel_scan()
history = await service.get_scan_history()
assert len(history) == 1
assert history[0]["key"] == "naruto"
assert history[0]["folder"] == "Naruto (2002)"

View File

@ -0,0 +1,244 @@
"""
Unit tests for Serie class to verify key validation and identifier usage.
"""
import json
import os
import tempfile
import pytest
from src.core.entities.series import Serie
class TestSerieValidation:
"""Test Serie class validation logic."""
def test_serie_creation_with_valid_key(self):
"""Test creating Serie with valid key."""
serie = Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3], 2: [1, 2]}
)
assert serie.key == "attack-on-titan"
assert serie.name == "Attack on Titan"
assert serie.site == "https://aniworld.to/anime/stream/attack-on-titan"
assert serie.folder == "Attack on Titan (2013)"
assert serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
def test_serie_creation_with_empty_key_raises_error(self):
"""Test that creating Serie with empty key raises ValueError."""
with pytest.raises(ValueError, match="key cannot be None or empty"):
Serie(
key="",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
def test_serie_creation_with_whitespace_key_raises_error(self):
"""Test that creating Serie with whitespace-only key raises error."""
with pytest.raises(ValueError, match="key cannot be None or empty"):
Serie(
key=" ",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
def test_serie_key_is_stripped(self):
"""Test that Serie key is stripped of whitespace."""
serie = Serie(
key=" attack-on-titan ",
name="Attack on Titan",
site="https://example.com",
folder="Attack on Titan (2013)",
episodeDict={1: [1]}
)
assert serie.key == "attack-on-titan"
def test_serie_key_setter_with_valid_value(self):
"""Test setting key property with valid value."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
serie.key = "new-key"
assert serie.key == "new-key"
def test_serie_key_setter_with_empty_value_raises_error(self):
"""Test that setting key to empty string raises ValueError."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
with pytest.raises(ValueError, match="key cannot be None or empty"):
serie.key = ""
def test_serie_key_setter_with_whitespace_raises_error(self):
"""Test that setting key to whitespace raises ValueError."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
with pytest.raises(ValueError, match="key cannot be None or empty"):
serie.key = " "
def test_serie_key_setter_strips_whitespace(self):
"""Test that key setter strips whitespace."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
serie.key = " new-key "
assert serie.key == "new-key"
class TestSerieProperties:
"""Test Serie class properties and methods."""
def test_serie_str_representation(self):
"""Test string representation of Serie."""
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2]}
)
str_repr = str(serie)
assert "key='test-key'" in str_repr
assert "name='Test Series'" in str_repr
assert "folder='Test Folder'" in str_repr
def test_serie_to_dict(self):
"""Test conversion of Serie to dictionary."""
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2], 2: [1, 2, 3]}
)
data = serie.to_dict()
assert data["key"] == "test-key"
assert data["name"] == "Test Series"
assert data["site"] == "https://example.com"
assert data["folder"] == "Test Folder"
assert "1" in data["episodeDict"]
assert data["episodeDict"]["1"] == [1, 2]
def test_serie_from_dict(self):
"""Test creating Serie from dictionary."""
data = {
"key": "test-key",
"name": "Test Series",
"site": "https://example.com",
"folder": "Test Folder",
"episodeDict": {"1": [1, 2], "2": [1, 2, 3]}
}
serie = Serie.from_dict(data)
assert serie.key == "test-key"
assert serie.name == "Test Series"
assert serie.folder == "Test Folder"
assert serie.episodeDict == {1: [1, 2], 2: [1, 2, 3]}
def test_serie_save_and_load_from_file(self):
"""Test saving and loading Serie from file."""
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2, 3]}
)
# Create temporary file
with tempfile.NamedTemporaryFile(
mode='w',
delete=False,
suffix='.json'
) as f:
temp_filename = f.name
try:
# Save to file
serie.save_to_file(temp_filename)
# Load from file
loaded_serie = Serie.load_from_file(temp_filename)
# Verify all properties match
assert loaded_serie.key == serie.key
assert loaded_serie.name == serie.name
assert loaded_serie.site == serie.site
assert loaded_serie.folder == serie.folder
assert loaded_serie.episodeDict == serie.episodeDict
finally:
# Cleanup
if os.path.exists(temp_filename):
os.remove(temp_filename)
def test_serie_folder_is_mutable(self):
"""Test that folder property can be changed (it's metadata only)."""
serie = Serie(
key="test-key",
name="Test",
site="https://example.com",
folder="Old Folder",
episodeDict={1: [1]}
)
serie.folder = "New Folder"
assert serie.folder == "New Folder"
# Key should remain unchanged
assert serie.key == "test-key"
class TestSerieDocumentation:
"""Test that Serie class has proper documentation."""
def test_serie_class_has_docstring(self):
"""Test that Serie class has a docstring."""
assert Serie.__doc__ is not None
assert "unique identifier" in Serie.__doc__.lower()
def test_key_property_has_docstring(self):
"""Test that key property has descriptive docstring."""
assert Serie.key.fget.__doc__ is not None
assert "unique" in Serie.key.fget.__doc__.lower()
assert "identifier" in Serie.key.fget.__doc__.lower()
def test_folder_property_has_docstring(self):
"""Test that folder property documents it's metadata only."""
assert Serie.folder.fget.__doc__ is not None
assert "metadata" in Serie.folder.fget.__doc__.lower()
assert "not used for lookups" in Serie.folder.fget.__doc__.lower()

View File

@ -0,0 +1,203 @@
"""Tests for SerieList class - identifier standardization."""
import os
import tempfile
import pytest
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
@pytest.fixture
def temp_directory():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3]}
)
class TestSerieListKeyBasedStorage:
"""Test SerieList uses key for internal storage."""
def test_init_creates_empty_keydict(self, temp_directory):
"""Test initialization creates keyDict."""
serie_list = SerieList(temp_directory)
assert hasattr(serie_list, 'keyDict')
assert isinstance(serie_list.keyDict, dict)
def test_add_stores_by_key(self, temp_directory, sample_serie):
"""Test add() stores series by key."""
serie_list = SerieList(temp_directory)
serie_list.add(sample_serie)
# Verify stored by key, not folder
assert sample_serie.key in serie_list.keyDict
assert serie_list.keyDict[sample_serie.key] == sample_serie
def test_contains_checks_by_key(self, temp_directory, sample_serie):
"""Test contains() checks by key."""
serie_list = SerieList(temp_directory)
serie_list.add(sample_serie)
assert serie_list.contains(sample_serie.key)
assert not serie_list.contains("nonexistent-key")
def test_add_prevents_duplicates_by_key(
self, temp_directory, sample_serie
):
"""Test add() prevents duplicates based on key."""
serie_list = SerieList(temp_directory)
# Add same serie twice
serie_list.add(sample_serie)
initial_count = len(serie_list.keyDict)
serie_list.add(sample_serie)
# Should still have only one entry
assert len(serie_list.keyDict) == initial_count
assert len(serie_list.keyDict) == 1
def test_get_by_key_returns_correct_serie(
self, temp_directory, sample_serie
):
"""Test get_by_key() retrieves series correctly."""
serie_list = SerieList(temp_directory)
serie_list.add(sample_serie)
result = serie_list.get_by_key(sample_serie.key)
assert result is not None
assert result.key == sample_serie.key
assert result.name == sample_serie.name
def test_get_by_key_returns_none_for_missing(self, temp_directory):
"""Test get_by_key() returns None for nonexistent key."""
serie_list = SerieList(temp_directory)
result = serie_list.get_by_key("nonexistent-key")
assert result is None
def test_get_by_folder_backward_compatibility(
self, temp_directory, sample_serie
):
"""Test get_by_folder() provides backward compatibility."""
serie_list = SerieList(temp_directory)
serie_list.add(sample_serie)
result = serie_list.get_by_folder(sample_serie.folder)
assert result is not None
assert result.key == sample_serie.key
assert result.folder == sample_serie.folder
def test_get_by_folder_returns_none_for_missing(self, temp_directory):
"""Test get_by_folder() returns None for nonexistent folder."""
serie_list = SerieList(temp_directory)
result = serie_list.get_by_folder("Nonexistent Folder")
assert result is None
def test_get_all_returns_all_series(self, temp_directory, sample_serie):
"""Test get_all() returns all series from keyDict."""
serie_list = SerieList(temp_directory)
serie_list.add(sample_serie)
serie2 = Serie(
key="naruto",
name="Naruto",
site="https://aniworld.to/anime/stream/naruto",
folder="Naruto (2002)",
episodeDict={1: [1, 2]}
)
serie_list.add(serie2)
all_series = serie_list.get_all()
assert len(all_series) == 2
assert sample_serie in all_series
assert serie2 in all_series
def test_get_missing_episodes_filters_by_episode_dict(
self, temp_directory
):
"""Test get_missing_episodes() returns only series with episodes."""
serie_list = SerieList(temp_directory)
# Serie with missing episodes
serie_with_episodes = Serie(
key="serie-with-episodes",
name="Serie With Episodes",
site="https://aniworld.to/anime/stream/serie-with-episodes",
folder="Serie With Episodes (2020)",
episodeDict={1: [1, 2, 3]}
)
# Serie without missing episodes
serie_without_episodes = Serie(
key="serie-without-episodes",
name="Serie Without Episodes",
site="https://aniworld.to/anime/stream/serie-without-episodes",
folder="Serie Without Episodes (2021)",
episodeDict={}
)
serie_list.add(serie_with_episodes)
serie_list.add(serie_without_episodes)
missing = serie_list.get_missing_episodes()
assert len(missing) == 1
assert serie_with_episodes in missing
assert serie_without_episodes not in missing
def test_load_series_stores_by_key(self, temp_directory, sample_serie):
"""Test load_series() stores series by key when loading from disk."""
# Create directory structure and save serie
folder_path = os.path.join(temp_directory, sample_serie.folder)
os.makedirs(folder_path, exist_ok=True)
data_path = os.path.join(folder_path, "data")
sample_serie.save_to_file(data_path)
# Create new SerieList (triggers load_series in __init__)
serie_list = SerieList(temp_directory)
# Verify loaded by key
assert sample_serie.key in serie_list.keyDict
loaded_serie = serie_list.keyDict[sample_serie.key]
assert loaded_serie.key == sample_serie.key
assert loaded_serie.name == sample_serie.name
class TestSerieListPublicAPI:
"""Test that public API still works correctly."""
def test_public_methods_work(self, temp_directory, sample_serie):
"""Test that all public methods work correctly after refactoring."""
serie_list = SerieList(temp_directory)
# Test add
serie_list.add(sample_serie)
# Test contains
assert serie_list.contains(sample_serie.key)
# Test GetList/get_all
assert len(serie_list.GetList()) == 1
assert len(serie_list.get_all()) == 1
# Test GetMissingEpisode/get_missing_episodes
assert len(serie_list.GetMissingEpisode()) == 1
assert len(serie_list.get_missing_episodes()) == 1
# Test new helper methods
assert serie_list.get_by_key(sample_serie.key) is not None
assert serie_list.get_by_folder(sample_serie.folder) is not None

View File

@ -1,15 +1,19 @@
""" """
Tests for template helper utilities. Tests for template helper utilities.
This module tests the template helper functions. This module tests the template helper functions including series context
preparation using `key` as the primary identifier.
""" """
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
from src.server.utils.template_helpers import ( from src.server.utils.template_helpers import (
filter_series_by_missing_episodes,
get_base_context, get_base_context,
get_series_by_key,
list_available_templates, list_available_templates,
prepare_series_context,
validate_template_exists, validate_template_exists,
) )
@ -84,3 +88,156 @@ class TestTemplateHelpers:
"""Test that all required templates exist.""" """Test that all required templates exist."""
assert validate_template_exists(template_name), \ assert validate_template_exists(template_name), \
f"Required template {template_name} does not exist" f"Required template {template_name} does not exist"
class TestSeriesContextHelpers:
"""Test series context helper functions.
These tests verify that series helpers use `key` as the primary
identifier following the project's identifier convention.
"""
def test_prepare_series_context_uses_key(self):
"""Test that prepare_series_context uses key as primary identifier."""
series_data = [
{
"key": "attack-on-titan",
"name": "Attack on Titan",
"folder": "Attack on Titan (2013)",
},
{
"key": "one-piece",
"name": "One Piece",
"folder": "One Piece (1999)",
},
]
prepared = prepare_series_context(series_data)
assert len(prepared) == 2
# Verify key is present and used
assert prepared[0]["key"] in ("attack-on-titan", "one-piece")
assert all("key" in item for item in prepared)
assert all("folder" in item for item in prepared)
def test_prepare_series_context_sorts_by_name(self):
"""Test that series are sorted by name by default."""
series_data = [
{"key": "z-series", "name": "Zebra Anime", "folder": "z"},
{"key": "a-series", "name": "Alpha Anime", "folder": "a"},
]
prepared = prepare_series_context(series_data, sort_by="name")
assert prepared[0]["name"] == "Alpha Anime"
assert prepared[1]["name"] == "Zebra Anime"
def test_prepare_series_context_sorts_by_key(self):
"""Test that series can be sorted by key."""
series_data = [
{"key": "z-series", "name": "Zebra", "folder": "z"},
{"key": "a-series", "name": "Alpha", "folder": "a"},
]
prepared = prepare_series_context(series_data, sort_by="key")
assert prepared[0]["key"] == "a-series"
assert prepared[1]["key"] == "z-series"
def test_prepare_series_context_empty_list(self):
"""Test prepare_series_context with empty list."""
prepared = prepare_series_context([])
assert prepared == []
def test_prepare_series_context_skips_missing_key(self):
"""Test that items without key are skipped with warning."""
series_data = [
{"key": "valid-series", "name": "Valid", "folder": "valid"},
{"name": "No Key", "folder": "nokey"}, # Missing key
]
prepared = prepare_series_context(series_data)
assert len(prepared) == 1
assert prepared[0]["key"] == "valid-series"
def test_prepare_series_context_preserves_extra_fields(self):
"""Test that extra fields are preserved."""
series_data = [
{
"key": "test",
"name": "Test",
"folder": "test",
"missing_episodes": {"1": [1, 2]},
"site": "aniworld.to",
}
]
prepared = prepare_series_context(series_data)
assert prepared[0]["missing_episodes"] == {"1": [1, 2]}
assert prepared[0]["site"] == "aniworld.to"
def test_get_series_by_key_found(self):
"""Test finding a series by key."""
series_data = [
{"key": "attack-on-titan", "name": "Attack on Titan"},
{"key": "one-piece", "name": "One Piece"},
]
result = get_series_by_key(series_data, "attack-on-titan")
assert result is not None
assert result["name"] == "Attack on Titan"
def test_get_series_by_key_not_found(self):
"""Test that None is returned when key not found."""
series_data = [
{"key": "attack-on-titan", "name": "Attack on Titan"},
]
result = get_series_by_key(series_data, "non-existent")
assert result is None
def test_get_series_by_key_empty_list(self):
"""Test get_series_by_key with empty list."""
result = get_series_by_key([], "any-key")
assert result is None
def test_filter_series_by_missing_episodes(self):
"""Test filtering series with missing episodes."""
series_data = [
{
"key": "has-missing",
"name": "Has Missing",
"missing_episodes": {"1": [1, 2, 3]},
},
{
"key": "no-missing",
"name": "No Missing",
"missing_episodes": {},
},
{
"key": "empty-seasons",
"name": "Empty Seasons",
"missing_episodes": {"1": [], "2": []},
},
]
filtered = filter_series_by_missing_episodes(series_data)
assert len(filtered) == 1
assert filtered[0]["key"] == "has-missing"
def test_filter_series_by_missing_episodes_empty(self):
"""Test filter with empty list."""
filtered = filter_series_by_missing_episodes([])
assert filtered == []
def test_filter_preserves_key_identifier(self):
"""Test that filter preserves key as identifier."""
series_data = [
{
"key": "test-series",
"folder": "Test Series (2020)",
"name": "Test",
"missing_episodes": {"1": [1]},
}
]
filtered = filter_series_by_missing_episodes(series_data)
assert filtered[0]["key"] == "test-series"
assert filtered[0]["folder"] == "Test Series (2020)"

View File

@ -0,0 +1,533 @@
"""
Unit tests for data validation utilities.
Tests the validators module in src/server/utils/validators.py.
"""
import pytest
from src.server.utils.validators import (
ValidatorMixin,
sanitize_filename,
validate_anime_url,
validate_backup_name,
validate_config_data,
validate_download_priority,
validate_download_quality,
validate_episode_range,
validate_ip_address,
validate_jwt_token,
validate_language,
validate_series_key,
validate_series_key_or_folder,
validate_series_name,
validate_websocket_message,
)
class TestValidateSeriesKey:
"""Tests for validate_series_key function."""
def test_valid_simple_key(self):
"""Test valid simple key."""
assert validate_series_key("naruto") == "naruto"
def test_valid_hyphenated_key(self):
"""Test valid hyphenated key."""
assert validate_series_key("attack-on-titan") == "attack-on-titan"
def test_valid_numeric_key(self):
"""Test valid key with numbers."""
assert validate_series_key("one-piece-2024") == "one-piece-2024"
def test_valid_key_starting_with_number(self):
"""Test valid key starting with number."""
assert validate_series_key("86-eighty-six") == "86-eighty-six"
def test_strips_whitespace(self):
"""Test that whitespace is stripped."""
assert validate_series_key(" naruto ") == "naruto"
def test_empty_string_raises(self):
"""Test empty string raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key("")
def test_none_raises(self):
"""Test None raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key(None)
def test_whitespace_only_raises(self):
"""Test whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_key(" ")
def test_uppercase_raises(self):
"""Test uppercase letters raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("Attack-On-Titan")
def test_spaces_raises(self):
"""Test spaces raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack on titan")
def test_underscores_raises(self):
"""Test underscores raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack_on_titan")
def test_special_characters_raises(self):
"""Test special characters raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack@titan")
def test_leading_hyphen_raises(self):
"""Test leading hyphen raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("-attack-on-titan")
def test_trailing_hyphen_raises(self):
"""Test trailing hyphen raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack-on-titan-")
def test_consecutive_hyphens_raises(self):
"""Test consecutive hyphens raise ValueError."""
with pytest.raises(ValueError, match="lowercase"):
validate_series_key("attack--on--titan")
def test_key_too_long_raises(self):
"""Test key exceeding 255 chars raises ValueError."""
long_key = "a" * 256
with pytest.raises(ValueError, match="255 characters"):
validate_series_key(long_key)
def test_max_length_key(self):
"""Test key at exactly 255 chars is valid."""
max_key = "a" * 255
assert validate_series_key(max_key) == max_key
class TestValidateSeriesKeyOrFolder:
"""Tests for validate_series_key_or_folder function."""
def test_valid_key_returns_key_true(self):
"""Test valid key returns (key, True)."""
result = validate_series_key_or_folder("attack-on-titan")
assert result == ("attack-on-titan", True)
def test_valid_folder_returns_folder_false(self):
"""Test valid folder returns (folder, False)."""
result = validate_series_key_or_folder("Attack on Titan (2013)")
assert result == ("Attack on Titan (2013)", False)
def test_empty_string_raises(self):
"""Test empty string raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key_or_folder("")
def test_none_raises(self):
"""Test None raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_series_key_or_folder(None)
def test_whitespace_only_raises(self):
"""Test whitespace-only string raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_key_or_folder(" ")
def test_folder_not_allowed_raises(self):
"""Test folder format raises when not allowed."""
with pytest.raises(ValueError, match="Invalid series key format"):
validate_series_key_or_folder(
"Attack on Titan (2013)", allow_folder=False
)
def test_key_allowed_when_folder_disabled(self):
"""Test valid key works when folder is disabled."""
result = validate_series_key_or_folder(
"attack-on-titan", allow_folder=False
)
assert result == ("attack-on-titan", True)
def test_strips_whitespace(self):
"""Test that whitespace is stripped."""
result = validate_series_key_or_folder(" attack-on-titan ")
assert result == ("attack-on-titan", True)
def test_folder_too_long_raises(self):
"""Test folder exceeding 1000 chars raises ValueError."""
long_folder = "A" * 1001
with pytest.raises(ValueError, match="too long"):
validate_series_key_or_folder(long_folder)
class TestValidateSeriesName:
"""Tests for validate_series_name function."""
def test_valid_name(self):
"""Test valid series name."""
assert validate_series_name("Attack on Titan") == "Attack on Titan"
def test_strips_whitespace(self):
"""Test whitespace is stripped."""
assert validate_series_name(" Naruto ") == "Naruto"
def test_empty_raises(self):
"""Test empty name raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_series_name("")
def test_too_long_raises(self):
"""Test name over 200 chars raises ValueError."""
with pytest.raises(ValueError, match="too long"):
validate_series_name("A" * 201)
def test_invalid_chars_raises(self):
"""Test invalid characters raise ValueError."""
with pytest.raises(ValueError, match="invalid character"):
validate_series_name("Attack: Titan")
class TestValidateEpisodeRange:
"""Tests for validate_episode_range function."""
def test_valid_range(self):
"""Test valid episode range."""
assert validate_episode_range(1, 10) == (1, 10)
def test_same_start_end(self):
"""Test start equals end is valid."""
assert validate_episode_range(5, 5) == (5, 5)
def test_start_less_than_one_raises(self):
"""Test start less than 1 raises ValueError."""
with pytest.raises(ValueError, match="at least 1"):
validate_episode_range(0, 10)
def test_end_less_than_start_raises(self):
"""Test end less than start raises ValueError."""
with pytest.raises(ValueError, match="greater than or equal"):
validate_episode_range(10, 5)
def test_range_too_large_raises(self):
"""Test range over 1000 raises ValueError."""
with pytest.raises(ValueError, match="too large"):
validate_episode_range(1, 1002)
class TestValidateDownloadQuality:
"""Tests for validate_download_quality function."""
@pytest.mark.parametrize("quality", [
"360p", "480p", "720p", "1080p", "best", "worst"
])
def test_valid_qualities(self, quality):
"""Test all valid quality values."""
assert validate_download_quality(quality) == quality
def test_invalid_quality_raises(self):
"""Test invalid quality raises ValueError."""
with pytest.raises(ValueError, match="Invalid quality"):
validate_download_quality("4k")
class TestValidateLanguage:
"""Tests for validate_language function."""
@pytest.mark.parametrize("language", [
"ger-sub", "ger-dub", "eng-sub", "eng-dub", "jpn"
])
def test_valid_languages(self, language):
"""Test all valid language values."""
assert validate_language(language) == language
def test_invalid_language_raises(self):
"""Test invalid language raises ValueError."""
with pytest.raises(ValueError, match="Invalid language"):
validate_language("spanish")
class TestValidateDownloadPriority:
"""Tests for validate_download_priority function."""
def test_valid_priority_min(self):
"""Test minimum priority is valid."""
assert validate_download_priority(0) == 0
def test_valid_priority_max(self):
"""Test maximum priority is valid."""
assert validate_download_priority(10) == 10
def test_negative_priority_raises(self):
"""Test negative priority raises ValueError."""
with pytest.raises(ValueError, match="between 0 and 10"):
validate_download_priority(-1)
def test_priority_too_high_raises(self):
"""Test priority over 10 raises ValueError."""
with pytest.raises(ValueError, match="between 0 and 10"):
validate_download_priority(11)
class TestValidateAnimeUrl:
"""Tests for validate_anime_url function."""
def test_valid_aniworld_url(self):
"""Test valid aniworld.to URL."""
url = "https://aniworld.to/anime/stream/attack-on-titan"
assert validate_anime_url(url) == url
def test_valid_s_to_url(self):
"""Test valid s.to URL."""
url = "https://s.to/serie/stream/naruto"
assert validate_anime_url(url) == url
def test_empty_url_raises(self):
"""Test empty URL raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_anime_url("")
def test_invalid_domain_raises(self):
"""Test invalid domain raises ValueError."""
with pytest.raises(ValueError, match="aniworld.to or s.to"):
validate_anime_url("https://example.com/anime")
class TestValidateBackupName:
"""Tests for validate_backup_name function."""
def test_valid_backup_name(self):
"""Test valid backup name."""
assert validate_backup_name("backup-2024.json") == "backup-2024.json"
def test_empty_raises(self):
"""Test empty name raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
validate_backup_name("")
def test_invalid_chars_raises(self):
"""Test invalid characters raise ValueError."""
with pytest.raises(ValueError, match="only contain"):
validate_backup_name("backup name.json")
def test_no_json_extension_raises(self):
"""Test missing .json raises ValueError."""
with pytest.raises(ValueError, match="end with .json"):
validate_backup_name("backup.txt")
class TestValidateConfigData:
"""Tests for validate_config_data function."""
def test_valid_config(self):
"""Test valid config data."""
data = {
"download_directory": "/downloads",
"concurrent_downloads": 3
}
assert validate_config_data(data) == data
def test_missing_keys_raises(self):
"""Test missing required keys raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
validate_config_data({"download_directory": "/downloads"})
def test_invalid_concurrent_downloads_raises(self):
"""Test invalid concurrent_downloads raises ValueError."""
with pytest.raises(ValueError, match="between 1 and 10"):
validate_config_data({
"download_directory": "/downloads",
"concurrent_downloads": 15
})
class TestSanitizeFilename:
"""Tests for sanitize_filename function."""
def test_valid_filename(self):
"""Test valid filename unchanged."""
assert sanitize_filename("episode-01.mp4") == "episode-01.mp4"
def test_removes_invalid_chars(self):
"""Test invalid characters are replaced."""
result = sanitize_filename("file<>name.mp4")
assert "<" not in result
assert ">" not in result
def test_strips_dots_spaces(self):
"""Test leading/trailing dots and spaces removed."""
assert sanitize_filename(" .filename. ") == "filename"
def test_empty_becomes_unnamed(self):
"""Test empty filename becomes 'unnamed'."""
assert sanitize_filename("") == "unnamed"
class TestValidateJwtToken:
"""Tests for validate_jwt_token function."""
def test_valid_token_format(self):
"""Test valid JWT token format."""
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" # noqa: E501
assert validate_jwt_token(token) == token
def test_empty_raises(self):
"""Test empty token raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_jwt_token("")
def test_invalid_format_raises(self):
"""Test invalid format raises ValueError."""
with pytest.raises(ValueError, match="Invalid JWT"):
validate_jwt_token("not-a-jwt-token")
class TestValidateIpAddress:
"""Tests for validate_ip_address function."""
def test_valid_ipv4(self):
"""Test valid IPv4 address."""
assert validate_ip_address("192.168.1.1") == "192.168.1.1"
def test_valid_ipv4_localhost(self):
"""Test localhost IPv4."""
assert validate_ip_address("127.0.0.1") == "127.0.0.1"
def test_empty_raises(self):
"""Test empty IP raises ValueError."""
with pytest.raises(ValueError, match="non-empty string"):
validate_ip_address("")
def test_invalid_ip_raises(self):
"""Test invalid IP raises ValueError."""
with pytest.raises(ValueError, match="Invalid IP"):
validate_ip_address("not-an-ip")
class TestValidateWebsocketMessage:
"""Tests for validate_websocket_message function."""
def test_valid_message(self):
"""Test valid WebSocket message."""
msg = {"type": "download_progress", "data": {}}
assert validate_websocket_message(msg) == msg
def test_missing_type_raises(self):
"""Test missing type raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
validate_websocket_message({"data": {}})
def test_invalid_type_raises(self):
"""Test invalid type raises ValueError."""
with pytest.raises(ValueError, match="Invalid message type"):
validate_websocket_message({"type": "invalid_type"})
class TestValidatorMixin:
"""Tests for ValidatorMixin class methods."""
def test_validate_password_strength_valid(self):
"""Test valid password passes."""
password = "SecurePass123!"
assert ValidatorMixin.validate_password_strength(password) == password
def test_validate_password_too_short_raises(self):
"""Test short password raises ValueError."""
with pytest.raises(ValueError, match="8 characters"):
ValidatorMixin.validate_password_strength("Short1!")
def test_validate_password_no_uppercase_raises(self):
"""Test no uppercase raises ValueError."""
with pytest.raises(ValueError, match="uppercase"):
ValidatorMixin.validate_password_strength("lowercase123!")
def test_validate_password_no_lowercase_raises(self):
"""Test no lowercase raises ValueError."""
with pytest.raises(ValueError, match="lowercase"):
ValidatorMixin.validate_password_strength("UPPERCASE123!")
def test_validate_password_no_digit_raises(self):
"""Test no digit raises ValueError."""
with pytest.raises(ValueError, match="digit"):
ValidatorMixin.validate_password_strength("NoDigitsHere!")
def test_validate_password_no_special_raises(self):
"""Test no special char raises ValueError."""
with pytest.raises(ValueError, match="special character"):
ValidatorMixin.validate_password_strength("NoSpecial123")
def test_validate_url_valid(self):
"""Test valid URL."""
url = "https://example.com/path"
assert ValidatorMixin.validate_url(url) == url
def test_validate_url_invalid_raises(self):
"""Test invalid URL raises ValueError."""
with pytest.raises(ValueError, match="Invalid URL"):
ValidatorMixin.validate_url("not-a-url")
def test_validate_port_valid(self):
"""Test valid port."""
assert ValidatorMixin.validate_port(8080) == 8080
def test_validate_port_invalid_raises(self):
"""Test invalid port raises ValueError."""
with pytest.raises(ValueError, match="between 1 and 65535"):
ValidatorMixin.validate_port(70000)
def test_validate_positive_integer_valid(self):
"""Test valid positive integer."""
assert ValidatorMixin.validate_positive_integer(5) == 5
def test_validate_positive_integer_zero_raises(self):
"""Test zero raises ValueError."""
with pytest.raises(ValueError, match="must be positive"):
ValidatorMixin.validate_positive_integer(0)
def test_validate_non_negative_integer_valid(self):
"""Test valid non-negative integer."""
assert ValidatorMixin.validate_non_negative_integer(0) == 0
def test_validate_non_negative_integer_negative_raises(self):
"""Test negative raises ValueError."""
with pytest.raises(ValueError, match="cannot be negative"):
ValidatorMixin.validate_non_negative_integer(-1)
def test_validate_string_length_valid(self):
"""Test valid string length."""
result = ValidatorMixin.validate_string_length("test", 1, 10)
assert result == "test"
def test_validate_string_length_too_short_raises(self):
"""Test too short raises ValueError."""
with pytest.raises(ValueError, match="at least"):
ValidatorMixin.validate_string_length("ab", min_length=5)
def test_validate_string_length_too_long_raises(self):
"""Test too long raises ValueError."""
with pytest.raises(ValueError, match="at most"):
ValidatorMixin.validate_string_length("abcdefgh", max_length=5)
def test_validate_choice_valid(self):
"""Test valid choice."""
result = ValidatorMixin.validate_choice("a", ["a", "b", "c"])
assert result == "a"
def test_validate_choice_invalid_raises(self):
"""Test invalid choice raises ValueError."""
with pytest.raises(ValueError, match="must be one of"):
ValidatorMixin.validate_choice("d", ["a", "b", "c"])
def test_validate_dict_keys_valid(self):
"""Test valid dict with required keys."""
data = {"a": 1, "b": 2}
result = ValidatorMixin.validate_dict_keys(data, ["a", "b"])
assert result == data
def test_validate_dict_keys_missing_raises(self):
"""Test missing keys raises ValueError."""
with pytest.raises(ValueError, match="missing required keys"):
ValidatorMixin.validate_dict_keys({"a": 1}, ["a", "b"])

View File

@ -307,10 +307,16 @@ class TestWebSocketService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_download_progress(self, service, mock_websocket): async def test_broadcast_download_progress(self, service, mock_websocket):
"""Test broadcasting download progress.""" """Test broadcasting download progress.
Verifies that progress data includes 'key' as the primary series
identifier and 'folder' for display purposes only.
"""
connection_id = "test-conn" connection_id = "test-conn"
download_id = "download123" download_id = "download123"
progress_data = { progress_data = {
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"percent": 50.0, "percent": 50.0,
"speed_mbps": 2.5, "speed_mbps": 2.5,
"eta_seconds": 120, "eta_seconds": 120,
@ -325,14 +331,24 @@ class TestWebSocketService:
call_args = mock_websocket.send_json.call_args[0][0] call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "download_progress" assert call_args["type"] == "download_progress"
assert call_args["data"]["download_id"] == download_id assert call_args["data"]["download_id"] == download_id
assert call_args["data"]["key"] == "attack-on-titan"
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
assert call_args["data"]["percent"] == 50.0 assert call_args["data"]["percent"] == 50.0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_download_complete(self, service, mock_websocket): async def test_broadcast_download_complete(self, service, mock_websocket):
"""Test broadcasting download completion.""" """Test broadcasting download completion.
Verifies that result data includes 'key' as the primary series
identifier and 'folder' for display purposes only.
"""
connection_id = "test-conn" connection_id = "test-conn"
download_id = "download123" download_id = "download123"
result_data = {"file_path": "/path/to/file.mp4"} result_data = {
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"file_path": "/path/to/file.mp4"
}
await service.connect(mock_websocket, connection_id) await service.connect(mock_websocket, connection_id)
await service._manager.join_room(connection_id, "downloads") await service._manager.join_room(connection_id, "downloads")
@ -342,13 +358,23 @@ class TestWebSocketService:
call_args = mock_websocket.send_json.call_args[0][0] call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "download_complete" assert call_args["type"] == "download_complete"
assert call_args["data"]["download_id"] == download_id assert call_args["data"]["download_id"] == download_id
assert call_args["data"]["key"] == "attack-on-titan"
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_download_failed(self, service, mock_websocket): async def test_broadcast_download_failed(self, service, mock_websocket):
"""Test broadcasting download failure.""" """Test broadcasting download failure.
Verifies that error data includes 'key' as the primary series
identifier and 'folder' for display purposes only.
"""
connection_id = "test-conn" connection_id = "test-conn"
download_id = "download123" download_id = "download123"
error_data = {"error_message": "Network error"} error_data = {
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"error_message": "Network error"
}
await service.connect(mock_websocket, connection_id) await service.connect(mock_websocket, connection_id)
await service._manager.join_room(connection_id, "downloads") await service._manager.join_room(connection_id, "downloads")
@ -358,6 +384,8 @@ class TestWebSocketService:
call_args = mock_websocket.send_json.call_args[0][0] call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "download_failed" assert call_args["type"] == "download_failed"
assert call_args["data"]["download_id"] == download_id assert call_args["data"]["download_id"] == download_id
assert call_args["data"]["key"] == "attack-on-titan"
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_broadcast_queue_status(self, service, mock_websocket): async def test_broadcast_queue_status(self, service, mock_websocket):