cleanup
This commit is contained in:
parent
1f39f07c5d
commit
04799633b4
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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 ✅
|
||||
@ -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"
|
||||
}
|
||||
@ -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 = (
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,36 +313,36 @@ 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()
|
||||
ws_service = websocket_service_module.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."""
|
||||
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)
|
||||
@ -332,7 +352,10 @@ def get_download_service() -> object:
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user