From 04799633b4f4bcb66aa7a504ae95e333079f422c Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 22 Oct 2025 13:38:46 +0200 Subject: [PATCH] cleanup --- QUALITY_IMPROVEMENTS.md | 204 ------------------------ QualityTODO.md | 22 --- TEST_VERIFICATION_REPORT.md | 108 ------------- data/download_queue.json | 158 +++++++++--------- src/cli/Main.py | 60 ++++--- src/core/SerieScanner.py | 60 ++++--- src/core/providers/aniworld_provider.py | 106 ++++++++---- src/server/api/anime.py | 191 ++++++++++++++++------ src/server/utils/dependencies.py | 73 ++++++--- 9 files changed, 411 insertions(+), 571 deletions(-) delete mode 100644 QUALITY_IMPROVEMENTS.md delete mode 100644 TEST_VERIFICATION_REPORT.md diff --git a/QUALITY_IMPROVEMENTS.md b/QUALITY_IMPROVEMENTS.md deleted file mode 100644 index 0aaaa17..0000000 --- a/QUALITY_IMPROVEMENTS.md +++ /dev/null @@ -1,204 +0,0 @@ -# Quality Improvements - Implementation Summary - -## Date: October 22, 2025 - -### Critical Issues Resolved - -#### 1. ✅ Fixed Missing Import Error (CRITICAL) - -**File**: `src/core/providers/enhanced_provider.py` - -- **Issue**: Import error from non-existent `error_handler` module causing runtime failure -- **Solution**: Created stub module at `src/core/error_handler.py` with: - - `RetryableError`, `NonRetryableError`, `NetworkError`, `DownloadError` exception classes - - `with_error_recovery` decorator for automatic retry logic - - `RecoveryStrategies` class with network and download failure handling - - `FileCorruptionDetector` class for video file validation -- **Impact**: Application now starts without import errors ✅ - -#### 2. ✅ Fixed CORS Configuration (SECURITY) - -**File**: `src/server/fastapi_app.py` - -- **Issue**: `allow_origins=["*"]` exposes API to any domain (production security risk) -- **Solution**: - - Updated to use environment-based `CORS_ORIGINS` setting - - Defaults to localhost for development - - Production can configure via `.env` file - - Added security warning in code comments -- **Impact**: Improved API security and configurability ✅ - -#### 3. ✅ Fixed Type Hint Syntax Errors (PEP8) - -**Files**: - -- `src/core/providers/streaming/Provider.py` -- `src/core/providers/streaming/voe.py` -- `src/core/providers/streaming/doodstream.py` - -**Issues Fixed**: - -- Invalid return type `(str, [str])` → corrected to `tuple[str, dict[str, Any]]` -- Method names not following PEP8: - - `GetLink()` → `get_link()` - - `embededLink` → `embedded_link` - - `DEFAULT_REQUEST_TIMEOUT` → `timeout` - -**Changes**: - -- Base `Provider` abstract class with proper type hints -- Updated all implementations (VOE, Doodstream) with correct signatures -- Updated all callers to use new method names -- Added comprehensive docstrings for all methods - -**Impact**: Fixed ~12 type checking violations ✅ - -#### 4. ✅ Enhanced Base Provider Documentation (PEP257) - -**File**: `src/core/providers/base_provider.py` - -**Improvements**: - -- Added comprehensive type hints to all abstract methods -- Enhanced docstrings for all parameters with clear descriptions -- Added return type annotations with detailed documentation -- Parameters now include type annotations and context -- Example: `get_season_episode_count()` now has full parameter and return documentation - -**Impact**: Improved code discoverability and maintainability ✅ - -### Quality Metrics Improved - -| Metric | Before | After | Status | -| ----------------------- | ---------- | ------------- | ------ | -| Import Errors | 1 Critical | 0 | ✅ | -| CORS Security Risk | High | Resolved | ✅ | -| Type Hint Syntax Errors | 12+ | 0 | ✅ | -| Abstract Method Docs | Minimal | Comprehensive | ✅ | -| Tests Passing | Yes | Yes | ✅ | - -### Files Modified - -1. **Created**: `src/core/error_handler.py` (100 lines) - - - New module for error handling infrastructure - - Provides recovery strategies and custom exceptions - -2. **Modified**: `src/core/providers/streaming/Provider.py` - - - Added proper type hints - - Enhanced documentation - -3. **Modified**: `src/core/providers/streaming/voe.py` - - - Method naming: GetLink → get_link - - Parameter naming: embededLink → embedded_link, etc. - - Return type: now returns tuple instead of just string - - Added type hints throughout - -4. **Modified**: `src/core/providers/streaming/doodstream.py` - - - Method naming: GetLink → get_link - - Parameter naming consistency - - Return type: now returns tuple with headers - - Added type hints and docstrings - -5. **Modified**: `src/server/fastapi_app.py` - - - CORS configuration now environment-based - - Added security warning comment - - Uses settings.cors_origins for configuration - -6. **Modified**: `src/core/providers/base_provider.py` - - - Added comprehensive type hints to all abstract methods - - Enhanced docstrings with parameter descriptions - - Added Optional and Callable type imports - -7. **Modified**: `src/core/providers/aniworld_provider.py` - - - Updated GetLink call → get_link - -8. **Modified**: `src/core/providers/enhanced_provider.py` - - Updated GetLink call → get_link - - Fixed import to use new error_handler module - -### Test Results - -All tests passing after changes: - -- ✅ Unit tests: PASS -- ✅ Integration tests: PASS -- ✅ API tests: PASS -- ✅ No regressions detected - -### Remaining Quality Issues (For Future Work) - -See `QualityTODO.md` for comprehensive list. Priority items: - -**High Priority**: - -- [ ] Sort imports with isort in CLI and provider modules -- [ ] Add type hints to CLI methods (search, get_user_selection, etc.) -- [ ] Remove duplicate SeriesApp class from CLI - -**Medium Priority**: - -- [ ] Rate limiter memory leak cleanup -- [ ] Database query optimization (N+1 problems) -- [ ] String operation efficiency improvements - -**Low Priority**: - -- [ ] Performance profiling and optimization -- [ ] Additional security hardening -- [ ] Extended documentation - -### Code Quality Standards Applied - -✅ **PEP 8 Compliance**: - -- Type hints on all modified method signatures -- Proper spacing between methods and classes -- Line length compliance - -✅ **PEP 257 Compliance**: - -- Comprehensive docstrings on abstract methods -- Parameter documentation -- Return type documentation - -✅ **Security**: - -- CORS properly configured -- Error handling infrastructure in place -- Input validation patterns established - -✅ **Performance**: - -- No blocking changes -- Efficient error recovery mechanisms -- Proper use of async patterns - -### Validation Checklist - -- [x] No syntax errors -- [x] All imports resolve correctly -- [x] Type hints are valid -- [x] All tests pass -- [x] No regressions -- [x] Security improvements applied -- [x] Documentation enhanced - -### Next Steps Recommended - -1. Run `isort` on remaining files to standardize imports -2. Add type hints to CLI module methods -3. Implement periodic cleanup for rate limiter -4. Add database query optimization -5. Profile performance under load - ---- - -**Completed by**: GitHub Copilot Assistant -**Verification**: All tests passing, no blockers, ready for production deployment diff --git a/QualityTODO.md b/QualityTODO.md index 272cdbd..4bf1ed5 100644 --- a/QualityTODO.md +++ b/QualityTODO.md @@ -78,28 +78,6 @@ conda run -n AniWorld python -m pytest tests/ -v -s ### 1️⃣ Code Follows PEP8 and Project Coding Standards -#### Naming Convention Issues - -- [ ] `src/core/providers/streaming/Provider.py` - PENDING - - - `GetLink()` should be `get_link()` - - Also has invalid type hint syntax that needs fixing - -- [ ] `src/core/providers/enhanced_provider.py` - PENDING - - - Similar naming convention issues as aniworld_provider - - Needs parallel refactoring - -#### Import Sorting and Organization - -- [ ] `src/cli/Main.py` - Imports not in isort order - - Should group: stdlib, third-party, local imports - - Line 1-11 needs reorganization -- [ ] `src/core/providers/aniworld_provider.py` - Imports not sorted -- [ ] `src/core/providers/enhanced_provider.py` - Imports not sorted -- [ ] `src/server/api/download.py` - Verify import order -- [ ] Run `isort --check-only` on entire codebase to identify violations - #### Blank Line Spacing Issues (PEP8 §4) - [ ] `src/cli/Main.py` - Missing blank lines between methods diff --git a/TEST_VERIFICATION_REPORT.md b/TEST_VERIFICATION_REPORT.md deleted file mode 100644 index 7e6c7c9..0000000 --- a/TEST_VERIFICATION_REPORT.md +++ /dev/null @@ -1,108 +0,0 @@ -# Test Verification Report - -**Date**: October 22, 2025 -**Status**: ✅ ALL TESTS PASSING - -## Test Results Summary - -### ✅ All Tests Suite - -- **Command**: `pytest tests/ -v --tb=short` -- **Status**: ✅ PASSED -- **Exit Code**: 0 - -### ✅ Unit Tests - -- **Command**: `pytest tests/unit/ -v` -- **Status**: ✅ PASSED -- **Exit Code**: 0 -- **Coverage**: All unit tests in `tests/unit/` directory - -### ✅ Integration Tests - -- **Command**: `pytest tests/integration/ -v` -- **Status**: ✅ PASSED -- **Exit Code**: 0 -- **Coverage**: All integration tests in `tests/integration/` directory - -## Quality Improvements Applied - -The following changes were made and all tests continue to pass: - -1. ✅ Created `src/core/error_handler.py` with: - - - Custom exception classes - - Error recovery decorators - - Network failure handling - - File corruption detection - -2. ✅ Fixed CORS configuration in `src/server/fastapi_app.py` - - - Environment-based configuration - - Security improvements - -3. ✅ Fixed type hints in provider modules: - - - `src/core/providers/streaming/Provider.py` - - `src/core/providers/streaming/voe.py` - - `src/core/providers/streaming/doodstream.py` - -4. ✅ Enhanced documentation in: - - - `src/core/providers/base_provider.py` - - All provider implementations - -5. ✅ Updated method calls and imports across: - - `src/core/providers/aniworld_provider.py` - - `src/core/providers/enhanced_provider.py` - - `src/server/fastapi_app.py` - -## Verification Checklist - -- [x] All unit tests passing -- [x] All integration tests passing -- [x] All API tests passing -- [x] No import errors -- [x] No type checking errors -- [x] No regression issues -- [x] Code quality improvements applied -- [x] Security enhancements in place - -## Test Command Reference - -```bash -# Run all tests -conda run -n AniWorld python -m pytest tests/ -v --tb=short - -# Run unit tests only -conda run -n AniWorld python -m pytest tests/unit/ -v - -# Run integration tests only -conda run -n AniWorld python -m pytest tests/integration/ -v - -# Run with coverage -conda run -n AniWorld python -m pytest tests/ -v --cov=src - -# Run with verbose output -conda run -n AniWorld python -m pytest tests/ -vv -``` - -## Next Steps - -The codebase is now in a stable state with: - -- ✅ All tests passing -- ✅ Critical security issues fixed -- ✅ Type hints improved -- ✅ Documentation enhanced -- ✅ No regressions - -Ready for: - -- Deployment to production -- Further feature development -- Additional quality improvements from the QualityTODO.md list - ---- - -**Last Verified**: October 22, 2025 - All systems operational ✅ diff --git a/data/download_queue.json b/data/download_queue.json index 7ee0bd5..fecfe50 100644 --- a/data/download_queue.json +++ b/data/download_queue.json @@ -1,7 +1,7 @@ { "pending": [ { - "id": "42233fa7-50b3-4941-882c-be23e776e88c", + "id": "a99e0a91-c71c-49c6-a26b-f0682643b61f", "serie_id": "workflow-series", "serie_name": "Workflow Test Series", "episode": { @@ -11,7 +11,7 @@ }, "status": "pending", "priority": "high", - "added_at": "2025-10-22T10:57:26.642934Z", + "added_at": "2025-10-22T11:08:17.906509Z", "started_at": null, "completed_at": null, "progress": null, @@ -20,7 +20,7 @@ "source_url": null }, { - "id": "eeafcd93-9df7-4429-872c-1220a642b775", + "id": "564070a3-6548-4238-96d3-05f3fc9a0a6b", "serie_id": "series-2", "serie_name": "Series 2", "episode": { @@ -30,7 +30,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.331686Z", + "added_at": "2025-10-22T11:08:17.631588Z", "started_at": null, "completed_at": null, "progress": null, @@ -39,7 +39,7 @@ "source_url": null }, { - "id": "21b6d879-7478-4711-8a91-647383cc2d64", + "id": "3587a189-0b04-4954-b480-20530cf5fdb9", "serie_id": "series-1", "serie_name": "Series 1", "episode": { @@ -49,7 +49,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.326103Z", + "added_at": "2025-10-22T11:08:17.629702Z", "started_at": null, "completed_at": null, "progress": null, @@ -58,7 +58,7 @@ "source_url": null }, { - "id": "68ade854-067f-4486-bfb3-f0d0ce2cdb88", + "id": "18c6c1dc-279e-44d7-b032-6f14aa061cc3", "serie_id": "series-0", "serie_name": "Series 0", "episode": { @@ -68,7 +68,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.321881Z", + "added_at": "2025-10-22T11:08:17.626684Z", "started_at": null, "completed_at": null, "progress": null, @@ -77,7 +77,7 @@ "source_url": null }, { - "id": "bf18f799-ce21-4f06-bf8c-cc4a26b815f5", + "id": "0e47ef3d-e233-4631-bea3-a1d15ac9b2ad", "serie_id": "series-high", "serie_name": "Series High", "episode": { @@ -87,7 +87,7 @@ }, "status": "pending", "priority": "high", - "added_at": "2025-10-22T10:57:26.077002Z", + "added_at": "2025-10-22T11:08:17.404606Z", "started_at": null, "completed_at": null, "progress": null, @@ -96,7 +96,7 @@ "source_url": null }, { - "id": "daebc904-6c32-4ca5-947c-18d25d43fdfe", + "id": "e09d09f4-46d7-4408-960b-1508389f0e5a", "serie_id": "test-series-2", "serie_name": "Another Series", "episode": { @@ -106,7 +106,7 @@ }, "status": "pending", "priority": "high", - "added_at": "2025-10-22T10:57:26.051169Z", + "added_at": "2025-10-22T11:08:17.379463Z", "started_at": null, "completed_at": null, "progress": null, @@ -115,7 +115,7 @@ "source_url": null }, { - "id": "aee9fdf2-4436-43ad-8c08-1bcdc2013bb4", + "id": "9e3eac5f-3d39-45b3-8584-96bcf9901af9", "serie_id": "test-series-1", "serie_name": "Test Anime Series", "episode": { @@ -125,7 +125,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.023723Z", + "added_at": "2025-10-22T11:08:17.354951Z", "started_at": null, "completed_at": null, "progress": null, @@ -134,7 +134,7 @@ "source_url": null }, { - "id": "c45a72c3-11af-48dd-ba23-2f2d93bd0d3c", + "id": "6cb7f8f0-ba85-4778-a7af-2d0b43e26dcb", "serie_id": "test-series-1", "serie_name": "Test Anime Series", "episode": { @@ -144,7 +144,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.023820Z", + "added_at": "2025-10-22T11:08:17.355041Z", "started_at": null, "completed_at": null, "progress": null, @@ -153,7 +153,7 @@ "source_url": null }, { - "id": "83e1e2bc-a1b5-49a2-a183-421884c183ce", + "id": "c43d205f-70f8-48d1-bde7-5f3e57cd0775", "serie_id": "series-normal", "serie_name": "Series Normal", "episode": { @@ -163,7 +163,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.082663Z", + "added_at": "2025-10-22T11:08:17.406642Z", "started_at": null, "completed_at": null, "progress": null, @@ -172,7 +172,7 @@ "source_url": null }, { - "id": "9a78d1b5-9e23-4e79-b4e4-40bf0a4ce8a1", + "id": "841046e8-9bd9-45e6-b53c-127a97918568", "serie_id": "series-low", "serie_name": "Series Low", "episode": { @@ -182,7 +182,7 @@ }, "status": "pending", "priority": "low", - "added_at": "2025-10-22T10:57:26.084695Z", + "added_at": "2025-10-22T11:08:17.410918Z", "started_at": null, "completed_at": null, "progress": null, @@ -191,7 +191,7 @@ "source_url": null }, { - "id": "d2f8fa60-9806-45d0-8ca8-8bf46499e9e7", + "id": "515d07ee-7ab6-4b37-acd9-cc4cc8066141", "serie_id": "test-series", "serie_name": "Test Series", "episode": { @@ -201,7 +201,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.269365Z", + "added_at": "2025-10-22T11:08:17.579360Z", "started_at": null, "completed_at": null, "progress": null, @@ -210,7 +210,7 @@ "source_url": null }, { - "id": "1605e153-67e5-4a47-b5a2-e721cbbfe609", + "id": "52ca17ce-3c35-454d-b0e7-4fc72da50282", "serie_id": "test-series", "serie_name": "Test Series", "episode": { @@ -220,7 +220,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.360496Z", + "added_at": "2025-10-22T11:08:17.656563Z", "started_at": null, "completed_at": null, "progress": null, @@ -229,7 +229,7 @@ "source_url": null }, { - "id": "c10c1bf1-ccab-45bb-8526-38da70efb337", + "id": "f8688ae7-2a1c-42a0-88af-29d5e2c17542", "serie_id": "invalid-series", "serie_name": "Invalid Series", "episode": { @@ -239,7 +239,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.417614Z", + "added_at": "2025-10-22T11:08:17.705743Z", "started_at": null, "completed_at": null, "progress": null, @@ -248,7 +248,7 @@ "source_url": null }, { - "id": "449e2572-c506-4f22-878f-24637b64ac89", + "id": "e93f5198-cb70-4e6a-b19e-c13b52286027", "serie_id": "test-series", "serie_name": "Test Series", "episode": { @@ -258,7 +258,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.442816Z", + "added_at": "2025-10-22T11:08:17.731256Z", "started_at": null, "completed_at": null, "progress": null, @@ -267,45 +267,7 @@ "source_url": null }, { - "id": "cf9ad8ab-deeb-400c-8fc8-791fe7ce2dc5", - "serie_id": "series-1", - "serie_name": "Series 1", - "episode": { - "season": 1, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-22T10:57:26.483725Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "62908e0e-2367-4472-b00d-0fc43e725b79", - "serie_id": "series-3", - "serie_name": "Series 3", - "episode": { - "season": 1, - "episode": 1, - "title": null - }, - "status": "pending", - "priority": "normal", - "added_at": "2025-10-22T10:57:26.484579Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "06eca34e-f597-4464-b8ce-e0b631ec3b92", + "id": "bc8d7f8f-b283-4364-9cdc-c4745d2c182a", "serie_id": "series-4", "serie_name": "Series 4", "episode": { @@ -315,7 +277,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.485459Z", + "added_at": "2025-10-22T11:08:17.765884Z", "started_at": null, "completed_at": null, "progress": null, @@ -324,7 +286,26 @@ "source_url": null }, { - "id": "86b4443a-4d5b-4aa9-b435-4227485a0ee3", + "id": "0d75d9d4-cb44-4625-927d-d173d0810fe7", + "serie_id": "series-3", + "serie_name": "Series 3", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-22T11:08:17.767550Z", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "a5e454ad-b044-4b56-af25-3cfc1182b5ee", "serie_id": "series-2", "serie_name": "Series 2", "episode": { @@ -334,7 +315,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.486109Z", + "added_at": "2025-10-22T11:08:17.768218Z", "started_at": null, "completed_at": null, "progress": null, @@ -343,7 +324,26 @@ "source_url": null }, { - "id": "68b49911-bb50-480c-ac60-fb679c381ffb", + "id": "06a5c4be-1b03-4098-8577-19b8b9f39d74", + "serie_id": "series-1", + "serie_name": "Series 1", + "episode": { + "season": 1, + "episode": 1, + "title": null + }, + "status": "pending", + "priority": "normal", + "added_at": "2025-10-22T11:08:17.768875Z", + "started_at": null, + "completed_at": null, + "progress": null, + "error": null, + "retry_count": 0, + "source_url": null + }, + { + "id": "fe20606e-7a8e-4378-a45e-e62148473729", "serie_id": "series-0", "serie_name": "Series 0", "episode": { @@ -353,7 +353,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.486790Z", + "added_at": "2025-10-22T11:08:17.769549Z", "started_at": null, "completed_at": null, "progress": null, @@ -362,7 +362,7 @@ "source_url": null }, { - "id": "800ca4df-12c0-4ebf-a5d6-139732d22fd6", + "id": "cd33d997-7e96-4105-9267-06811cc20439", "serie_id": "persistent-series", "serie_name": "Persistent Series", "episode": { @@ -372,7 +372,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.556798Z", + "added_at": "2025-10-22T11:08:17.829367Z", "started_at": null, "completed_at": null, "progress": null, @@ -381,7 +381,7 @@ "source_url": null }, { - "id": "3c693ed7-6a9e-4afd-a71e-99ec549a4d00", + "id": "89c65c60-a936-44a0-a0be-6c97fd9ce5a7", "serie_id": "ws-series", "serie_name": "WebSocket Series", "episode": { @@ -391,7 +391,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.615000Z", + "added_at": "2025-10-22T11:08:17.880458Z", "started_at": null, "completed_at": null, "progress": null, @@ -400,7 +400,7 @@ "source_url": null }, { - "id": "3b1ae938-e470-4449-8e04-9c576a55f636", + "id": "031ecfa1-e940-407f-b52f-e532147fbd99", "serie_id": "pause-test", "serie_name": "Pause Test Series", "episode": { @@ -410,7 +410,7 @@ }, "status": "pending", "priority": "normal", - "added_at": "2025-10-22T10:57:26.778755Z", + "added_at": "2025-10-22T11:08:18.039451Z", "started_at": null, "completed_at": null, "progress": null, @@ -421,5 +421,5 @@ ], "active": [], "failed": [], - "timestamp": "2025-10-22T10:57:26.779584+00:00" + "timestamp": "2025-10-22T11:08:18.039697+00:00" } \ No newline at end of file diff --git a/src/cli/Main.py b/src/cli/Main.py index 758e0e3..9af2e7b 100644 --- a/src/cli/Main.py +++ b/src/cli/Main.py @@ -1,13 +1,12 @@ import logging import os -import sys import time +from typing import Any, Callable, Mapping, Optional, Sequence from rich.progress import Progress from ..core.entities import SerieList from ..core.entities.series import Serie -from ..core.providers import aniworld_provider from ..core.providers.provider_factory import Loaders from ..core.SerieScanner import SerieScanner @@ -30,35 +29,37 @@ for h in logging.getLogger().handlers: class NoKeyFoundException(Exception): """Exception raised when an anime key cannot be found.""" pass + + class MatchNotFoundError(Exception): """Exception raised when an anime key cannot be found.""" pass class SeriesApp: - _initialization_count = 0 # Track how many times initialization has been called + _initialization_count = 0 # Track initialization calls - def __init__(self, directory_to_search: str): + def __init__(self, directory_to_search: str) -> None: SeriesApp._initialization_count += 1 # Only show initialization message for the first instance if SeriesApp._initialization_count <= 1: print("Please wait while initializing...") - self.progress = None + self.progress: Optional[Progress] = None self.directory_to_search = directory_to_search - self.Loaders = Loaders() + self.Loaders: Loaders = Loaders() loader = self.Loaders.GetLoader(key="aniworld.to") self.SerieScanner = SerieScanner(directory_to_search, loader) self.List = SerieList(self.directory_to_search) self.__init_list__() - def __init_list__(self): + def __init_list__(self) -> None: """Initialize the series list by fetching missing episodes.""" - self.series_list = self.List.GetMissingEpisode() + self.series_list: Sequence[Serie] = self.List.GetMissingEpisode() - def display_series(self): + def display_series(self) -> None: """Print all series with assigned numbers.""" print("\nCurrent result:") for i, serie in enumerate(self.series_list, 1): @@ -68,12 +69,12 @@ class SeriesApp: else: print(f"{i}. {serie.name}") - def search(self, words: str) -> list: + def search(self, words: str) -> list[dict[str, Any]]: """Search for anime series by name.""" loader = self.Loaders.GetLoader(key="aniworld.to") return loader.search(words) - def get_user_selection(self): + def get_user_selection(self) -> Optional[Sequence[Serie]]: """Handle user input for selecting series.""" self.display_series() while True: @@ -86,9 +87,9 @@ class SeriesApp: if selection == "exit": return None - selected_series = [] + selected_series: list[Serie] = [] if selection == "all": - selected_series = self.series_list + selected_series = list(self.series_list) else: try: indexes = [ @@ -118,7 +119,14 @@ class SeriesApp: print(msg) return None - def retry(self, func, max_retries=3, delay=2, *args, **kwargs): + def retry( + self, + func: Callable[..., Any], + max_retries: int = 3, + delay: float = 2, + *args: Any, + **kwargs: Any, + ) -> bool: """Retry a function with exponential backoff. Args: @@ -140,7 +148,7 @@ class SeriesApp: time.sleep(delay) return False - def download_series(self, series): + def download_series(self, series: Sequence[Serie]) -> None: """Simulate the downloading process with a progress bar.""" total_downloaded = 0 total_episodes = sum( @@ -182,7 +190,7 @@ class SeriesApp: episode, serie.key, "German Dub", - self.print_Download_Progress, + self.print_download_progress, ) downloaded += 1 @@ -195,20 +203,24 @@ class SeriesApp: self.progress.stop() self.progress = None - def print_download_progress(self, d): + def print_download_progress(self, d: Mapping[str, Any]) -> None: """Update download progress in the UI. Args: d: Dictionary containing download status information """ # Use self.progress and self.download_progress_task to display progress - if (self.progress is None or - not hasattr(self, "download_progress_task")): + if ( + self.progress is None + or not hasattr(self, "download_progress_task") + ): return if d["status"] == "downloading": - total = (d.get("total_bytes") or - d.get("total_bytes_estimate")) + total = ( + d.get("total_bytes") + or d.get("total_bytes_estimate") + ) downloaded = d.get("downloaded_bytes", 0) if total: percent = downloaded / total * 100 @@ -232,7 +244,7 @@ class SeriesApp: description=desc ) - def search_mode(self): + def search_mode(self) -> None: """Search for a series and allow user to select an option.""" search_string = input("Enter search string: ").strip() results = self.search(search_string) @@ -272,10 +284,10 @@ class SeriesApp: except ValueError: print("Invalid input. Try again.") - def updateFromReinit(self, folder, counter): + def updateFromReinit(self, folder: str, counter: int) -> None: self.progress.update(self.task1, advance=1) - def run(self): + def run(self) -> None: """Main function to run the app.""" while True: prompt = ( diff --git a/src/core/SerieScanner.py b/src/core/SerieScanner.py index dd60271..571dd82 100644 --- a/src/core/SerieScanner.py +++ b/src/core/SerieScanner.py @@ -10,7 +10,7 @@ import os import re import traceback import uuid -from typing import Callable, Optional +from typing import Callable, Iterable, Iterator, Optional from src.core.entities.series import Serie from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException @@ -40,7 +40,7 @@ class SerieScanner: basePath: str, loader: Loader, callback_manager: Optional[CallbackManager] = None - ): + ) -> None: """ Initialize the SerieScanner. @@ -49,10 +49,12 @@ class SerieScanner: loader: Loader instance for fetching series information callback_manager: Optional callback manager for progress updates """ - self.directory = basePath + self.directory: str = basePath self.folderDict: dict[str, Serie] = {} - self.loader = loader - self._callback_manager = callback_manager or CallbackManager() + self.loader: Loader = loader + self._callback_manager: CallbackManager = ( + callback_manager or CallbackManager() + ) self._current_operation_id: Optional[str] = None logger.info("Initialized SerieScanner with base path: %s", basePath) @@ -62,22 +64,22 @@ class SerieScanner: """Get the callback manager instance.""" return self._callback_manager - def reinit(self): + def reinit(self) -> None: """Reinitialize the folder dictionary.""" self.folderDict: dict[str, Serie] = {} - def is_null_or_whitespace(self, s): + def is_null_or_whitespace(self, value: Optional[str]) -> bool: """Check if a string is None or whitespace. Args: - s: String value to check + value: String value to check Returns: True if string is None or contains only whitespace """ - return s is None or s.strip() == "" + return value is None or value.strip() == "" - def get_total_to_scan(self): + def get_total_to_scan(self) -> int: """Get the total number of folders to scan. Returns: @@ -86,7 +88,10 @@ class SerieScanner: result = self.__find_mp4_files() return sum(1 for _ in result) - def scan(self, callback: Optional[Callable[[str, int], None]] = None): + def scan( + self, + callback: Optional[Callable[[str, int], None]] = None + ) -> None: """ Scan directories for anime series and missing episodes. @@ -127,10 +132,10 @@ class SerieScanner: counter += 1 # Calculate progress - percentage = ( - (counter / total_to_scan * 100) - if total_to_scan > 0 else 0 - ) + if total_to_scan > 0: + percentage = (counter / total_to_scan) * 100 + else: + percentage = 0.0 # Notify progress self._callback_manager.notify_progress( @@ -262,13 +267,13 @@ class SerieScanner: raise - def __find_mp4_files(self): + def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]: """Find all .mp4 files in the directory structure.""" logger.info("Scanning for .mp4 files") for anime_name in os.listdir(self.directory): anime_path = os.path.join(self.directory, anime_name) if os.path.isdir(anime_path): - mp4_files = [] + mp4_files: list[str] = [] has_files = False for root, _, files in os.walk(anime_path): for file in files: @@ -277,7 +282,7 @@ class SerieScanner: has_files = True yield anime_name, mp4_files if has_files else [] - def __remove_year(self, input_string: str): + def __remove_year(self, input_string: str) -> str: """Remove year information from input string.""" cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip() logger.debug( @@ -287,7 +292,7 @@ class SerieScanner: ) return cleaned_string - def __read_data_from_file(self, folder_name: str): + def __read_data_from_file(self, folder_name: str) -> Optional[Serie]: """Read serie data from file or key file. Args: @@ -322,7 +327,7 @@ class SerieScanner: return None - def __get_episode_and_season(self, filename: str): + def __get_episode_and_season(self, filename: str) -> tuple[int, int]: """Extract season and episode numbers from filename. Args: @@ -355,7 +360,10 @@ class SerieScanner: "Season and episode pattern not found in the filename." ) - def __get_episodes_and_seasons(self, mp4_files: list): + def __get_episodes_and_seasons( + self, + mp4_files: Iterable[str] + ) -> dict[int, list[int]]: """Get episodes grouped by season from mp4 files. Args: @@ -364,7 +372,7 @@ class SerieScanner: Returns: Dictionary mapping season to list of episode numbers """ - episodes_dict = {} + episodes_dict: dict[int, list[int]] = {} for file in mp4_files: season, episode = self.__get_episode_and_season(file) @@ -375,7 +383,11 @@ class SerieScanner: episodes_dict[season] = [episode] return episodes_dict - def __get_missing_episodes_and_season(self, key: str, mp4_files: list): + def __get_missing_episodes_and_season( + self, + key: str, + mp4_files: Iterable[str] + ) -> tuple[dict[int, list[int]], str]: """Get missing episodes for a serie. Args: @@ -388,7 +400,7 @@ class SerieScanner: # key season , value count of episodes expected_dict = self.loader.get_season_episode_count(key) filedict = self.__get_episodes_and_seasons(mp4_files) - episodes_dict = {} + episodes_dict: dict[int, list[int]] = {} for season, expected_count in expected_dict.items(): existing_episodes = filedict.get(season, []) missing_episodes = [ diff --git a/src/core/providers/aniworld_provider.py b/src/core/providers/aniworld_provider.py index 536f1fc..2672ced 100644 --- a/src/core/providers/aniworld_provider.py +++ b/src/core/providers/aniworld_provider.py @@ -27,38 +27,74 @@ noKeyFound_logger = logging.getLogger("NoKeyFound") noKeyFound_handler = logging.FileHandler("../../NoKeyFound.log") noKeyFound_handler.setLevel(logging.ERROR) + class AniworldLoader(Loader): def __init__(self): - self.SUPPORTED_PROVIDERS = ["VOE", "Doodstream", "Vidmoly", "Vidoza", "SpeedFiles", "Streamtape", "Luluvdo"] + self.SUPPORTED_PROVIDERS = [ + "VOE", + "Doodstream", + "Vidmoly", + "Vidoza", + "SpeedFiles", + "Streamtape", + "Luluvdo", + ] self.AniworldHeaders = { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", - "cache-control": "max-age=0", - "priority": "u=0, i", - "sec-ch-ua": '"Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "sec-fetch-site": "none", - "sec-fetch-user": "?1", - "upgrade-insecure-requests": "1", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0" - } - self.INVALID_PATH_CHARS = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '&'] + "accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,image/apng,*/*;q=0.8" + ), + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": ( + "de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6" + ), + "cache-control": "max-age=0", + "priority": "u=0, i", + "sec-ch-ua": ( + '"Chromium";v="136", "Microsoft Edge";v="136", ' + '"Not.A/Brand";v="99"' + ), + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0" + ), + } + self.INVALID_PATH_CHARS = [ + "<", + ">", + ":", + '"', + "/", + "\\", + "|", + "?", + "*", + "&", + ] self.RANDOM_USER_AGENT = UserAgent().random - self.LULUVDO_USER_AGENT = "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0" + self.LULUVDO_USER_AGENT = ( + "Mozilla/5.0 (Android 15; Mobile; rv:132.0) " + "Gecko/132.0 Firefox/132.0" + ) self.PROVIDER_HEADERS = { - "Vidmoly": ['Referer: "https://vidmoly.to"'], - "Doodstream": ['Referer: "https://dood.li/"'], - "VOE": [f'User-Agent: {self.RANDOM_USER_AGENT}'], - "Luluvdo": [ - f'User-Agent: {self.LULUVDO_USER_AGENT}', - 'Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', - 'Origin: "https://luluvdo.com"', - 'Referer: "https://luluvdo.com/"' - ]} + "Vidmoly": ['Referer: "https://vidmoly.to"'], + "Doodstream": ['Referer: "https://dood.li/"'], + "VOE": [f"User-Agent: {self.RANDOM_USER_AGENT}"], + "Luluvdo": [ + f"User-Agent: {self.LULUVDO_USER_AGENT}", + "Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + 'Origin: "https://luluvdo.com"', + 'Referer: "https://luluvdo.com/"', + ], + } self.ANIWORLD_TO = "https://aniworld.to" self.session = requests.Session() @@ -66,7 +102,7 @@ class AniworldLoader(Loader): retries = Retry( total=5, # Number of retries backoff_factor=1, # Delay multiplier (1s, 2s, 4s, ...) - status_forcelist=[500, 502, 503, 504], # Retry for specific HTTP errors + status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"] ) @@ -96,12 +132,13 @@ class AniworldLoader(Loader): Returns: List of found series """ - search_url = f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}" + search_url = ( + f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}" + ) anime_list = self.fetch_anime_list(search_url) return anime_list - def fetch_anime_list(self, url: str) -> list: response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() @@ -297,7 +334,7 @@ class AniworldLoader(Loader): self._get_episode_html(season, episode, key).content, 'html.parser' ) - providers = {} + providers: dict[str, dict[int, str]] = {} episode_links = soup.find_all( 'li', class_=lambda x: x and x.startswith('episodeLink') @@ -390,7 +427,7 @@ class AniworldLoader(Loader): "VOE" ).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT) - def get_season_episode_count(self, slug : str) -> dict: + def get_season_episode_count(self, slug: str) -> dict: base_url = f"{self.ANIWORLD_TO}/anime/stream/{slug}/" response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT) soup = BeautifulSoup(response.content, 'html.parser') @@ -402,7 +439,10 @@ class AniworldLoader(Loader): for season in range(1, number_of_seasons + 1): season_url = f"{base_url}staffel-{season}" - response = requests.get(season_url, timeout=self.DEFAULT_REQUEST_TIMEOUT) + response = requests.get( + season_url, + timeout=self.DEFAULT_REQUEST_TIMEOUT, + ) soup = BeautifulSoup(response.content, 'html.parser') episode_links = soup.find_all('a', href=True) diff --git a/src/server/api/anime.py b/src/server/api/anime.py index 466574d..d6f8156 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -24,40 +24,76 @@ class AnimeDetail(BaseModel): @router.get("/", response_model=List[AnimeSummary]) async def list_anime( _auth: dict = Depends(require_auth), - series_app=Depends(get_series_app) -): - """List series with missing episodes using the core SeriesApp.""" + series_app: Any = Depends(get_series_app), +) -> List[AnimeSummary]: + """List library series that still have missing episodes. + + Args: + _auth: Ensures the caller is authenticated (value unused). + series_app: Core `SeriesApp` instance provided via dependency. + + Returns: + List[AnimeSummary]: Summary entries describing missing content. + + Raises: + HTTPException: When the underlying lookup fails. + """ try: series = series_app.List.GetMissingEpisode() - result = [] - for s in series: - missing = 0 - try: - missing = len(s.episodeDict) if getattr(s, "episodeDict", None) is not None else 0 - except Exception: - missing = 0 - result.append(AnimeSummary(id=getattr(s, "key", getattr(s, "folder", "")), title=getattr(s, "name", ""), missing_episodes=missing)) - return result + summaries: List[AnimeSummary] = [] + for serie in series: + episodes_dict = getattr(serie, "episodeDict", {}) or {} + missing_episodes = len(episodes_dict) + key = getattr(serie, "key", getattr(serie, "folder", "")) + title = getattr(serie, "name", "") + summaries.append( + AnimeSummary( + id=key, + title=title, + missing_episodes=missing_episodes, + ) + ) + return summaries except HTTPException: raise - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve anime list") + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve anime list", + ) from exc @router.post("/rescan") -async def trigger_rescan(series_app=Depends(get_series_app)): - """Trigger a rescan of local series data using SeriesApp.ReScan.""" +async def trigger_rescan(series_app: Any = Depends(get_series_app)) -> dict: + """Kick off a background rescan of the local library. + + Args: + series_app: Core `SeriesApp` instance provided via dependency. + + Returns: + Dict[str, Any]: Status payload communicating whether the rescan + launched successfully. + + Raises: + HTTPException: If the rescan command is unsupported or fails. + """ try: # SeriesApp.ReScan expects a callback; pass a no-op if hasattr(series_app, "ReScan"): series_app.ReScan(lambda *args, **kwargs: None) return {"success": True, "message": "Rescan started"} - else: - raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Rescan not available") + + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Rescan not available", + ) except HTTPException: raise - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start rescan") + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to start rescan", + ) from exc class SearchRequest(BaseModel): @@ -65,56 +101,107 @@ class SearchRequest(BaseModel): @router.post("/search", response_model=List[AnimeSummary]) -async def search_anime(request: SearchRequest, series_app=Depends(get_series_app)): - """Search for new anime by query text using the SeriesApp loader.""" +async def search_anime( + request: SearchRequest, + series_app: Any = Depends(get_series_app), +) -> List[AnimeSummary]: + """Search the provider for additional series matching a query. + + Args: + request: Incoming payload containing the search term. + series_app: Core `SeriesApp` instance provided via dependency. + + Returns: + List[AnimeSummary]: Discovered matches returned from the provider. + + Raises: + HTTPException: When provider communication fails. + """ try: - matches = [] + matches: List[Any] = [] if hasattr(series_app, "search"): # SeriesApp.search is synchronous in core; call directly matches = series_app.search(request.query) - result = [] - for m in matches: - # matches may be dicts or objects - if isinstance(m, dict): - mid = m.get("key") or m.get("id") or "" - title = m.get("title") or m.get("name") or "" - missing = int(m.get("missing", 0)) if m.get("missing") is not None else 0 + summaries: List[AnimeSummary] = [] + for match in matches: + if isinstance(match, dict): + identifier = match.get("key") or match.get("id") or "" + title = match.get("title") or match.get("name") or "" + missing = match.get("missing") + missing_episodes = int(missing) if missing is not None else 0 else: - mid = getattr(m, "key", getattr(m, "id", "")) - title = getattr(m, "title", getattr(m, "name", "")) - missing = int(getattr(m, "missing", 0)) - result.append(AnimeSummary(id=mid, title=title, missing_episodes=missing)) + identifier = getattr(match, "key", getattr(match, "id", "")) + title = getattr(match, "title", getattr(match, "name", "")) + missing_episodes = int(getattr(match, "missing", 0)) - return result + summaries.append( + AnimeSummary( + id=identifier, + title=title, + missing_episodes=missing_episodes, + ) + ) + + return summaries except HTTPException: raise - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Search failed") + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Search failed", + ) from exc @router.get("/{anime_id}", response_model=AnimeDetail) -async def get_anime(anime_id: str, series_app=Depends(get_series_app)): - """Return detailed info about a series from SeriesApp.List.""" +async def get_anime( + anime_id: str, + series_app: Any = Depends(get_series_app) +) -> AnimeDetail: + """Return detailed information about a specific series. + + Args: + anime_id: Provider key or folder name of the requested series. + series_app: Core `SeriesApp` instance provided via dependency. + + Returns: + AnimeDetail: Detailed series metadata including episode list. + + Raises: + HTTPException: If the anime cannot be located or retrieval fails. + """ try: series = series_app.List.GetList() found = None - for s in series: - if getattr(s, "key", None) == anime_id or getattr(s, "folder", None) == anime_id: - found = s + for serie in series: + matches_key = getattr(serie, "key", None) == anime_id + matches_folder = getattr(serie, "folder", None) == anime_id + if matches_key or matches_folder: + found = serie break if not found: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Series not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Series not found", + ) - episodes = [] - epdict = getattr(found, "episodeDict", {}) or {} - for season, eps in epdict.items(): - for e in eps: - episodes.append(f"{season}-{e}") + episodes: List[str] = [] + episode_dict = getattr(found, "episodeDict", {}) or {} + for season, episode_numbers in episode_dict.items(): + for episode in episode_numbers: + episodes.append(f"{season}-{episode}") - return AnimeDetail(id=getattr(found, "key", getattr(found, "folder", "")), title=getattr(found, "name", ""), episodes=episodes, description=getattr(found, "description", None)) + return AnimeDetail( + id=getattr(found, "key", getattr(found, "folder", "")), + title=getattr(found, "name", ""), + episodes=episodes, + description=getattr(found, "description", None), + ) except HTTPException: raise - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve series details") + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve series details", + ) from exc diff --git a/src/server/utils/dependencies.py b/src/server/utils/dependencies.py index 758d179..b4e446e 100644 --- a/src/server/utils/dependencies.py +++ b/src/server/utils/dependencies.py @@ -5,7 +5,7 @@ This module provides dependency injection functions for the FastAPI application, including SeriesApp instances, AnimeService, DownloadService, database sessions, and authentication dependencies. """ -from typing import AsyncGenerator, Optional +from typing import TYPE_CHECKING, AsyncGenerator, Optional from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -19,6 +19,10 @@ from src.config.settings import settings from src.core.SeriesApp import SeriesApp from src.server.services.auth_service import AuthError, auth_service +if TYPE_CHECKING: + from src.server.services.anime_service import AnimeService + from src.server.services.download_service import DownloadService + # Security scheme for JWT authentication # Use auto_error=False to handle errors manually and return 401 instead of 403 security = HTTPBearer(auto_error=False) @@ -28,8 +32,8 @@ security = HTTPBearer(auto_error=False) _series_app: Optional[SeriesApp] = None # Global service instances -_anime_service: Optional[object] = None -_download_service: Optional[object] = None +_anime_service: Optional["AnimeService"] = None +_download_service: Optional["DownloadService"] = None def get_series_app() -> SeriesApp: @@ -193,7 +197,13 @@ def get_current_user_optional( class CommonQueryParams: """Common query parameters for API endpoints.""" - def __init__(self, skip: int = 0, limit: int = 100): + def __init__(self, skip: int = 0, limit: int = 100) -> None: + """Create a reusable pagination parameter container. + + Args: + skip: Number of records to offset when querying collections. + limit: Maximum number of records to return in a single call. + """ self.skip = skip self.limit = limit @@ -235,7 +245,7 @@ async def log_request_dependency(): pass -def get_anime_service() -> object: +def get_anime_service() -> "AnimeService": """ Dependency to get AnimeService instance. @@ -257,29 +267,39 @@ def get_anime_service() -> object: import sys import tempfile - running_tests = "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules + running_tests = ( + "PYTEST_CURRENT_TEST" in os.environ + or "pytest" in sys.modules + ) if running_tests: settings.anime_directory = tempfile.gettempdir() else: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Anime directory not configured. Please complete setup.", + detail=( + "Anime directory not configured. " + "Please complete setup." + ), ) if _anime_service is None: try: from src.server.services.anime_service import AnimeService + _anime_service = AnimeService(settings.anime_directory) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to initialize AnimeService: {str(e)}", + detail=( + "Failed to initialize AnimeService: " + f"{str(e)}" + ), ) from e return _anime_service -def get_download_service() -> object: +def get_download_service() -> "DownloadService": """ Dependency to get DownloadService instance. @@ -293,46 +313,49 @@ def get_download_service() -> object: if _download_service is None: try: + from src.server.services import ( + websocket_service as websocket_service_module, + ) from src.server.services.download_service import DownloadService - from src.server.services.websocket_service import get_websocket_service - # Get anime service first (required dependency) anime_service = get_anime_service() - - # Initialize download service with anime service _download_service = DownloadService(anime_service) - - # Setup WebSocket broadcast callback - ws_service = get_websocket_service() - - async def broadcast_callback(update_type: str, data: dict): + + ws_service = websocket_service_module.get_websocket_service() + + async def broadcast_callback(update_type: str, data: dict) -> None: """Broadcast download updates via WebSocket.""" if update_type == "download_progress": await ws_service.broadcast_download_progress( - data.get("download_id", ""), data + data.get("download_id", ""), + data, ) elif update_type == "download_complete": await ws_service.broadcast_download_complete( - data.get("download_id", ""), data + data.get("download_id", ""), + data, ) elif update_type == "download_failed": await ws_service.broadcast_download_failed( - data.get("download_id", ""), data + data.get("download_id", ""), + data, ) elif update_type == "queue_status": await ws_service.broadcast_queue_status(data) else: - # Generic queue update await ws_service.broadcast_queue_status(data) - + _download_service.set_broadcast_callback(broadcast_callback) - + except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to initialize DownloadService: {str(e)}", + detail=( + "Failed to initialize DownloadService: " + f"{str(e)}" + ), ) from e return _download_service