This commit is contained in:
Lukas 2025-10-22 13:38:46 +02:00
parent 1f39f07c5d
commit 04799633b4
9 changed files with 411 additions and 571 deletions

View File

@ -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

View File

@ -78,28 +78,6 @@ conda run -n AniWorld python -m pytest tests/ -v -s
### 1⃣ Code Follows PEP8 and Project Coding Standards ### 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) #### Blank Line Spacing Issues (PEP8 §4)
- [ ] `src/cli/Main.py` - Missing blank lines between methods - [ ] `src/cli/Main.py` - Missing blank lines between methods

View File

@ -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 ✅

View File

@ -1,7 +1,7 @@
{ {
"pending": [ "pending": [
{ {
"id": "42233fa7-50b3-4941-882c-be23e776e88c", "id": "a99e0a91-c71c-49c6-a26b-f0682643b61f",
"serie_id": "workflow-series", "serie_id": "workflow-series",
"serie_name": "Workflow Test Series", "serie_name": "Workflow Test Series",
"episode": { "episode": {
@ -11,7 +11,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "high", "priority": "high",
"added_at": "2025-10-22T10:57:26.642934Z", "added_at": "2025-10-22T11:08:17.906509Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -20,7 +20,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "eeafcd93-9df7-4429-872c-1220a642b775", "id": "564070a3-6548-4238-96d3-05f3fc9a0a6b",
"serie_id": "series-2", "serie_id": "series-2",
"serie_name": "Series 2", "serie_name": "Series 2",
"episode": { "episode": {
@ -30,7 +30,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.331686Z", "added_at": "2025-10-22T11:08:17.631588Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -39,7 +39,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "21b6d879-7478-4711-8a91-647383cc2d64", "id": "3587a189-0b04-4954-b480-20530cf5fdb9",
"serie_id": "series-1", "serie_id": "series-1",
"serie_name": "Series 1", "serie_name": "Series 1",
"episode": { "episode": {
@ -49,7 +49,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.326103Z", "added_at": "2025-10-22T11:08:17.629702Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -58,7 +58,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "68ade854-067f-4486-bfb3-f0d0ce2cdb88", "id": "18c6c1dc-279e-44d7-b032-6f14aa061cc3",
"serie_id": "series-0", "serie_id": "series-0",
"serie_name": "Series 0", "serie_name": "Series 0",
"episode": { "episode": {
@ -68,7 +68,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.321881Z", "added_at": "2025-10-22T11:08:17.626684Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -77,7 +77,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "bf18f799-ce21-4f06-bf8c-cc4a26b815f5", "id": "0e47ef3d-e233-4631-bea3-a1d15ac9b2ad",
"serie_id": "series-high", "serie_id": "series-high",
"serie_name": "Series High", "serie_name": "Series High",
"episode": { "episode": {
@ -87,7 +87,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "high", "priority": "high",
"added_at": "2025-10-22T10:57:26.077002Z", "added_at": "2025-10-22T11:08:17.404606Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -96,7 +96,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "daebc904-6c32-4ca5-947c-18d25d43fdfe", "id": "e09d09f4-46d7-4408-960b-1508389f0e5a",
"serie_id": "test-series-2", "serie_id": "test-series-2",
"serie_name": "Another Series", "serie_name": "Another Series",
"episode": { "episode": {
@ -106,7 +106,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "high", "priority": "high",
"added_at": "2025-10-22T10:57:26.051169Z", "added_at": "2025-10-22T11:08:17.379463Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -115,7 +115,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "aee9fdf2-4436-43ad-8c08-1bcdc2013bb4", "id": "9e3eac5f-3d39-45b3-8584-96bcf9901af9",
"serie_id": "test-series-1", "serie_id": "test-series-1",
"serie_name": "Test Anime Series", "serie_name": "Test Anime Series",
"episode": { "episode": {
@ -125,7 +125,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.023723Z", "added_at": "2025-10-22T11:08:17.354951Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -134,7 +134,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "c45a72c3-11af-48dd-ba23-2f2d93bd0d3c", "id": "6cb7f8f0-ba85-4778-a7af-2d0b43e26dcb",
"serie_id": "test-series-1", "serie_id": "test-series-1",
"serie_name": "Test Anime Series", "serie_name": "Test Anime Series",
"episode": { "episode": {
@ -144,7 +144,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.023820Z", "added_at": "2025-10-22T11:08:17.355041Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -153,7 +153,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "83e1e2bc-a1b5-49a2-a183-421884c183ce", "id": "c43d205f-70f8-48d1-bde7-5f3e57cd0775",
"serie_id": "series-normal", "serie_id": "series-normal",
"serie_name": "Series Normal", "serie_name": "Series Normal",
"episode": { "episode": {
@ -163,7 +163,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.082663Z", "added_at": "2025-10-22T11:08:17.406642Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -172,7 +172,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "9a78d1b5-9e23-4e79-b4e4-40bf0a4ce8a1", "id": "841046e8-9bd9-45e6-b53c-127a97918568",
"serie_id": "series-low", "serie_id": "series-low",
"serie_name": "Series Low", "serie_name": "Series Low",
"episode": { "episode": {
@ -182,7 +182,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "low", "priority": "low",
"added_at": "2025-10-22T10:57:26.084695Z", "added_at": "2025-10-22T11:08:17.410918Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -191,7 +191,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "d2f8fa60-9806-45d0-8ca8-8bf46499e9e7", "id": "515d07ee-7ab6-4b37-acd9-cc4cc8066141",
"serie_id": "test-series", "serie_id": "test-series",
"serie_name": "Test Series", "serie_name": "Test Series",
"episode": { "episode": {
@ -201,7 +201,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.269365Z", "added_at": "2025-10-22T11:08:17.579360Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -210,7 +210,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "1605e153-67e5-4a47-b5a2-e721cbbfe609", "id": "52ca17ce-3c35-454d-b0e7-4fc72da50282",
"serie_id": "test-series", "serie_id": "test-series",
"serie_name": "Test Series", "serie_name": "Test Series",
"episode": { "episode": {
@ -220,7 +220,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.360496Z", "added_at": "2025-10-22T11:08:17.656563Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -229,7 +229,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "c10c1bf1-ccab-45bb-8526-38da70efb337", "id": "f8688ae7-2a1c-42a0-88af-29d5e2c17542",
"serie_id": "invalid-series", "serie_id": "invalid-series",
"serie_name": "Invalid Series", "serie_name": "Invalid Series",
"episode": { "episode": {
@ -239,7 +239,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.417614Z", "added_at": "2025-10-22T11:08:17.705743Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -248,7 +248,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "449e2572-c506-4f22-878f-24637b64ac89", "id": "e93f5198-cb70-4e6a-b19e-c13b52286027",
"serie_id": "test-series", "serie_id": "test-series",
"serie_name": "Test Series", "serie_name": "Test Series",
"episode": { "episode": {
@ -258,7 +258,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.442816Z", "added_at": "2025-10-22T11:08:17.731256Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -267,45 +267,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "cf9ad8ab-deeb-400c-8fc8-791fe7ce2dc5", "id": "bc8d7f8f-b283-4364-9cdc-c4745d2c182a",
"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",
"serie_id": "series-4", "serie_id": "series-4",
"serie_name": "Series 4", "serie_name": "Series 4",
"episode": { "episode": {
@ -315,7 +277,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.485459Z", "added_at": "2025-10-22T11:08:17.765884Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -324,7 +286,26 @@
"source_url": null "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_id": "series-2",
"serie_name": "Series 2", "serie_name": "Series 2",
"episode": { "episode": {
@ -334,7 +315,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.486109Z", "added_at": "2025-10-22T11:08:17.768218Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -343,7 +324,26 @@
"source_url": null "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_id": "series-0",
"serie_name": "Series 0", "serie_name": "Series 0",
"episode": { "episode": {
@ -353,7 +353,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.486790Z", "added_at": "2025-10-22T11:08:17.769549Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -362,7 +362,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "800ca4df-12c0-4ebf-a5d6-139732d22fd6", "id": "cd33d997-7e96-4105-9267-06811cc20439",
"serie_id": "persistent-series", "serie_id": "persistent-series",
"serie_name": "Persistent Series", "serie_name": "Persistent Series",
"episode": { "episode": {
@ -372,7 +372,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.556798Z", "added_at": "2025-10-22T11:08:17.829367Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -381,7 +381,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "3c693ed7-6a9e-4afd-a71e-99ec549a4d00", "id": "89c65c60-a936-44a0-a0be-6c97fd9ce5a7",
"serie_id": "ws-series", "serie_id": "ws-series",
"serie_name": "WebSocket Series", "serie_name": "WebSocket Series",
"episode": { "episode": {
@ -391,7 +391,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.615000Z", "added_at": "2025-10-22T11:08:17.880458Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -400,7 +400,7 @@
"source_url": null "source_url": null
}, },
{ {
"id": "3b1ae938-e470-4449-8e04-9c576a55f636", "id": "031ecfa1-e940-407f-b52f-e532147fbd99",
"serie_id": "pause-test", "serie_id": "pause-test",
"serie_name": "Pause Test Series", "serie_name": "Pause Test Series",
"episode": { "episode": {
@ -410,7 +410,7 @@
}, },
"status": "pending", "status": "pending",
"priority": "normal", "priority": "normal",
"added_at": "2025-10-22T10:57:26.778755Z", "added_at": "2025-10-22T11:08:18.039451Z",
"started_at": null, "started_at": null,
"completed_at": null, "completed_at": null,
"progress": null, "progress": null,
@ -421,5 +421,5 @@
], ],
"active": [], "active": [],
"failed": [], "failed": [],
"timestamp": "2025-10-22T10:57:26.779584+00:00" "timestamp": "2025-10-22T11:08:18.039697+00:00"
} }

View File

@ -1,13 +1,12 @@
import logging import logging
import os import os
import sys
import time import time
from typing import Any, Callable, Mapping, Optional, Sequence
from rich.progress import Progress from rich.progress import Progress
from ..core.entities import SerieList from ..core.entities import SerieList
from ..core.entities.series import Serie from ..core.entities.series import Serie
from ..core.providers import aniworld_provider
from ..core.providers.provider_factory import Loaders from ..core.providers.provider_factory import Loaders
from ..core.SerieScanner import SerieScanner from ..core.SerieScanner import SerieScanner
@ -30,35 +29,37 @@ for h in logging.getLogger().handlers:
class NoKeyFoundException(Exception): class NoKeyFoundException(Exception):
"""Exception raised when an anime key cannot be found.""" """Exception raised when an anime key cannot be found."""
pass pass
class MatchNotFoundError(Exception): class MatchNotFoundError(Exception):
"""Exception raised when an anime key cannot be found.""" """Exception raised when an anime key cannot be found."""
pass pass
class SeriesApp: 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 SeriesApp._initialization_count += 1
# Only show initialization message for the first instance # Only show initialization message for the first instance
if SeriesApp._initialization_count <= 1: if SeriesApp._initialization_count <= 1:
print("Please wait while initializing...") print("Please wait while initializing...")
self.progress = None self.progress: Optional[Progress] = None
self.directory_to_search = directory_to_search self.directory_to_search = directory_to_search
self.Loaders = Loaders() self.Loaders: Loaders = Loaders()
loader = self.Loaders.GetLoader(key="aniworld.to") loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(directory_to_search, loader) self.SerieScanner = SerieScanner(directory_to_search, loader)
self.List = SerieList(self.directory_to_search) self.List = SerieList(self.directory_to_search)
self.__init_list__() self.__init_list__()
def __init_list__(self): def __init_list__(self) -> None:
"""Initialize the series list by fetching missing episodes.""" """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 all series with assigned numbers."""
print("\nCurrent result:") print("\nCurrent result:")
for i, serie in enumerate(self.series_list, 1): for i, serie in enumerate(self.series_list, 1):
@ -68,12 +69,12 @@ class SeriesApp:
else: else:
print(f"{i}. {serie.name}") 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.""" """Search for anime series by name."""
loader = self.Loaders.GetLoader(key="aniworld.to") loader = self.Loaders.GetLoader(key="aniworld.to")
return loader.search(words) return loader.search(words)
def get_user_selection(self): def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Handle user input for selecting series.""" """Handle user input for selecting series."""
self.display_series() self.display_series()
while True: while True:
@ -86,9 +87,9 @@ class SeriesApp:
if selection == "exit": if selection == "exit":
return None return None
selected_series = [] selected_series: list[Serie] = []
if selection == "all": if selection == "all":
selected_series = self.series_list selected_series = list(self.series_list)
else: else:
try: try:
indexes = [ indexes = [
@ -118,7 +119,14 @@ class SeriesApp:
print(msg) print(msg)
return None 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. """Retry a function with exponential backoff.
Args: Args:
@ -140,7 +148,7 @@ class SeriesApp:
time.sleep(delay) time.sleep(delay)
return False return False
def download_series(self, series): def download_series(self, series: Sequence[Serie]) -> None:
"""Simulate the downloading process with a progress bar.""" """Simulate the downloading process with a progress bar."""
total_downloaded = 0 total_downloaded = 0
total_episodes = sum( total_episodes = sum(
@ -182,7 +190,7 @@ class SeriesApp:
episode, episode,
serie.key, serie.key,
"German Dub", "German Dub",
self.print_Download_Progress, self.print_download_progress,
) )
downloaded += 1 downloaded += 1
@ -195,20 +203,24 @@ class SeriesApp:
self.progress.stop() self.progress.stop()
self.progress = None 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. """Update download progress in the UI.
Args: Args:
d: Dictionary containing download status information d: Dictionary containing download status information
""" """
# Use self.progress and self.download_progress_task to display progress # Use self.progress and self.download_progress_task to display progress
if (self.progress is None or if (
not hasattr(self, "download_progress_task")): self.progress is None
or not hasattr(self, "download_progress_task")
):
return return
if d["status"] == "downloading": if d["status"] == "downloading":
total = (d.get("total_bytes") or total = (
d.get("total_bytes_estimate")) d.get("total_bytes")
or d.get("total_bytes_estimate")
)
downloaded = d.get("downloaded_bytes", 0) downloaded = d.get("downloaded_bytes", 0)
if total: if total:
percent = downloaded / total * 100 percent = downloaded / total * 100
@ -232,7 +244,7 @@ class SeriesApp:
description=desc description=desc
) )
def search_mode(self): def search_mode(self) -> None:
"""Search for a series and allow user to select an option.""" """Search for a series and allow user to select an option."""
search_string = input("Enter search string: ").strip() search_string = input("Enter search string: ").strip()
results = self.search(search_string) results = self.search(search_string)
@ -272,10 +284,10 @@ class SeriesApp:
except ValueError: except ValueError:
print("Invalid input. Try again.") 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) self.progress.update(self.task1, advance=1)
def run(self): def run(self) -> None:
"""Main function to run the app.""" """Main function to run the app."""
while True: while True:
prompt = ( prompt = (

View File

@ -10,7 +10,7 @@ import os
import re import re
import traceback import traceback
import uuid import uuid
from typing import Callable, Optional from typing import Callable, Iterable, Iterator, Optional
from src.core.entities.series import Serie from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
@ -40,7 +40,7 @@ class SerieScanner:
basePath: str, basePath: str,
loader: Loader, loader: Loader,
callback_manager: Optional[CallbackManager] = None callback_manager: Optional[CallbackManager] = None
): ) -> None:
""" """
Initialize the SerieScanner. Initialize the SerieScanner.
@ -49,10 +49,12 @@ class SerieScanner:
loader: Loader instance for fetching series information loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates callback_manager: Optional callback manager for progress updates
""" """
self.directory = basePath self.directory: str = basePath
self.folderDict: dict[str, Serie] = {} self.folderDict: dict[str, Serie] = {}
self.loader = loader self.loader: Loader = loader
self._callback_manager = callback_manager or CallbackManager() self._callback_manager: CallbackManager = (
callback_manager or CallbackManager()
)
self._current_operation_id: Optional[str] = None self._current_operation_id: Optional[str] = None
logger.info("Initialized SerieScanner with base path: %s", basePath) logger.info("Initialized SerieScanner with base path: %s", basePath)
@ -62,22 +64,22 @@ class SerieScanner:
"""Get the callback manager instance.""" """Get the callback manager instance."""
return self._callback_manager return self._callback_manager
def reinit(self): def reinit(self) -> None:
"""Reinitialize the folder dictionary.""" """Reinitialize the folder dictionary."""
self.folderDict: dict[str, Serie] = {} 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. """Check if a string is None or whitespace.
Args: Args:
s: String value to check value: String value to check
Returns: Returns:
True if string is None or contains only whitespace 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. """Get the total number of folders to scan.
Returns: Returns:
@ -86,7 +88,10 @@ class SerieScanner:
result = self.__find_mp4_files() result = self.__find_mp4_files()
return sum(1 for _ in result) 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. Scan directories for anime series and missing episodes.
@ -127,10 +132,10 @@ class SerieScanner:
counter += 1 counter += 1
# Calculate progress # Calculate progress
percentage = ( if total_to_scan > 0:
(counter / total_to_scan * 100) percentage = (counter / total_to_scan) * 100
if total_to_scan > 0 else 0 else:
) percentage = 0.0
# Notify progress # Notify progress
self._callback_manager.notify_progress( self._callback_manager.notify_progress(
@ -262,13 +267,13 @@ class SerieScanner:
raise raise
def __find_mp4_files(self): def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]:
"""Find all .mp4 files in the directory structure.""" """Find all .mp4 files in the directory structure."""
logger.info("Scanning for .mp4 files") logger.info("Scanning for .mp4 files")
for anime_name in os.listdir(self.directory): for anime_name in os.listdir(self.directory):
anime_path = os.path.join(self.directory, anime_name) anime_path = os.path.join(self.directory, anime_name)
if os.path.isdir(anime_path): if os.path.isdir(anime_path):
mp4_files = [] mp4_files: list[str] = []
has_files = False has_files = False
for root, _, files in os.walk(anime_path): for root, _, files in os.walk(anime_path):
for file in files: for file in files:
@ -277,7 +282,7 @@ class SerieScanner:
has_files = True has_files = True
yield anime_name, mp4_files if has_files else [] 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.""" """Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip() cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logger.debug( logger.debug(
@ -287,7 +292,7 @@ class SerieScanner:
) )
return cleaned_string 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. """Read serie data from file or key file.
Args: Args:
@ -322,7 +327,7 @@ class SerieScanner:
return None 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. """Extract season and episode numbers from filename.
Args: Args:
@ -355,7 +360,10 @@ class SerieScanner:
"Season and episode pattern not found in the filename." "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. """Get episodes grouped by season from mp4 files.
Args: Args:
@ -364,7 +372,7 @@ class SerieScanner:
Returns: Returns:
Dictionary mapping season to list of episode numbers Dictionary mapping season to list of episode numbers
""" """
episodes_dict = {} episodes_dict: dict[int, list[int]] = {}
for file in mp4_files: for file in mp4_files:
season, episode = self.__get_episode_and_season(file) season, episode = self.__get_episode_and_season(file)
@ -375,7 +383,11 @@ class SerieScanner:
episodes_dict[season] = [episode] episodes_dict[season] = [episode]
return episodes_dict 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. """Get missing episodes for a serie.
Args: Args:
@ -388,7 +400,7 @@ class SerieScanner:
# key season , value count of episodes # key season , value count of episodes
expected_dict = self.loader.get_season_episode_count(key) expected_dict = self.loader.get_season_episode_count(key)
filedict = self.__get_episodes_and_seasons(mp4_files) filedict = self.__get_episodes_and_seasons(mp4_files)
episodes_dict = {} episodes_dict: dict[int, list[int]] = {}
for season, expected_count in expected_dict.items(): for season, expected_count in expected_dict.items():
existing_episodes = filedict.get(season, []) existing_episodes = filedict.get(season, [])
missing_episodes = [ missing_episodes = [

View File

@ -27,16 +27,33 @@ noKeyFound_logger = logging.getLogger("NoKeyFound")
noKeyFound_handler = logging.FileHandler("../../NoKeyFound.log") noKeyFound_handler = logging.FileHandler("../../NoKeyFound.log")
noKeyFound_handler.setLevel(logging.ERROR) noKeyFound_handler.setLevel(logging.ERROR)
class AniworldLoader(Loader): class AniworldLoader(Loader):
def __init__(self): 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 = { self.AniworldHeaders = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "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-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", "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", "cache-control": "max-age=0",
"priority": "u=0, i", "priority": "u=0, i",
"sec-ch-ua": '"Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"', "sec-ch-ua": (
'"Chromium";v="136", "Microsoft Edge";v="136", '
'"Not.A/Brand";v="99"'
),
"sec-ch-ua-mobile": "?0", "sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"', "sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document", "sec-fetch-dest": "document",
@ -44,21 +61,40 @@ class AniworldLoader(Loader):
"sec-fetch-site": "none", "sec-fetch-site": "none",
"sec-fetch-user": "?1", "sec-fetch-user": "?1",
"upgrade-insecure-requests": "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" "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.INVALID_PATH_CHARS = [
"<",
">",
":",
'"',
"/",
"\\",
"|",
"?",
"*",
"&",
]
self.RANDOM_USER_AGENT = UserAgent().random 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 = { self.PROVIDER_HEADERS = {
"Vidmoly": ['Referer: "https://vidmoly.to"'], "Vidmoly": ['Referer: "https://vidmoly.to"'],
"Doodstream": ['Referer: "https://dood.li/"'], "Doodstream": ['Referer: "https://dood.li/"'],
"VOE": [f'User-Agent: {self.RANDOM_USER_AGENT}'], "VOE": [f"User-Agent: {self.RANDOM_USER_AGENT}"],
"Luluvdo": [ "Luluvdo": [
f'User-Agent: {self.LULUVDO_USER_AGENT}', f"User-Agent: {self.LULUVDO_USER_AGENT}",
'Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', "Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
'Origin: "https://luluvdo.com"', 'Origin: "https://luluvdo.com"',
'Referer: "https://luluvdo.com/"' 'Referer: "https://luluvdo.com/"',
]} ],
}
self.ANIWORLD_TO = "https://aniworld.to" self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session() self.session = requests.Session()
@ -66,7 +102,7 @@ class AniworldLoader(Loader):
retries = Retry( retries = Retry(
total=5, # Number of retries total=5, # Number of retries
backoff_factor=1, # Delay multiplier (1s, 2s, 4s, ...) 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"] allowed_methods=["GET"]
) )
@ -96,12 +132,13 @@ class AniworldLoader(Loader):
Returns: Returns:
List of found series 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) anime_list = self.fetch_anime_list(search_url)
return anime_list return anime_list
def fetch_anime_list(self, url: str) -> list: def fetch_anime_list(self, url: str) -> list:
response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT) response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
@ -297,7 +334,7 @@ class AniworldLoader(Loader):
self._get_episode_html(season, episode, key).content, self._get_episode_html(season, episode, key).content,
'html.parser' 'html.parser'
) )
providers = {} providers: dict[str, dict[int, str]] = {}
episode_links = soup.find_all( episode_links = soup.find_all(
'li', class_=lambda x: x and x.startswith('episodeLink') 'li', class_=lambda x: x and x.startswith('episodeLink')
@ -402,7 +439,10 @@ class AniworldLoader(Loader):
for season in range(1, number_of_seasons + 1): for season in range(1, number_of_seasons + 1):
season_url = f"{base_url}staffel-{season}" 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') soup = BeautifulSoup(response.content, 'html.parser')
episode_links = soup.find_all('a', href=True) episode_links = soup.find_all('a', href=True)

View File

@ -1,4 +1,4 @@
from typing import List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
@ -24,40 +24,76 @@ class AnimeDetail(BaseModel):
@router.get("/", response_model=List[AnimeSummary]) @router.get("/", response_model=List[AnimeSummary])
async def list_anime( async def list_anime(
_auth: dict = Depends(require_auth), _auth: dict = Depends(require_auth),
series_app=Depends(get_series_app) series_app: Any = Depends(get_series_app),
): ) -> List[AnimeSummary]:
"""List series with missing episodes using the core SeriesApp.""" """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: try:
series = series_app.List.GetMissingEpisode() series = series_app.List.GetMissingEpisode()
result = [] summaries: List[AnimeSummary] = []
for s in series: for serie in series:
missing = 0 episodes_dict = getattr(serie, "episodeDict", {}) or {}
try: missing_episodes = len(episodes_dict)
missing = len(s.episodeDict) if getattr(s, "episodeDict", None) is not None else 0 key = getattr(serie, "key", getattr(serie, "folder", ""))
except Exception: title = getattr(serie, "name", "")
missing = 0 summaries.append(
result.append(AnimeSummary(id=getattr(s, "key", getattr(s, "folder", "")), title=getattr(s, "name", ""), missing_episodes=missing)) AnimeSummary(
return result id=key,
title=title,
missing_episodes=missing_episodes,
)
)
return summaries
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve anime list") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve anime list",
) from exc
@router.post("/rescan") @router.post("/rescan")
async def trigger_rescan(series_app=Depends(get_series_app)): async def trigger_rescan(series_app: Any = Depends(get_series_app)) -> dict:
"""Trigger a rescan of local series data using SeriesApp.ReScan.""" """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: try:
# SeriesApp.ReScan expects a callback; pass a no-op # SeriesApp.ReScan expects a callback; pass a no-op
if hasattr(series_app, "ReScan"): if hasattr(series_app, "ReScan"):
series_app.ReScan(lambda *args, **kwargs: None) series_app.ReScan(lambda *args, **kwargs: None)
return {"success": True, "message": "Rescan started"} 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: except HTTPException:
raise raise
except Exception: except Exception as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start rescan") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start rescan",
) from exc
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
@ -65,56 +101,107 @@ class SearchRequest(BaseModel):
@router.post("/search", response_model=List[AnimeSummary]) @router.post("/search", response_model=List[AnimeSummary])
async def search_anime(request: SearchRequest, series_app=Depends(get_series_app)): async def search_anime(
"""Search for new anime by query text using the SeriesApp loader.""" 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: try:
matches = [] matches: List[Any] = []
if hasattr(series_app, "search"): if hasattr(series_app, "search"):
# SeriesApp.search is synchronous in core; call directly # SeriesApp.search is synchronous in core; call directly
matches = series_app.search(request.query) matches = series_app.search(request.query)
result = [] summaries: List[AnimeSummary] = []
for m in matches: for match in matches:
# matches may be dicts or objects if isinstance(match, dict):
if isinstance(m, dict): identifier = match.get("key") or match.get("id") or ""
mid = m.get("key") or m.get("id") or "" title = match.get("title") or match.get("name") or ""
title = m.get("title") or m.get("name") or "" missing = match.get("missing")
missing = int(m.get("missing", 0)) if m.get("missing") is not None else 0 missing_episodes = int(missing) if missing is not None else 0
else: else:
mid = getattr(m, "key", getattr(m, "id", "")) identifier = getattr(match, "key", getattr(match, "id", ""))
title = getattr(m, "title", getattr(m, "name", "")) title = getattr(match, "title", getattr(match, "name", ""))
missing = int(getattr(m, "missing", 0)) missing_episodes = int(getattr(match, "missing", 0))
result.append(AnimeSummary(id=mid, title=title, missing_episodes=missing))
return result summaries.append(
AnimeSummary(
id=identifier,
title=title,
missing_episodes=missing_episodes,
)
)
return summaries
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Search failed") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Search failed",
) from exc
@router.get("/{anime_id}", response_model=AnimeDetail) @router.get("/{anime_id}", response_model=AnimeDetail)
async def get_anime(anime_id: str, series_app=Depends(get_series_app)): async def get_anime(
"""Return detailed info about a series from SeriesApp.List.""" 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: try:
series = series_app.List.GetList() series = series_app.List.GetList()
found = None found = None
for s in series: for serie in series:
if getattr(s, "key", None) == anime_id or getattr(s, "folder", None) == anime_id: matches_key = getattr(serie, "key", None) == anime_id
found = s matches_folder = getattr(serie, "folder", None) == anime_id
if matches_key or matches_folder:
found = serie
break break
if not found: 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 = [] episodes: List[str] = []
epdict = getattr(found, "episodeDict", {}) or {} episode_dict = getattr(found, "episodeDict", {}) or {}
for season, eps in epdict.items(): for season, episode_numbers in episode_dict.items():
for e in eps: for episode in episode_numbers:
episodes.append(f"{season}-{e}") 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: except HTTPException:
raise raise
except Exception: except Exception as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve series details") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve series details",
) from exc

View File

@ -5,7 +5,7 @@ This module provides dependency injection functions for the FastAPI
application, including SeriesApp instances, AnimeService, DownloadService, application, including SeriesApp instances, AnimeService, DownloadService,
database sessions, and authentication dependencies. 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 import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@ -19,6 +19,10 @@ from src.config.settings import settings
from src.core.SeriesApp import SeriesApp from src.core.SeriesApp import SeriesApp
from src.server.services.auth_service import AuthError, auth_service 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 # Security scheme for JWT authentication
# Use auto_error=False to handle errors manually and return 401 instead of 403 # Use auto_error=False to handle errors manually and return 401 instead of 403
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@ -28,8 +32,8 @@ security = HTTPBearer(auto_error=False)
_series_app: Optional[SeriesApp] = None _series_app: Optional[SeriesApp] = None
# Global service instances # Global service instances
_anime_service: Optional[object] = None _anime_service: Optional["AnimeService"] = None
_download_service: Optional[object] = None _download_service: Optional["DownloadService"] = None
def get_series_app() -> SeriesApp: def get_series_app() -> SeriesApp:
@ -193,7 +197,13 @@ def get_current_user_optional(
class CommonQueryParams: class CommonQueryParams:
"""Common query parameters for API endpoints.""" """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.skip = skip
self.limit = limit self.limit = limit
@ -235,7 +245,7 @@ async def log_request_dependency():
pass pass
def get_anime_service() -> object: def get_anime_service() -> "AnimeService":
""" """
Dependency to get AnimeService instance. Dependency to get AnimeService instance.
@ -257,29 +267,39 @@ def get_anime_service() -> object:
import sys import sys
import tempfile 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: if running_tests:
settings.anime_directory = tempfile.gettempdir() settings.anime_directory = tempfile.gettempdir()
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 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: if _anime_service is None:
try: try:
from src.server.services.anime_service import AnimeService from src.server.services.anime_service import AnimeService
_anime_service = AnimeService(settings.anime_directory) _anime_service = AnimeService(settings.anime_directory)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 ) from e
return _anime_service return _anime_service
def get_download_service() -> object: def get_download_service() -> "DownloadService":
""" """
Dependency to get DownloadService instance. Dependency to get DownloadService instance.
@ -293,36 +313,36 @@ def get_download_service() -> object:
if _download_service is None: if _download_service is None:
try: try:
from src.server.services import (
websocket_service as websocket_service_module,
)
from src.server.services.download_service import DownloadService 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() anime_service = get_anime_service()
# Initialize download service with anime service
_download_service = DownloadService(anime_service) _download_service = DownloadService(anime_service)
# Setup WebSocket broadcast callback ws_service = websocket_service_module.get_websocket_service()
ws_service = get_websocket_service()
async def broadcast_callback(update_type: str, data: dict): async def broadcast_callback(update_type: str, data: dict) -> None:
"""Broadcast download updates via WebSocket.""" """Broadcast download updates via WebSocket."""
if update_type == "download_progress": if update_type == "download_progress":
await ws_service.broadcast_download_progress( await ws_service.broadcast_download_progress(
data.get("download_id", ""), data data.get("download_id", ""),
data,
) )
elif update_type == "download_complete": elif update_type == "download_complete":
await ws_service.broadcast_download_complete( await ws_service.broadcast_download_complete(
data.get("download_id", ""), data data.get("download_id", ""),
data,
) )
elif update_type == "download_failed": elif update_type == "download_failed":
await ws_service.broadcast_download_failed( await ws_service.broadcast_download_failed(
data.get("download_id", ""), data data.get("download_id", ""),
data,
) )
elif update_type == "queue_status": elif update_type == "queue_status":
await ws_service.broadcast_queue_status(data) await ws_service.broadcast_queue_status(data)
else: else:
# Generic queue update
await ws_service.broadcast_queue_status(data) await ws_service.broadcast_queue_status(data)
_download_service.set_broadcast_callback(broadcast_callback) _download_service.set_broadcast_callback(broadcast_callback)
@ -332,7 +352,10 @@ def get_download_service() -> object:
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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 ) from e
return _download_service return _download_service