Compare commits
33 Commits
95b7059576
...
2441730862
| Author | SHA1 | Date | |
|---|---|---|---|
| 2441730862 | |||
| 5c4bd3d7e8 | |||
| 5c88572ac7 | |||
| a80bfba873 | |||
| 64e78bb9b8 | |||
| ec987eff80 | |||
| e414a1a358 | |||
| 8a49db2a10 | |||
| 2de3317aee | |||
| ca4bf72fde | |||
| d5f7b1598f | |||
| 57c30a0156 | |||
| 9fce617949 | |||
| 0b5faeffc9 | |||
| 18faf3fe91 | |||
| 4dba4db344 | |||
| b76ffbf656 | |||
| f0b9d50f85 | |||
| 6cdb2eb1e1 | |||
| 33aeac0141 | |||
| eaf6bb9957 | |||
| 3c6d82907d | |||
| 3be175522f | |||
| 6ebc2ed2ea | |||
| fadd4973da | |||
| 727486795c | |||
| dbb5701660 | |||
| 55781a8448 | |||
| fd76be02fd | |||
| 4649cf562d | |||
| 627f8b0cc4 | |||
| adfbdf56d0 | |||
| 02764f7e6f |
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@ -8,7 +8,7 @@
|
|||||||
"program": "${workspaceFolder}/src/server/fastapi_app.py",
|
"program": "${workspaceFolder}/src/server/fastapi_app.py",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"env": {
|
"env": {
|
||||||
"PYTHONPATH": "${workspaceFolder}/src:${workspaceFolder}",
|
"PYTHONPATH": "${workspaceFolder}/src:${workspaceFolder}",
|
||||||
"JWT_SECRET_KEY": "your-secret-key-here-debug",
|
"JWT_SECRET_KEY": "your-secret-key-here-debug",
|
||||||
@ -30,7 +30,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "uvicorn",
|
"module": "uvicorn",
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"args": [
|
"args": [
|
||||||
"src.server.fastapi_app:app",
|
"src.server.fastapi_app:app",
|
||||||
"--host",
|
"--host",
|
||||||
@ -61,7 +61,7 @@
|
|||||||
"program": "${workspaceFolder}/src/cli/Main.py",
|
"program": "${workspaceFolder}/src/cli/Main.py",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"env": {
|
"env": {
|
||||||
"PYTHONPATH": "${workspaceFolder}/src:${workspaceFolder}",
|
"PYTHONPATH": "${workspaceFolder}/src:${workspaceFolder}",
|
||||||
"LOG_LEVEL": "DEBUG",
|
"LOG_LEVEL": "DEBUG",
|
||||||
@ -79,7 +79,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "pytest",
|
"module": "pytest",
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"args": [
|
"args": [
|
||||||
"${workspaceFolder}/tests",
|
"${workspaceFolder}/tests",
|
||||||
"-v",
|
"-v",
|
||||||
@ -105,7 +105,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "pytest",
|
"module": "pytest",
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"args": [
|
"args": [
|
||||||
"${workspaceFolder}/tests/unit",
|
"${workspaceFolder}/tests/unit",
|
||||||
"-v",
|
"-v",
|
||||||
@ -126,7 +126,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "pytest",
|
"module": "pytest",
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"args": [
|
"args": [
|
||||||
"${workspaceFolder}/tests/integration",
|
"${workspaceFolder}/tests/integration",
|
||||||
"-v",
|
"-v",
|
||||||
@ -150,7 +150,7 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "uvicorn",
|
"module": "uvicorn",
|
||||||
"python": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
"python": "/home/lukas/miniconda3/envs/AniWorld/bin/python",
|
||||||
"args": [
|
"args": [
|
||||||
"src.server.fastapi_app:app",
|
"src.server.fastapi_app:app",
|
||||||
"--host",
|
"--host",
|
||||||
|
|||||||
215
SERVER_COMMANDS.md
Normal file
215
SERVER_COMMANDS.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
# Server Management Commands
|
||||||
|
|
||||||
|
Quick reference for starting, stopping, and managing the Aniworld server.
|
||||||
|
|
||||||
|
## Start Server
|
||||||
|
|
||||||
|
### Using the start script (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using conda directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld python run_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using uvicorn directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stop Server
|
||||||
|
|
||||||
|
### Using the stop script (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stop_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual commands
|
||||||
|
|
||||||
|
**Kill uvicorn processes:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "uvicorn.*fastapi_app:app"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kill process on port 8000:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -ti:8000 | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kill run_server.py processes:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "run_server.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check Server Status
|
||||||
|
|
||||||
|
**Check if port 8000 is in use:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -i:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check for running uvicorn processes:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps aux | grep uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check server is responding:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restart Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stop_server.sh && ./start_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Address already in use" Error
|
||||||
|
|
||||||
|
**Problem:** Port 8000 is already occupied
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stop_server.sh
|
||||||
|
# or
|
||||||
|
lsof -ti:8000 | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server not responding
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f logs/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check if process is running:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps aux | grep uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cannot connect to server
|
||||||
|
|
||||||
|
**Verify server is running:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check firewall:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
**Run with auto-reload:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start_server.sh # Already includes --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with custom port:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8080 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with debug logging:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LOG_LEVEL=DEBUG
|
||||||
|
./start_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Mode
|
||||||
|
|
||||||
|
**Run without auto-reload:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with systemd (Linux):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start aniworld
|
||||||
|
sudo systemctl stop aniworld
|
||||||
|
sudo systemctl restart aniworld
|
||||||
|
sudo systemctl status aniworld
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- **Web Interface:** http://127.0.0.1:8000
|
||||||
|
- **API Documentation:** http://127.0.0.1:8000/api/docs
|
||||||
|
- **Login Page:** http://127.0.0.1:8000/login
|
||||||
|
- **Queue Management:** http://127.0.0.1:8000/queue
|
||||||
|
- **Health Check:** http://127.0.0.1:8000/api/health
|
||||||
|
|
||||||
|
## Default Credentials
|
||||||
|
|
||||||
|
- **Password:** `Hallo123!`
|
||||||
|
|
||||||
|
## Log Files
|
||||||
|
|
||||||
|
- **Application logs:** `logs/app.log`
|
||||||
|
- **Download logs:** `logs/downloads/`
|
||||||
|
- **Error logs:** Check console output or systemd journal
|
||||||
|
|
||||||
|
## Quick Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Solution |
|
||||||
|
| ------------------------ | ------------------------------------ |
|
||||||
|
| Port already in use | `./stop_server.sh` |
|
||||||
|
| Server won't start | Check `logs/app.log` |
|
||||||
|
| 404 errors | Verify URL and check routing |
|
||||||
|
| WebSocket not connecting | Check server is running and firewall |
|
||||||
|
| Slow responses | Check system resources (`htop`) |
|
||||||
|
| Database errors | Check `data/` directory permissions |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set log level
|
||||||
|
export LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
|
||||||
|
|
||||||
|
# Set server port
|
||||||
|
export PORT=8000
|
||||||
|
|
||||||
|
# Set host
|
||||||
|
export HOST=127.0.0.1
|
||||||
|
|
||||||
|
# Set workers (production)
|
||||||
|
export WORKERS=4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Scripts
|
||||||
|
|
||||||
|
- `start_server.sh` - Start the server
|
||||||
|
- `stop_server.sh` - Stop the server
|
||||||
|
- `run_server.py` - Python server runner
|
||||||
|
- `scripts/setup.py` - Initial setup
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- [User Guide](docs/user_guide.md)
|
||||||
|
- [API Reference](docs/api_reference.md)
|
||||||
|
- [Deployment Guide](docs/deployment.md)
|
||||||
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"created_at": "2025-10-27T20:15:18.690820",
|
|
||||||
"last_updated": "2025-10-27T20:15:18.690826",
|
|
||||||
"download_stats": {
|
|
||||||
"total_downloads": 0,
|
|
||||||
"successful_downloads": 0,
|
|
||||||
"failed_downloads": 0,
|
|
||||||
"total_bytes_downloaded": 0,
|
|
||||||
"average_speed_mbps": 0.0,
|
|
||||||
"success_rate": 0.0,
|
|
||||||
"average_duration_seconds": 0.0
|
|
||||||
},
|
|
||||||
"series_popularity": [],
|
|
||||||
"storage_history": [],
|
|
||||||
"performance_samples": []
|
|
||||||
}
|
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"keep_days": 30
|
"keep_days": 30
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$hjDm/H8vRehdCyEkRGitVQ$JJC2Bxw8XeNA0NoG/e4rhw6PjZaN588mJ2SDY3ZPFNY",
|
"master_password_hash": "$pbkdf2-sha256$29000$8v4/p1RKyRnDWEspJSTEeA$u8rsOktLvjCgB2XeHrQvcSGj2vq.Gea0rQQt/e6Ygm0",
|
||||||
"anime_directory": "/home/lukas/Volume/serien/"
|
"anime_directory": "/home/lukas/Volume/serien/"
|
||||||
},
|
},
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {
|
|
||||||
"enabled": true,
|
|
||||||
"interval_minutes": 60
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO",
|
|
||||||
"file": null,
|
|
||||||
"max_bytes": null,
|
|
||||||
"backup_count": 3
|
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"enabled": false,
|
|
||||||
"path": "data/backups",
|
|
||||||
"keep_days": 30
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"master_password_hash": "$pbkdf2-sha256$29000$qRWiNCaEEIKQkhKiFOLcWw$P1QqwKEJHzPszsU/nHmIzdxwbTMIV2iC4tbWUuhqZlo",
|
|
||||||
"anime_directory": "/home/lukas/Volume/serien/"
|
|
||||||
},
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@ -1,150 +1,18 @@
|
|||||||
{
|
{
|
||||||
"pending": [
|
"pending": [
|
||||||
{
|
{
|
||||||
"id": "47335663-456f-44b6-a176-aa2c2ab74451",
|
"id": "b8b02c5c-257c-400a-a8b1-2d2559acdaad",
|
||||||
"serie_id": "workflow-series",
|
"serie_id": "beheneko-the-elf-girls-cat-is-secretly-an-s-ranked-monster",
|
||||||
"serie_name": "Workflow Test Series",
|
"serie_folder": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
"episode": {
|
"serie_name": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "high",
|
|
||||||
"added_at": "2025-10-27T19:15:24.278322Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "665e833d-b4b8-4fb2-810f-5a02ed1b3161",
|
|
||||||
"serie_id": "series-2",
|
|
||||||
"serie_name": "Series 2",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.825647Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "6d2d59b4-c4a7-4056-a386-d49f709f56ec",
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Series 1",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.822544Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "eb43e2ce-b782-473f-aa5e-b29e07531034",
|
|
||||||
"serie_id": "series-0",
|
|
||||||
"serie_name": "Series 0",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.817448Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "f942fc20-2eb3-44fc-b2e1-5634d3749856",
|
|
||||||
"serie_id": "series-high",
|
|
||||||
"serie_name": "Series High",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "high",
|
|
||||||
"added_at": "2025-10-27T19:15:23.494450Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d91b4625-af9f-4f84-a223-a3a68a743a6f",
|
|
||||||
"serie_id": "test-series-2",
|
|
||||||
"serie_name": "Another Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "high",
|
|
||||||
"added_at": "2025-10-27T19:15:23.458331Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "020aa6c4-b969-4290-a9f3-3951a0ebf218",
|
|
||||||
"serie_id": "test-series-1",
|
|
||||||
"serie_name": "Test Anime Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": "Episode 1"
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.424005Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "67a98da0-544d-46c6-865c-0eea068ee47d",
|
|
||||||
"serie_id": "test-series-1",
|
|
||||||
"serie_name": "Test Anime Series",
|
|
||||||
"episode": {
|
"episode": {
|
||||||
"season": 1,
|
"season": 1,
|
||||||
"episode": 2,
|
"episode": 2,
|
||||||
"title": "Episode 2"
|
"title": null
|
||||||
},
|
},
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"priority": "normal",
|
"priority": "NORMAL",
|
||||||
"added_at": "2025-10-27T19:15:23.424103Z",
|
"added_at": "2025-11-02T14:41:55.086784Z",
|
||||||
"started_at": null,
|
"started_at": null,
|
||||||
"completed_at": null,
|
"completed_at": null,
|
||||||
"progress": null,
|
"progress": null,
|
||||||
@ -153,17 +21,18 @@
|
|||||||
"source_url": null
|
"source_url": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bb811506-a40f-45e0-a517-9d12afa33759",
|
"id": "e2dfbb04-b538-4635-92c3-1a967f7eef34",
|
||||||
"serie_id": "series-normal",
|
"serie_id": "beheneko-the-elf-girls-cat-is-secretly-an-s-ranked-monster",
|
||||||
"serie_name": "Series Normal",
|
"serie_folder": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
|
"serie_name": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
"episode": {
|
"episode": {
|
||||||
"season": 1,
|
"season": 1,
|
||||||
"episode": 1,
|
"episode": 3,
|
||||||
"title": null
|
"title": null
|
||||||
},
|
},
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"priority": "normal",
|
"priority": "NORMAL",
|
||||||
"added_at": "2025-10-27T19:15:23.496680Z",
|
"added_at": "2025-11-02T14:41:55.086820Z",
|
||||||
"started_at": null,
|
"started_at": null,
|
||||||
"completed_at": null,
|
"completed_at": null,
|
||||||
"progress": null,
|
"progress": null,
|
||||||
@ -172,245 +41,18 @@
|
|||||||
"source_url": null
|
"source_url": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "2f8e6e85-7a1c-4d9b-aeaf-f4c9da6de8da",
|
"id": "8740a24e-7d49-4512-9e5f-328f5f4f61b1",
|
||||||
"serie_id": "series-low",
|
"serie_id": "beheneko-the-elf-girls-cat-is-secretly-an-s-ranked-monster",
|
||||||
"serie_name": "Series Low",
|
"serie_folder": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
|
"serie_name": "beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)",
|
||||||
"episode": {
|
"episode": {
|
||||||
"season": 1,
|
"season": 1,
|
||||||
"episode": 1,
|
"episode": 4,
|
||||||
"title": null
|
"title": null
|
||||||
},
|
},
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"priority": "low",
|
"priority": "NORMAL",
|
||||||
"added_at": "2025-10-27T19:15:23.498731Z",
|
"added_at": "2025-11-02T14:41:55.086860Z",
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "885b8873-8a97-439d-b2f3-93d50828baad",
|
|
||||||
"serie_id": "test-series",
|
|
||||||
"serie_name": "Test Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.746489Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "15711557-66d2-4b7c-90f5-17600dfb0e40",
|
|
||||||
"serie_id": "test-series",
|
|
||||||
"serie_name": "Test Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.860548Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "e3b0ade0-b4bb-414e-a65d-9593dd3b27b9",
|
|
||||||
"serie_id": "invalid-series",
|
|
||||||
"serie_name": "Invalid Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 99,
|
|
||||||
"episode": 99,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.938644Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "41f5ce9e-f20c-4ad6-b074-ff06787463d5",
|
|
||||||
"serie_id": "test-series",
|
|
||||||
"serie_name": "Test Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:23.973361Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "3c84fcc6-3aa4-4531-bcc8-296c7eb36430",
|
|
||||||
"serie_id": "series-4",
|
|
||||||
"serie_name": "Series 4",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.075622Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "650324c2-7028-46fb-bceb-9ed756f514c8",
|
|
||||||
"serie_id": "series-3",
|
|
||||||
"serie_name": "Series 3",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.076679Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "8782d952-25c3-4907-85eb-205c216f0b35",
|
|
||||||
"serie_id": "series-2",
|
|
||||||
"serie_name": "Series 2",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.077499Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ba2e0be5-3d11-47df-892b-7df465824419",
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Series 1",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.078333Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "7a64b375-aaad-494d-bcd1-1f2ae5c421f4",
|
|
||||||
"serie_id": "series-0",
|
|
||||||
"serie_name": "Series 0",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.079175Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "c532886f-6dc2-45fa-92dd-3d46ef62a692",
|
|
||||||
"serie_id": "persistent-series",
|
|
||||||
"serie_name": "Persistent Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.173243Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0e6d4e1e-7714-4fb1-9ad1-3458c9c6d4e6",
|
|
||||||
"serie_id": "ws-series",
|
|
||||||
"serie_name": "WebSocket Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.241585Z",
|
|
||||||
"started_at": null,
|
|
||||||
"completed_at": null,
|
|
||||||
"progress": null,
|
|
||||||
"error": null,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "f10196c8-f093-4a15-a498-72c3bfe6f735",
|
|
||||||
"serie_id": "pause-test",
|
|
||||||
"serie_name": "Pause Test Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 1,
|
|
||||||
"title": null
|
|
||||||
},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": "2025-10-27T19:15:24.426637Z",
|
|
||||||
"started_at": null,
|
"started_at": null,
|
||||||
"completed_at": null,
|
"completed_at": null,
|
||||||
"progress": null,
|
"progress": null,
|
||||||
@ -421,5 +63,5 @@
|
|||||||
],
|
],
|
||||||
"active": [],
|
"active": [],
|
||||||
"failed": [],
|
"failed": [],
|
||||||
"timestamp": "2025-10-27T19:15:24.426898+00:00"
|
"timestamp": "2025-11-02T14:42:15.345939+00:00"
|
||||||
}
|
}
|
||||||
@ -35,22 +35,15 @@ Added the following endpoints to `/src/server/api/anime.py`:
|
|||||||
- Calls `SeriesApp.Download()` with folder list
|
- Calls `SeriesApp.Download()` with folder list
|
||||||
- Used when user selects multiple series and clicks download
|
- Used when user selects multiple series and clicks download
|
||||||
|
|
||||||
#### `/api/v1/anime/process/locks` (GET)
|
|
||||||
|
|
||||||
- Returns current lock status for rescan and download processes
|
|
||||||
- Response: `{success: boolean, locks: {rescan: {is_locked: boolean}, download: {is_locked: boolean}}}`
|
|
||||||
- Used to update UI status indicators and disable buttons during operations
|
|
||||||
|
|
||||||
### 2. Updated Frontend API Calls
|
### 2. Updated Frontend API Calls
|
||||||
|
|
||||||
Modified `/src/server/web/static/js/app.js` to use correct endpoint paths:
|
Modified `/src/server/web/static/js/app.js` to use correct endpoint paths:
|
||||||
|
|
||||||
| Old Path | New Path | Purpose |
|
| Old Path | New Path | Purpose |
|
||||||
| --------------------------- | ----------------------------- | ------------------------- |
|
| ----------------- | ------------------------ | ------------------------- |
|
||||||
| `/api/add_series` | `/api/v1/anime/add` | Add new series |
|
| `/api/add_series` | `/api/v1/anime/add` | Add new series |
|
||||||
| `/api/download` | `/api/v1/anime/download` | Download selected folders |
|
| `/api/download` | `/api/v1/anime/download` | Download selected folders |
|
||||||
| `/api/status` | `/api/v1/anime/status` | Get library status |
|
| `/api/status` | `/api/v1/anime/status` | Get library status |
|
||||||
| `/api/process/locks/status` | `/api/v1/anime/process/locks` | Check process locks |
|
|
||||||
|
|
||||||
### 3. Verified Existing Endpoints
|
### 3. Verified Existing Endpoints
|
||||||
|
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
# Logging Implementation Summary
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
### 1. Core Logging Infrastructure (`src/infrastructure/logging/`)
|
|
||||||
|
|
||||||
- **`logger.py`**: Main logging configuration module
|
|
||||||
|
|
||||||
- `setup_logging()`: Configures both console and file handlers
|
|
||||||
- `get_logger()`: Retrieves logger instances for specific modules
|
|
||||||
- Follows Python logging best practices with proper formatters
|
|
||||||
|
|
||||||
- **`uvicorn_config.py`**: Uvicorn-specific logging configuration
|
|
||||||
|
|
||||||
- Custom logging configuration dictionary for uvicorn
|
|
||||||
- Ensures uvicorn logs are captured in both console and file
|
|
||||||
- Configures multiple loggers (uvicorn, uvicorn.error, uvicorn.access, aniworld)
|
|
||||||
|
|
||||||
- **`__init__.py`**: Package initialization
|
|
||||||
- Exports public API: `setup_logging`, `get_logger`, `get_uvicorn_log_config`
|
|
||||||
|
|
||||||
### 2. FastAPI Integration
|
|
||||||
|
|
||||||
Updated `src/server/fastapi_app.py` to:
|
|
||||||
|
|
||||||
- Import and use the logging infrastructure
|
|
||||||
- Call `setup_logging()` during application startup (in `lifespan()`)
|
|
||||||
- Replace all `print()` statements with proper logger calls
|
|
||||||
- Use lazy formatting (`logger.info("Message: %s", value)`)
|
|
||||||
|
|
||||||
### 3. Startup Scripts
|
|
||||||
|
|
||||||
- **`run_server.py`**: Python startup script
|
|
||||||
|
|
||||||
- Uses the custom uvicorn logging configuration
|
|
||||||
- Recommended way to start the server
|
|
||||||
|
|
||||||
- **`start_server.sh`**: Bash startup script
|
|
||||||
- Wrapper around `run_server.py`
|
|
||||||
- Made executable with proper shebang
|
|
||||||
|
|
||||||
### 4. Documentation
|
|
||||||
|
|
||||||
- **`docs/logging.md`**: Comprehensive logging guide
|
|
||||||
- How to run the server
|
|
||||||
- Log file locations
|
|
||||||
- Log format examples
|
|
||||||
- Troubleshooting guide
|
|
||||||
- Programmatic usage examples
|
|
||||||
|
|
||||||
## Log Outputs
|
|
||||||
|
|
||||||
### Console Output
|
|
||||||
|
|
||||||
```
|
|
||||||
INFO: Starting FastAPI application...
|
|
||||||
INFO: Loaded anime_directory from config: /home/lukas/Volume/serien/
|
|
||||||
INFO: Server running on http://127.0.0.1:8000
|
|
||||||
INFO: API documentation available at http://127.0.0.1:8000/api/docs
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Output (`logs/fastapi_app.log`)
|
|
||||||
|
|
||||||
```
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - ============================================================
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Logging configured successfully
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Log level: INFO
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Log file: /home/lukas/Volume/repo/Aniworld/logs/fastapi_app.log
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - ============================================================
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Starting FastAPI application...
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Loaded anime_directory from config: /home/lukas/Volume/serien/
|
|
||||||
2025-10-25 17:31:19 - src.core.SeriesApp - INFO - Initializing SeriesApp...
|
|
||||||
2025-10-25 17:31:19 - src.core.SerieScanner - INFO - Initialized SerieScanner...
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - SeriesApp initialized with directory: /home/lukas/Volume/serien/
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - FastAPI application started successfully
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - Server running on http://127.0.0.1:8000
|
|
||||||
2025-10-25 17:31:19 - aniworld - INFO - API documentation available at http://127.0.0.1:8000/api/docs
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
### Starting the Server
|
|
||||||
|
|
||||||
**Recommended:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
conda run -n AniWorld python run_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Alternative:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./start_server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**View logs in real-time:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -f logs/fastapi_app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Code
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.infrastructure.logging import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
logger.info("Message: %s", value)
|
|
||||||
logger.warning("Warning: %s", warning_msg)
|
|
||||||
logger.error("Error occurred", exc_info=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Set log level via environment variable or `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export LOG_LEVEL=INFO # or DEBUG, WARNING, ERROR
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ **Console logging**: Colored, easy-to-read format
|
|
||||||
✅ **File logging**: Detailed with timestamps and logger names
|
|
||||||
✅ **Automatic log directory creation**: `logs/` created if missing
|
|
||||||
✅ **Uvicorn integration**: All uvicorn logs captured
|
|
||||||
✅ **Multiple loggers**: Different loggers for different modules
|
|
||||||
✅ **Configurable log level**: Via environment variable
|
|
||||||
✅ **Proper formatting**: Uses lazy formatting for performance
|
|
||||||
✅ **Startup/shutdown logging**: Clear application lifecycle logs
|
|
||||||
✅ **Error tracebacks**: Full exception context with `exc_info=True`
|
|
||||||
|
|
||||||
## Files Created/Modified
|
|
||||||
|
|
||||||
### Created:
|
|
||||||
|
|
||||||
- `src/infrastructure/logging/logger.py`
|
|
||||||
- `src/infrastructure/logging/uvicorn_config.py`
|
|
||||||
- `src/infrastructure/logging/__init__.py`
|
|
||||||
- `run_server.py`
|
|
||||||
- `start_server.sh`
|
|
||||||
- `docs/logging.md`
|
|
||||||
- `docs/logging_implementation_summary.md` (this file)
|
|
||||||
|
|
||||||
### Modified:
|
|
||||||
|
|
||||||
- `src/server/fastapi_app.py`: Integrated logging throughout
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The implementation has been tested and verified:
|
|
||||||
|
|
||||||
- ✅ Log file created at `logs/fastapi_app.log`
|
|
||||||
- ✅ Startup messages logged correctly
|
|
||||||
- ✅ Application configuration loaded and logged
|
|
||||||
- ✅ Uvicorn logs captured
|
|
||||||
- ✅ File watching events logged
|
|
||||||
- ✅ Shutdown messages logged
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Consider adding:
|
|
||||||
|
|
||||||
1. **Log rotation**: Use `RotatingFileHandler` to prevent log files from growing too large
|
|
||||||
2. **Structured logging**: Use `structlog` for JSON-formatted logs
|
|
||||||
3. **Log aggregation**: Send logs to a centralized logging service
|
|
||||||
4. **Performance monitoring**: Add timing logs for slow operations
|
|
||||||
5. **Request logging middleware**: Log all HTTP requests/responses
|
|
||||||
450
docs/progress_service_architecture.md
Normal file
450
docs/progress_service_architecture.md
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
# Progress Service Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The ProgressService serves as the **single source of truth** for all real-time progress tracking in the Aniworld application. This architecture follows a clean, decoupled design where progress updates flow through a well-defined pipeline.
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ SeriesApp │ ← Core download/scan logic
|
||||||
|
└──────┬──────┘
|
||||||
|
│ Events (download_status, scan_status)
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ AnimeService │ ← Subscribes to SeriesApp events
|
||||||
|
└────────┬────────┘
|
||||||
|
│ Forwards events
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ ProgressService │ ← Single source of truth for progress
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ Emits events to subscribers
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ WebSocketService │ ← Subscribes to progress events
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Connected clients receive real-time updates
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. SeriesApp (Core Layer)
|
||||||
|
|
||||||
|
**Location**: `src/core/SeriesApp.py`
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
|
||||||
|
- Execute actual downloads and scans
|
||||||
|
- Fire events with detailed progress information
|
||||||
|
- Manage download state and error handling
|
||||||
|
|
||||||
|
**Events**:
|
||||||
|
|
||||||
|
- `download_status`: Fired during downloads
|
||||||
|
|
||||||
|
- `started`: Download begins
|
||||||
|
- `progress`: Progress updates (percent, speed, ETA)
|
||||||
|
- `completed`: Download finished successfully
|
||||||
|
- `failed`: Download encountered an error
|
||||||
|
|
||||||
|
- `scan_status`: Fired during library scans
|
||||||
|
- `started`: Scan begins
|
||||||
|
- `progress`: Scan progress updates
|
||||||
|
- `completed`: Scan finished
|
||||||
|
- `failed`: Scan encountered an error
|
||||||
|
- `cancelled`: Scan was cancelled
|
||||||
|
|
||||||
|
### 2. AnimeService (Service Layer)
|
||||||
|
|
||||||
|
**Location**: `src/server/services/anime_service.py`
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
|
||||||
|
- Subscribe to SeriesApp events
|
||||||
|
- Translate SeriesApp events into ProgressService updates
|
||||||
|
- Provide async interface for web layer
|
||||||
|
|
||||||
|
**Event Handlers**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _on_download_status(self, args):
|
||||||
|
"""Translates download events to progress service."""
|
||||||
|
if args.status == "started":
|
||||||
|
await progress_service.start_progress(...)
|
||||||
|
elif args.status == "progress":
|
||||||
|
await progress_service.update_progress(...)
|
||||||
|
elif args.status == "completed":
|
||||||
|
await progress_service.complete_progress(...)
|
||||||
|
elif args.status == "failed":
|
||||||
|
await progress_service.fail_progress(...)
|
||||||
|
|
||||||
|
def _on_scan_status(self, args):
|
||||||
|
"""Translates scan events to progress service."""
|
||||||
|
# Similar pattern as download_status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ProgressService (Service Layer)
|
||||||
|
|
||||||
|
**Location**: `src/server/services/progress_service.py`
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
|
||||||
|
- Central progress tracking for all operations
|
||||||
|
- Maintain active and historical progress records
|
||||||
|
- Calculate percentages and rates
|
||||||
|
- Emit events to subscribers (event-based architecture)
|
||||||
|
|
||||||
|
**Progress Types**:
|
||||||
|
|
||||||
|
- `DOWNLOAD`: Individual episode downloads
|
||||||
|
- `SCAN`: Library scans for missing episodes
|
||||||
|
- `QUEUE`: Download queue operations
|
||||||
|
- `SYSTEM`: System-level operations
|
||||||
|
- `ERROR`: Error notifications
|
||||||
|
|
||||||
|
**Event System**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Subscribe to progress events
|
||||||
|
def subscribe(event_name: str, handler: Callable[[ProgressEvent], None])
|
||||||
|
def unsubscribe(event_name: str, handler: Callable[[ProgressEvent], None])
|
||||||
|
|
||||||
|
# Internal event emission
|
||||||
|
async def _emit_event(event: ProgressEvent)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start_progress(progress_id, progress_type, title, ...):
|
||||||
|
"""Start tracking a new operation."""
|
||||||
|
|
||||||
|
async def update_progress(progress_id, current, total, message, ...):
|
||||||
|
"""Update progress for an ongoing operation."""
|
||||||
|
|
||||||
|
async def complete_progress(progress_id, message, ...):
|
||||||
|
"""Mark operation as completed."""
|
||||||
|
|
||||||
|
async def fail_progress(progress_id, error_message, ...):
|
||||||
|
"""Mark operation as failed."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DownloadService (Service Layer)
|
||||||
|
|
||||||
|
**Location**: `src/server/services/download_service.py`
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
|
||||||
|
- Manage download queue (FIFO processing)
|
||||||
|
- Track queue state (pending, active, completed, failed)
|
||||||
|
- Persist queue to disk
|
||||||
|
- Use ProgressService for queue-related updates
|
||||||
|
|
||||||
|
**Progress Integration**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Queue operations notify via ProgressService
|
||||||
|
await progress_service.update_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message="Added 3 items to queue",
|
||||||
|
metadata={
|
||||||
|
"action": "items_added",
|
||||||
|
"queue_status": {...}
|
||||||
|
},
|
||||||
|
force_broadcast=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: DownloadService does NOT directly broadcast. Individual download progress flows through:
|
||||||
|
`SeriesApp → AnimeService → ProgressService → WebSocket`
|
||||||
|
|
||||||
|
### 5. WebSocketService (Service Layer)
|
||||||
|
|
||||||
|
**Location**: `src/server/services/websocket_service.py`
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
|
||||||
|
- Manage WebSocket connections
|
||||||
|
- Support room-based messaging
|
||||||
|
- Broadcast progress updates to clients
|
||||||
|
- Handle connection lifecycle
|
||||||
|
|
||||||
|
**Integration**:
|
||||||
|
WebSocketService subscribes to ProgressService events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Get services
|
||||||
|
progress_service = get_progress_service()
|
||||||
|
ws_service = get_websocket_service()
|
||||||
|
|
||||||
|
# Define event handler
|
||||||
|
async def progress_event_handler(event) -> None:
|
||||||
|
"""Handle progress events and broadcast via WebSocket."""
|
||||||
|
message = {
|
||||||
|
"type": event.event_type,
|
||||||
|
"data": event.progress.to_dict(),
|
||||||
|
}
|
||||||
|
await ws_service.manager.broadcast_to_room(message, event.room)
|
||||||
|
|
||||||
|
# Subscribe to progress events
|
||||||
|
progress_service.subscribe("progress_updated", progress_event_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow Examples
|
||||||
|
|
||||||
|
### Example 1: Episode Download
|
||||||
|
|
||||||
|
1. **User triggers download** via API endpoint
|
||||||
|
2. **DownloadService** queues the download
|
||||||
|
3. **DownloadService** starts processing → calls `anime_service.download()`
|
||||||
|
4. **AnimeService** calls `series_app.download()`
|
||||||
|
5. **SeriesApp** fires `download_status` events:
|
||||||
|
- `started` → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
- `progress` (multiple) → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
- `completed` → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
|
||||||
|
### Example 2: Library Scan
|
||||||
|
|
||||||
|
1. **User triggers scan** via API endpoint
|
||||||
|
2. **AnimeService** calls `series_app.rescan()`
|
||||||
|
3. **SeriesApp** fires `scan_status` events:
|
||||||
|
- `started` → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
- `progress` (multiple) → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
- `completed` → AnimeService → ProgressService → WebSocket → Client
|
||||||
|
|
||||||
|
### Example 3: Queue Management
|
||||||
|
|
||||||
|
1. **User adds items to queue** via API endpoint
|
||||||
|
2. **DownloadService** adds items to internal queue
|
||||||
|
3. **DownloadService** notifies via ProgressService:
|
||||||
|
```python
|
||||||
|
await progress_service.update_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message="Added 5 items to queue",
|
||||||
|
metadata={"queue_status": {...}},
|
||||||
|
force_broadcast=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
4. **ProgressService** → WebSocket → Client receives queue update
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
### 1. **Single Source of Truth**
|
||||||
|
|
||||||
|
- All progress tracking goes through ProgressService
|
||||||
|
- Consistent progress reporting across the application
|
||||||
|
- Easy to monitor and debug
|
||||||
|
|
||||||
|
### 2. **Decoupling**
|
||||||
|
|
||||||
|
- Core logic (SeriesApp) doesn't know about web layer
|
||||||
|
- Services can be tested independently
|
||||||
|
- Easy to add new progress consumers (e.g., CLI, GUI)
|
||||||
|
|
||||||
|
### 3. **Type Safety**
|
||||||
|
|
||||||
|
- Strongly typed progress updates
|
||||||
|
- Enum-based progress types and statuses
|
||||||
|
- Clear data contracts
|
||||||
|
|
||||||
|
### 4. **Flexibility**
|
||||||
|
|
||||||
|
- Multiple subscribers can listen to progress events
|
||||||
|
- Room-based WebSocket messaging
|
||||||
|
- Metadata support for custom data
|
||||||
|
- Multiple concurrent progress operations
|
||||||
|
|
||||||
|
### 5. **Maintainability**
|
||||||
|
|
||||||
|
- Clear separation of concerns
|
||||||
|
- Single place to modify progress logic
|
||||||
|
- Easy to extend with new progress types or subscribers
|
||||||
|
|
||||||
|
### 6. **Scalability**
|
||||||
|
|
||||||
|
- Event-based architecture supports multiple consumers
|
||||||
|
- Isolated error handling per subscriber
|
||||||
|
- No single point of failure
|
||||||
|
|
||||||
|
## Progress IDs
|
||||||
|
|
||||||
|
Progress operations are identified by unique IDs:
|
||||||
|
|
||||||
|
- **Downloads**: `download_{serie_folder}_{season}_{episode}`
|
||||||
|
- **Scans**: `library_scan`
|
||||||
|
- **Queue**: `download_queue`
|
||||||
|
|
||||||
|
## WebSocket Messages
|
||||||
|
|
||||||
|
Clients receive progress updates in this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "download_progress",
|
||||||
|
"data": {
|
||||||
|
"id": "download_naruto_1_1",
|
||||||
|
"type": "download",
|
||||||
|
"status": "in_progress",
|
||||||
|
"title": "Downloading Naruto",
|
||||||
|
"message": "S01E01",
|
||||||
|
"percent": 45.5,
|
||||||
|
"current": 45,
|
||||||
|
"total": 100,
|
||||||
|
"metadata": {},
|
||||||
|
"started_at": "2025-11-07T10:00:00Z",
|
||||||
|
"updated_at": "2025-11-07T10:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Startup (fastapi_app.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Initialize services
|
||||||
|
progress_service = get_progress_service()
|
||||||
|
ws_service = get_websocket_service()
|
||||||
|
|
||||||
|
# Define event handler
|
||||||
|
async def progress_event_handler(event) -> None:
|
||||||
|
"""Handle progress events and broadcast via WebSocket."""
|
||||||
|
message = {
|
||||||
|
"type": event.event_type,
|
||||||
|
"data": event.progress.to_dict(),
|
||||||
|
}
|
||||||
|
await ws_service.manager.broadcast_to_room(message, event.room)
|
||||||
|
|
||||||
|
# Subscribe to progress events
|
||||||
|
progress_service.subscribe("progress_updated", progress_event_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Initialization
|
||||||
|
|
||||||
|
```python
|
||||||
|
# AnimeService automatically subscribes to SeriesApp events
|
||||||
|
anime_service = AnimeService(series_app)
|
||||||
|
|
||||||
|
# DownloadService uses ProgressService for queue updates
|
||||||
|
download_service = DownloadService(anime_service)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
**Before (Callback-based)**:
|
||||||
|
|
||||||
|
- ProgressService had a single `set_broadcast_callback()` method
|
||||||
|
- Only one consumer could receive updates
|
||||||
|
- Direct coupling between ProgressService and WebSocketService
|
||||||
|
|
||||||
|
**After (Event-based)**:
|
||||||
|
|
||||||
|
- ProgressService uses `subscribe()` and `unsubscribe()` methods
|
||||||
|
- Multiple consumers can subscribe to progress events
|
||||||
|
- Loose coupling - ProgressService doesn't know about subscribers
|
||||||
|
- Clean event flow: SeriesApp → AnimeService → ProgressService → Subscribers
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
1. **ProgressService**:
|
||||||
|
|
||||||
|
- `set_broadcast_callback()` method
|
||||||
|
- `_broadcast_callback` attribute
|
||||||
|
- `_broadcast()` method
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **ProgressService**:
|
||||||
|
|
||||||
|
- `ProgressEvent` dataclass to encapsulate event data
|
||||||
|
- `subscribe()` method for event subscription
|
||||||
|
- `unsubscribe()` method to remove handlers
|
||||||
|
- `_emit_event()` method for broadcasting to all subscribers
|
||||||
|
- `_event_handlers` dictionary to track subscribers
|
||||||
|
|
||||||
|
2. **fastapi_app.py**:
|
||||||
|
- Event handler function `progress_event_handler`
|
||||||
|
- Uses `subscribe()` instead of `set_broadcast_callback()`
|
||||||
|
|
||||||
|
### Benefits of Event-Based Design
|
||||||
|
|
||||||
|
1. **Multiple Subscribers**: Can now have multiple services listening to progress
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WebSocket for real-time updates
|
||||||
|
progress_service.subscribe("progress_updated", websocket_handler)
|
||||||
|
# Metrics for analytics
|
||||||
|
progress_service.subscribe("progress_updated", metrics_handler)
|
||||||
|
# Logging for debugging
|
||||||
|
progress_service.subscribe("progress_updated", logging_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Isolated Error Handling**: If one subscriber fails, others continue working
|
||||||
|
|
||||||
|
3. **Dynamic Subscription**: Handlers can subscribe/unsubscribe at runtime
|
||||||
|
|
||||||
|
4. **Extensibility**: Easy to add new features without modifying ProgressService
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- Test each service independently
|
||||||
|
- Mock ProgressService for services that use it
|
||||||
|
- Verify event handler logic
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- Test full flow: SeriesApp → AnimeService → ProgressService → WebSocket
|
||||||
|
- Verify progress updates reach clients
|
||||||
|
- Test error handling
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def test_download_progress_flow():
|
||||||
|
# Setup
|
||||||
|
progress_service = ProgressService()
|
||||||
|
events_received = []
|
||||||
|
|
||||||
|
async def mock_event_handler(event):
|
||||||
|
events_received.append(event)
|
||||||
|
|
||||||
|
progress_service.subscribe("progress_updated", mock_event_handler)
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
await progress_service.start_progress(
|
||||||
|
progress_id="test_download",
|
||||||
|
progress_type=ProgressType.DOWNLOAD,
|
||||||
|
title="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert len(events_received) == 1
|
||||||
|
assert events_received[0].event_type == "download_progress"
|
||||||
|
assert events_received[0].progress.id == "test_download"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Progress Persistence**: Save progress to database for recovery
|
||||||
|
2. **Progress History**: Keep detailed history for analytics
|
||||||
|
3. **Rate Limiting**: Throttle progress updates to prevent spam
|
||||||
|
4. **Progress Aggregation**: Combine multiple progress operations
|
||||||
|
5. **Custom Rooms**: Allow clients to subscribe to specific progress types
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [WebSocket API](./websocket_api.md)
|
||||||
|
- [Download Service](./download_service.md)
|
||||||
|
- [Error Handling](./error_handling_validation.md)
|
||||||
|
- [API Implementation](./api_implementation_summary.md)
|
||||||
51
features.md
51
features.md
@ -1,24 +1,53 @@
|
|||||||
# Aniworld Web Application Features
|
# Aniworld Web Application Features
|
||||||
|
|
||||||
## Authentication & Security
|
## Authentication & Security
|
||||||
- **Master Password Login**: Secure access to the application with a master password system
|
|
||||||
|
- **Master Password Login**: Secure access to the application with a master password system
|
||||||
|
- **JWT Token Sessions**: Stateless authentication with JSON Web Tokens
|
||||||
|
- **Rate Limiting**: Built-in protection against brute force attacks
|
||||||
|
|
||||||
## Configuration Management
|
## Configuration Management
|
||||||
- **Setup Page**: Initial configuration interface for server setup and basic settings
|
|
||||||
- **Config Page**: View and modify application configuration settings
|
- **Setup Page**: Initial configuration interface for server setup and basic settings
|
||||||
|
- **Config Page**: View and modify application configuration settings
|
||||||
|
- **Scheduler Configuration**: Configure automated rescan schedules
|
||||||
|
- **Backup Management**: Create, restore, and manage configuration backups
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
- **Dark Mode**: Toggle between light and dark themes for better user experience
|
|
||||||
|
- **Dark Mode**: Toggle between light and dark themes for better user experience
|
||||||
|
- **Responsive Design**: Mobile-friendly interface with touch support
|
||||||
|
- **Real-time Updates**: WebSocket-based live notifications and progress tracking
|
||||||
|
|
||||||
## Anime Management
|
## Anime Management
|
||||||
- **Anime Library Page**: Display list of anime series with missing episodes
|
|
||||||
- **Series Selection**: Select individual anime series and add episodes to download queue
|
- **Anime Library Page**: Display list of anime series with missing episodes
|
||||||
- **Anime Search Page**: Search functionality to find and add new anime series to the library
|
- **Series Selection**: Select individual anime series and add episodes to download queue
|
||||||
|
- **Anime Search**: Search for anime series using integrated providers
|
||||||
|
- **Library Scanning**: Automated scanning for missing episodes
|
||||||
|
|
||||||
## Download Management
|
## Download Management
|
||||||
- **Download Queue Page**: View and manage the current download queue
|
|
||||||
- **Download Status Display**: Real-time status updates and progress of current downloads
|
- **Download Queue Page**: View and manage the current download queue with organized sections
|
||||||
- **Queue Operations**: Add, remove, and prioritize items in the download queue
|
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
|
||||||
|
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
|
||||||
|
- **FIFO Queue Processing**: First-in, first-out queue order (no priority or reordering)
|
||||||
|
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
|
||||||
|
- **Download Status Display**: Real-time status updates and progress of current download
|
||||||
|
- **Queue Operations**: Add and remove items from the pending queue
|
||||||
|
- **Completed Downloads List**: Separate section for completed downloads with clear button
|
||||||
|
- **Failed Downloads List**: Separate section for failed downloads with retry and clear options
|
||||||
|
- **Retry Failed Downloads**: Automatically retry failed downloads with configurable limits
|
||||||
|
- **Clear Completed**: Remove completed downloads from the queue
|
||||||
|
- **Clear Failed**: Remove failed downloads from the queue
|
||||||
|
- **Queue Statistics**: Real-time counters for pending, active, completed, and failed items
|
||||||
|
|
||||||
|
## Real-time Communication
|
||||||
|
|
||||||
|
- **WebSocket Support**: Real-time notifications for download progress and queue updates
|
||||||
|
- **Progress Tracking**: Live progress updates for downloads and scans
|
||||||
|
- **System Notifications**: Real-time system messages and alerts
|
||||||
|
|
||||||
## Core Functionality Overview
|
## Core Functionality Overview
|
||||||
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring.
|
|
||||||
|
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.
|
||||||
|
|||||||
@ -41,9 +41,8 @@ conda activate AniWorld
|
|||||||
│ │ │ ├── config.py # Configuration endpoints
|
│ │ │ ├── config.py # Configuration endpoints
|
||||||
│ │ │ ├── anime.py # Anime management endpoints
|
│ │ │ ├── anime.py # Anime management endpoints
|
||||||
│ │ │ ├── download.py # Download queue endpoints
|
│ │ │ ├── download.py # Download queue endpoints
|
||||||
│ │ │ ├── providers.py # Provider health & config endpoints
|
│ │ │ ├── scheduler.py # Scheduler configuration endpoints
|
||||||
│ │ │ ├── websocket.py # WebSocket real-time endpoints
|
│ │ │ └── websocket.py # WebSocket real-time endpoints
|
||||||
│ │ │ └── search.py # Search endpoints
|
|
||||||
│ │ ├── models/ # Pydantic models
|
│ │ ├── models/ # Pydantic models
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
│ │ │ ├── auth.py
|
│ │ │ ├── auth.py
|
||||||
@ -57,7 +56,10 @@ conda activate AniWorld
|
|||||||
│ │ │ ├── config_service.py
|
│ │ │ ├── config_service.py
|
||||||
│ │ │ ├── anime_service.py
|
│ │ │ ├── anime_service.py
|
||||||
│ │ │ ├── download_service.py
|
│ │ │ ├── download_service.py
|
||||||
│ │ │ └── websocket_service.py # WebSocket connection management
|
│ │ │ ├── websocket_service.py # WebSocket connection management
|
||||||
|
│ │ │ ├── progress_service.py # Progress tracking
|
||||||
|
│ │ │ ├── notification_service.py # Notification system
|
||||||
|
│ │ │ └── cache_service.py # Caching layer
|
||||||
│ │ ├── database/ # Database layer
|
│ │ ├── database/ # Database layer
|
||||||
│ │ │ ├── __init__.py # Database package
|
│ │ │ ├── __init__.py # Database package
|
||||||
│ │ │ ├── base.py # Base models and mixins
|
│ │ │ ├── base.py # Base models and mixins
|
||||||
@ -214,21 +216,6 @@ conda activate AniWorld
|
|||||||
- `POST /api/scheduler/config` - Update scheduler configuration
|
- `POST /api/scheduler/config` - Update scheduler configuration
|
||||||
- `POST /api/scheduler/trigger-rescan` - Manually trigger rescan
|
- `POST /api/scheduler/trigger-rescan` - Manually trigger rescan
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
- `GET /api/logging/config` - Get logging configuration
|
|
||||||
- `POST /api/logging/config` - Update logging configuration
|
|
||||||
- `GET /api/logging/files` - List all log files
|
|
||||||
- `GET /api/logging/files/{filename}/download` - Download log file
|
|
||||||
- `GET /api/logging/files/{filename}/tail` - Get last N lines of log file
|
|
||||||
- `POST /api/logging/test` - Test logging by writing messages at all levels
|
|
||||||
- `POST /api/logging/cleanup` - Clean up old log files
|
|
||||||
|
|
||||||
### Diagnostics
|
|
||||||
|
|
||||||
- `GET /api/diagnostics/network` - Run network connectivity diagnostics
|
|
||||||
- `GET /api/diagnostics/system` - Get basic system information
|
|
||||||
|
|
||||||
### Anime Management
|
### Anime Management
|
||||||
|
|
||||||
- `GET /api/anime` - List anime with missing episodes
|
- `GET /api/anime` - List anime with missing episodes
|
||||||
@ -245,85 +232,42 @@ initialization.
|
|||||||
|
|
||||||
- `GET /api/queue/status` - Get download queue status and statistics
|
- `GET /api/queue/status` - Get download queue status and statistics
|
||||||
- `POST /api/queue/add` - Add episodes to download queue
|
- `POST /api/queue/add` - Add episodes to download queue
|
||||||
- `DELETE /api/queue/{id}` - Remove item from queue
|
- `DELETE /api/queue/{id}` - Remove single item from pending queue
|
||||||
- `DELETE /api/queue/` - Remove multiple items from queue
|
- `POST /api/queue/start` - Manually start next download from queue (one at a time)
|
||||||
- `POST /api/queue/start` - Start download queue processing
|
- `POST /api/queue/stop` - Stop processing new downloads
|
||||||
- `POST /api/queue/stop` - Stop download queue processing
|
|
||||||
- `POST /api/queue/pause` - Pause queue processing
|
|
||||||
- `POST /api/queue/resume` - Resume queue processing
|
|
||||||
- `POST /api/queue/reorder` - Reorder pending queue items
|
|
||||||
- `DELETE /api/queue/completed` - Clear completed downloads
|
- `DELETE /api/queue/completed` - Clear completed downloads
|
||||||
- `POST /api/queue/retry` - Retry failed downloads
|
- `DELETE /api/queue/failed` - Clear failed downloads
|
||||||
|
- `POST /api/queue/retry/{id}` - Retry a specific failed download
|
||||||
|
- `POST /api/queue/retry` - Retry all failed downloads
|
||||||
|
|
||||||
### Provider Management (October 2025)
|
**Manual Download Control:**
|
||||||
|
|
||||||
The provider system has been enhanced with comprehensive health monitoring,
|
- Queue processing is fully manual - no auto-start
|
||||||
automatic failover, performance tracking, and dynamic configuration.
|
- User must click "Start" to begin downloading next item from queue
|
||||||
|
- Only one download active at a time
|
||||||
|
- "Stop" prevents new downloads but allows current to complete
|
||||||
|
- FIFO queue order (first-in, first-out)
|
||||||
|
|
||||||
**Provider Health Monitoring:**
|
**Queue Organization:**
|
||||||
|
|
||||||
- `GET /api/providers/health` - Get overall provider health summary
|
- **Pending Queue**: Items waiting to be downloaded, displayed in FIFO order
|
||||||
- `GET /api/providers/health/{provider_name}` - Get specific provider health
|
- **Active Download**: Currently downloading item with progress bar (max 1)
|
||||||
- `GET /api/providers/available` - List currently available providers
|
- **Completed Downloads**: Successfully downloaded items with completion timestamps
|
||||||
- `GET /api/providers/best` - Get best performing provider
|
- **Failed Downloads**: Failed items with error messages and retry options
|
||||||
- `POST /api/providers/health/{provider_name}/reset` - Reset provider metrics
|
|
||||||
|
|
||||||
**Provider Configuration:**
|
**Queue Display Features:**
|
||||||
|
|
||||||
- `GET /api/providers/config` - Get all provider configurations
|
- Real-time statistics counters (pending, active, completed, failed)
|
||||||
- `GET /api/providers/config/{provider_name}` - Get specific provider config
|
- Empty state messages with helpful hints
|
||||||
- `PUT /api/providers/config/{provider_name}` - Update provider settings
|
- Per-section action buttons (clear, retry all)
|
||||||
- `POST /api/providers/config/{provider_name}/enable` - Enable provider
|
- Start/Stop buttons for manual queue control
|
||||||
- `POST /api/providers/config/{provider_name}/disable` - Disable provider
|
|
||||||
|
|
||||||
**Failover Management:**
|
### WebSocket
|
||||||
|
|
||||||
- `GET /api/providers/failover` - Get failover statistics
|
- `WS /api/ws` - WebSocket connection for real-time updates
|
||||||
- `POST /api/providers/failover/{provider_name}/add` - Add to failover chain
|
- Real-time download progress notifications
|
||||||
- `DELETE /api/providers/failover/{provider_name}` - Remove from failover
|
- Queue status updates
|
||||||
|
- System notifications
|
||||||
**Provider Enhancement Features:**
|
|
||||||
|
|
||||||
- **Health Monitoring**: Real-time tracking of provider availability, response
|
|
||||||
times, success rates, and bandwidth usage. Automatic marking of providers as
|
|
||||||
unavailable after consecutive failures.
|
|
||||||
- **Automatic Failover**: Seamless switching between providers when primary
|
|
||||||
fails. Configurable retry attempts and delays.
|
|
||||||
- **Performance Tracking**: Wrapped provider interface that automatically
|
|
||||||
records metrics for all operations (search, download, metadata retrieval).
|
|
||||||
- **Dynamic Configuration**: Runtime updates to provider settings without
|
|
||||||
application restart. Configurable timeouts, retries, bandwidth limits.
|
|
||||||
- **Best Provider Selection**: Intelligent selection based on success rate,
|
|
||||||
response time, and availability.
|
|
||||||
|
|
||||||
**Provider Metrics Tracked:**
|
|
||||||
|
|
||||||
- Total requests (successful/failed)
|
|
||||||
- Average response time (milliseconds)
|
|
||||||
- Success rate (percentage)
|
|
||||||
- Consecutive failures count
|
|
||||||
- Total bytes downloaded
|
|
||||||
- Uptime percentage (last 60 minutes)
|
|
||||||
- Last error message and timestamp
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
|
|
||||||
- `src/core/providers/health_monitor.py` - ProviderHealthMonitor class
|
|
||||||
- `src/core/providers/failover.py` - ProviderFailover system
|
|
||||||
- `src/core/providers/monitored_provider.py` - Performance tracking wrapper
|
|
||||||
- `src/core/providers/config_manager.py` - Dynamic configuration manager
|
|
||||||
- `src/server/api/providers.py` - Provider management API endpoints
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
|
|
||||||
- 34 unit tests covering health monitoring, failover, and configuration
|
|
||||||
- Tests for provider availability tracking and failover scenarios
|
|
||||||
- Configuration persistence and validation tests
|
|
||||||
|
|
||||||
### Search
|
|
||||||
|
|
||||||
- `GET /api/search?q={query}` - Search for anime
|
|
||||||
- `POST /api/search/add` - Add anime to library
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@ -345,7 +289,7 @@ automatic failover, performance tracking, and dynamic configuration.
|
|||||||
- Master password protection for application access
|
- Master password protection for application access
|
||||||
- Secure session management with JWT tokens
|
- Secure session management with JWT tokens
|
||||||
- Input validation and sanitization
|
- Input validation and sanitization
|
||||||
- Rate limiting on API endpoints
|
- Built-in rate limiting in authentication middleware
|
||||||
- HTTPS enforcement in production
|
- HTTPS enforcement in production
|
||||||
- Secure file path handling to prevent directory traversal
|
- Secure file path handling to prevent directory traversal
|
||||||
|
|
||||||
@ -827,8 +771,6 @@ The `SeriesApp` class (`src/core/SeriesApp.py`) is the main application engine f
|
|||||||
- `search(words)`: Search for anime series
|
- `search(words)`: Search for anime series
|
||||||
- `download()`: Download episodes with progress tracking
|
- `download()`: Download episodes with progress tracking
|
||||||
- `ReScan()`: Scan directory for missing episodes
|
- `ReScan()`: Scan directory for missing episodes
|
||||||
- `async_download()`: Async version of download
|
|
||||||
- `async_rescan()`: Async version of rescan
|
|
||||||
- `cancel_operation()`: Cancel current operation
|
- `cancel_operation()`: Cancel current operation
|
||||||
- `get_operation_status()`: Get current status
|
- `get_operation_status()`: Get current status
|
||||||
- `get_series_list()`: Get series with missing episodes
|
- `get_series_list()`: Get series with missing episodes
|
||||||
|
|||||||
@ -105,9 +105,30 @@ For each task completed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. Server is running: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload`
|
||||||
|
2. Password: `Hallo123!`
|
||||||
|
3. Login via browser at `http://127.0.0.1:8000/login`
|
||||||
|
|
||||||
|
**Deployment Steps:**
|
||||||
|
|
||||||
|
1. Commit all changes to git repository
|
||||||
|
2. Create deployment tag (e.g., `v1.0.0-queue-simplified`)
|
||||||
|
3. Deploy to production environment
|
||||||
|
4. Monitor logs for any unexpected behavior
|
||||||
|
5. Verify production queue functionality
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This is a simplification that removes complexity while maintaining core functionality
|
||||||
|
- Improves user experience with explicit manual control
|
||||||
|
- Easier to understand, test, and maintain
|
||||||
|
- Good foundation for future enhancements if needed
|
||||||
|
- No database schema changes required
|
||||||
|
- WebSocket infrastructure remains unchanged
|
||||||
|
|
||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
## Setup
|
[] check method from SeriesApp are used in a correct way. SeriesApp method changed. make sure that classes that use SeriesApp take the latest interface.
|
||||||
|
[] SeriesApp no have events make sure services and api use them
|
||||||
- [x] Redirect to setup if no config is present.
|
|
||||||
- [x] After setup confirmed redirect to login
|
|
||||||
|
|||||||
@ -3,598 +3,440 @@ SeriesApp - Core application logic for anime series management.
|
|||||||
|
|
||||||
This module provides the main application interface for searching,
|
This module provides the main application interface for searching,
|
||||||
downloading, and managing anime series with support for async callbacks,
|
downloading, and managing anime series with support for async callbacks,
|
||||||
progress reporting, error handling, and operation cancellation.
|
progress reporting, and error handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
from typing import Any, Dict, List, Optional
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
from events import Events
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
from src.core.entities.SerieList import SerieList
|
from src.core.entities.SerieList import SerieList
|
||||||
from src.core.interfaces.callbacks import (
|
|
||||||
CallbackManager,
|
|
||||||
CompletionContext,
|
|
||||||
ErrorContext,
|
|
||||||
OperationType,
|
|
||||||
ProgressContext,
|
|
||||||
ProgressPhase,
|
|
||||||
)
|
|
||||||
from src.core.providers.provider_factory import Loaders
|
from src.core.providers.provider_factory import Loaders
|
||||||
from src.core.SerieScanner import SerieScanner
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OperationStatus(Enum):
|
class DownloadStatusEventArgs:
|
||||||
"""Status of an operation."""
|
"""Event arguments for download status events."""
|
||||||
IDLE = "idle"
|
|
||||||
RUNNING = "running"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
serie_folder: str,
|
||||||
|
season: int,
|
||||||
|
episode: int,
|
||||||
|
status: str,
|
||||||
|
progress: float = 0.0,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
error: Optional[Exception] = None,
|
||||||
|
eta: Optional[int] = None,
|
||||||
|
mbper_sec: Optional[float] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize download status event arguments.
|
||||||
|
|
||||||
@dataclass
|
Args:
|
||||||
class ProgressInfo:
|
serie_folder: Serie folder name
|
||||||
"""Progress information for long-running operations."""
|
season: Season number
|
||||||
current: int
|
episode: Episode number
|
||||||
total: int
|
status: Status message (e.g., "started", "progress", "completed", "failed")
|
||||||
message: str
|
progress: Download progress (0.0 to 1.0)
|
||||||
percentage: float
|
message: Optional status message
|
||||||
status: OperationStatus
|
error: Optional error if status is "failed"
|
||||||
|
eta: Estimated time remaining in seconds
|
||||||
|
mbper_sec: Download speed in MB/s
|
||||||
|
"""
|
||||||
|
self.serie_folder = serie_folder
|
||||||
|
self.season = season
|
||||||
|
self.episode = episode
|
||||||
|
self.status = status
|
||||||
|
self.progress = progress
|
||||||
|
self.message = message
|
||||||
|
self.error = error
|
||||||
|
self.eta = eta
|
||||||
|
self.mbper_sec = mbper_sec
|
||||||
|
|
||||||
|
class ScanStatusEventArgs:
|
||||||
|
"""Event arguments for scan status events."""
|
||||||
|
|
||||||
@dataclass
|
def __init__(
|
||||||
class OperationResult:
|
self,
|
||||||
"""Result of an operation."""
|
current: int,
|
||||||
success: bool
|
total: int,
|
||||||
message: str
|
folder: str,
|
||||||
data: Optional[Any] = None
|
status: str,
|
||||||
error: Optional[Exception] = None
|
progress: float = 0.0,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
error: Optional[Exception] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize scan status event arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current item being scanned
|
||||||
|
total: Total items to scan
|
||||||
|
folder: Current folder being scanned
|
||||||
|
status: Status message (e.g., "started", "progress", "completed", "failed", "cancelled")
|
||||||
|
progress: Scan progress (0.0 to 1.0)
|
||||||
|
message: Optional status message
|
||||||
|
error: Optional error if status is "failed"
|
||||||
|
"""
|
||||||
|
self.current = current
|
||||||
|
self.total = total
|
||||||
|
self.folder = folder
|
||||||
|
self.status = status
|
||||||
|
self.progress = progress
|
||||||
|
self.message = message
|
||||||
|
self.error = error
|
||||||
|
|
||||||
class SeriesApp:
|
class SeriesApp:
|
||||||
"""
|
"""
|
||||||
Main application class for anime series management.
|
Main application class for anime series management.
|
||||||
|
|
||||||
Provides functionality for:
|
Provides functionality for:
|
||||||
- Searching anime series
|
- Searching anime series
|
||||||
- Downloading episodes
|
- Downloading episodes
|
||||||
- Scanning directories for missing episodes
|
- Scanning directories for missing episodes
|
||||||
- Managing series lists
|
- Managing series lists
|
||||||
|
|
||||||
Supports async callbacks for progress reporting and cancellation.
|
Supports async callbacks for progress reporting.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
download_status: Raised when download status changes.
|
||||||
|
Handler signature: def handler(args: DownloadStatusEventArgs)
|
||||||
|
scan_status: Raised when scan status changes.
|
||||||
|
Handler signature: def handler(args: ScanStatusEventArgs)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_initialization_count = 0
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
directory_to_search: str,
|
directory_to_search: str,
|
||||||
progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
|
|
||||||
error_callback: Optional[Callable[[Exception], None]] = None,
|
|
||||||
callback_manager: Optional[CallbackManager] = None
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize SeriesApp.
|
Initialize SeriesApp.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory_to_search: Base directory for anime series
|
directory_to_search: Base directory for anime series
|
||||||
progress_callback: Optional legacy callback for progress updates
|
|
||||||
error_callback: Optional legacy callback for error notifications
|
|
||||||
callback_manager: Optional callback manager for new callback system
|
|
||||||
"""
|
"""
|
||||||
SeriesApp._initialization_count += 1
|
|
||||||
|
|
||||||
# Only show initialization message for the first instance
|
|
||||||
if SeriesApp._initialization_count <= 1:
|
|
||||||
logger.info("Initializing SeriesApp...")
|
|
||||||
|
|
||||||
self.directory_to_search = directory_to_search
|
self.directory_to_search = directory_to_search
|
||||||
self.progress_callback = progress_callback
|
|
||||||
self.error_callback = error_callback
|
# Initialize events
|
||||||
|
self._events = Events()
|
||||||
# Initialize new callback system
|
self._events.download_status = None
|
||||||
self._callback_manager = callback_manager or CallbackManager()
|
self._events.scan_status = None
|
||||||
|
|
||||||
# Cancellation support
|
self.loaders = Loaders()
|
||||||
self._cancel_flag = False
|
self.loader = self.loaders.GetLoader(key="aniworld.to")
|
||||||
self._current_operation: Optional[str] = None
|
self.serie_scanner = SerieScanner(directory_to_search, self.loader)
|
||||||
self._current_operation_id: Optional[str] = None
|
self.list = SerieList(self.directory_to_search)
|
||||||
self._operation_status = OperationStatus.IDLE
|
# Synchronous init used during constructor to avoid awaiting in __init__
|
||||||
|
self._init_list_sync()
|
||||||
# Initialize components
|
|
||||||
try:
|
logger.info("SeriesApp initialized for directory: %s", directory_to_search)
|
||||||
self.Loaders = Loaders()
|
|
||||||
self.loader = self.Loaders.GetLoader(key="aniworld.to")
|
|
||||||
self.SerieScanner = SerieScanner(
|
|
||||||
directory_to_search,
|
|
||||||
self.loader,
|
|
||||||
self._callback_manager
|
|
||||||
)
|
|
||||||
self.List = SerieList(self.directory_to_search)
|
|
||||||
self.__InitList__()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"SeriesApp initialized for directory: %s",
|
|
||||||
directory_to_search
|
|
||||||
)
|
|
||||||
except (IOError, OSError, RuntimeError) as e:
|
|
||||||
logger.error("Failed to initialize SeriesApp: %s", e)
|
|
||||||
self._handle_error(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def callback_manager(self) -> CallbackManager:
|
def download_status(self):
|
||||||
"""Get the callback manager instance."""
|
|
||||||
return self._callback_manager
|
|
||||||
|
|
||||||
def __InitList__(self):
|
|
||||||
"""Initialize the series list with missing episodes."""
|
|
||||||
try:
|
|
||||||
self.series_list = self.List.GetMissingEpisode()
|
|
||||||
logger.debug(
|
|
||||||
"Loaded %d series with missing episodes",
|
|
||||||
len(self.series_list)
|
|
||||||
)
|
|
||||||
except (IOError, OSError, RuntimeError) as e:
|
|
||||||
logger.error("Failed to initialize series list: %s", e)
|
|
||||||
self._handle_error(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def search(self, words: str) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Search for anime series.
|
Event raised when download status changes.
|
||||||
|
|
||||||
|
Subscribe using:
|
||||||
|
app.download_status += handler
|
||||||
|
"""
|
||||||
|
return self._events.download_status
|
||||||
|
|
||||||
|
@download_status.setter
|
||||||
|
def download_status(self, value):
|
||||||
|
"""Set download_status event handler."""
|
||||||
|
self._events.download_status = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scan_status(self):
|
||||||
|
"""
|
||||||
|
Event raised when scan status changes.
|
||||||
|
|
||||||
|
Subscribe using:
|
||||||
|
app.scan_status += handler
|
||||||
|
"""
|
||||||
|
return self._events.scan_status
|
||||||
|
|
||||||
|
@scan_status.setter
|
||||||
|
def scan_status(self, value):
|
||||||
|
"""Set scan_status event handler."""
|
||||||
|
self._events.scan_status = value
|
||||||
|
|
||||||
|
def _init_list_sync(self) -> None:
|
||||||
|
"""Synchronous initialization helper for constructor."""
|
||||||
|
self.series_list = self.list.GetMissingEpisode()
|
||||||
|
logger.debug("Loaded %d series with missing episodes", len(self.series_list))
|
||||||
|
|
||||||
|
async def _init_list(self) -> None:
|
||||||
|
"""Initialize the series list with missing episodes (async)."""
|
||||||
|
self.series_list = await asyncio.to_thread(self.list.GetMissingEpisode)
|
||||||
|
logger.debug("Loaded %d series with missing episodes", len(self.series_list))
|
||||||
|
|
||||||
|
|
||||||
|
async def search(self, words: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search for anime series (async).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
words: Search query
|
words: Search query
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of search results
|
List of search results
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If search fails
|
RuntimeError: If search fails
|
||||||
"""
|
"""
|
||||||
try:
|
logger.info("Searching for: %s", words)
|
||||||
logger.info("Searching for: %s", words)
|
results = await asyncio.to_thread(self.loader.search, words)
|
||||||
results = self.loader.search(words)
|
logger.info("Found %d results", len(results))
|
||||||
logger.info("Found %d results", len(results))
|
return results
|
||||||
return results
|
|
||||||
except (IOError, OSError, RuntimeError) as e:
|
async def download(
|
||||||
logger.error("Search failed for '%s': %s", words, e)
|
|
||||||
self._handle_error(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def download(
|
|
||||||
self,
|
self,
|
||||||
serieFolder: str,
|
serie_folder: str,
|
||||||
season: int,
|
season: int,
|
||||||
episode: int,
|
episode: int,
|
||||||
key: str,
|
key: str,
|
||||||
callback: Optional[Callable[[float], None]] = None,
|
language: str = "German Dub",
|
||||||
language: str = "German Dub"
|
) -> bool:
|
||||||
) -> OperationResult:
|
|
||||||
"""
|
"""
|
||||||
Download an episode.
|
Download an episode (async).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serieFolder: Serie folder name
|
serie_folder: Serie folder name
|
||||||
season: Season number
|
season: Season number
|
||||||
episode: Episode number
|
episode: Episode number
|
||||||
key: Serie key
|
key: Serie key
|
||||||
callback: Optional legacy progress callback
|
|
||||||
language: Language preference
|
language: Language preference
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult with download status
|
True if download succeeded, False otherwise
|
||||||
"""
|
"""
|
||||||
self._current_operation = f"download_S{season:02d}E{episode:02d}"
|
logger.info("Starting download: %s S%02dE%02d", serie_folder, season, episode)
|
||||||
self._current_operation_id = str(uuid.uuid4())
|
|
||||||
self._operation_status = OperationStatus.RUNNING
|
# Fire download started event
|
||||||
self._cancel_flag = False
|
self._events.download_status(
|
||||||
|
DownloadStatusEventArgs(
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
season=season,
|
||||||
|
episode=episode,
|
||||||
|
status="started",
|
||||||
|
message="Download started",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
def download_callback(progress_info):
|
||||||
"Starting download: %s S%02dE%02d",
|
logger.debug(f"wrapped_callback called with: {progress_info}")
|
||||||
serieFolder, season, episode
|
|
||||||
)
|
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||||
|
total_bytes = (
|
||||||
# Notify download starting
|
progress_info.get('total_bytes')
|
||||||
start_msg = (
|
or progress_info.get('total_bytes_estimate', 0)
|
||||||
f"Starting download: {serieFolder} "
|
|
||||||
f"S{season:02d}E{episode:02d}"
|
|
||||||
)
|
|
||||||
self._callback_manager.notify_progress(
|
|
||||||
ProgressContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
phase=ProgressPhase.STARTING,
|
|
||||||
current=0,
|
|
||||||
total=100,
|
|
||||||
percentage=0.0,
|
|
||||||
message=start_msg,
|
|
||||||
metadata={
|
|
||||||
"series": serieFolder,
|
|
||||||
"season": season,
|
|
||||||
"episode": episode,
|
|
||||||
"key": key,
|
|
||||||
"language": language
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
speed = progress_info.get('speed', 0) # bytes/sec
|
||||||
# Check for cancellation before starting
|
eta = progress_info.get('eta') # seconds
|
||||||
if self._is_cancelled():
|
mbper_sec = speed / (1024 * 1024) if speed else None
|
||||||
self._callback_manager.notify_completion(
|
|
||||||
CompletionContext(
|
self._events.download_status(
|
||||||
operation_type=OperationType.DOWNLOAD,
|
DownloadStatusEventArgs(
|
||||||
operation_id=self._current_operation_id,
|
serie_folder=serie_folder,
|
||||||
success=False,
|
season=season,
|
||||||
message="Download cancelled before starting"
|
episode=episode,
|
||||||
|
status="progress",
|
||||||
|
message="Download progress",
|
||||||
|
progress=(downloaded / total_bytes) * 100 if total_bytes else 0,
|
||||||
|
eta=eta,
|
||||||
|
mbper_sec=mbper_sec,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return OperationResult(
|
# Perform download in thread to avoid blocking event loop
|
||||||
success=False,
|
download_success = await asyncio.to_thread(
|
||||||
message="Download cancelled before starting"
|
self.loader.download,
|
||||||
)
|
|
||||||
|
|
||||||
# Wrap callback to enforce cancellation checks and bridge the new
|
|
||||||
# event-driven progress reporting with the legacy callback API that
|
|
||||||
# the CLI still relies on.
|
|
||||||
def wrapped_callback(progress: float):
|
|
||||||
if self._is_cancelled():
|
|
||||||
raise InterruptedError("Download cancelled by user")
|
|
||||||
|
|
||||||
# Notify progress via new callback system
|
|
||||||
self._callback_manager.notify_progress(
|
|
||||||
ProgressContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
phase=ProgressPhase.IN_PROGRESS,
|
|
||||||
current=int(progress),
|
|
||||||
total=100,
|
|
||||||
percentage=progress,
|
|
||||||
message=f"Downloading: {progress:.1f}%",
|
|
||||||
metadata={
|
|
||||||
"series": serieFolder,
|
|
||||||
"season": season,
|
|
||||||
"episode": episode
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call legacy callback if provided
|
|
||||||
if callback:
|
|
||||||
callback(progress)
|
|
||||||
|
|
||||||
# Propagate progress into the legacy callback chain so existing
|
|
||||||
# UI surfaces continue to receive updates without rewriting the
|
|
||||||
# old interfaces.
|
|
||||||
# Call legacy progress_callback if provided
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(ProgressInfo(
|
|
||||||
current=int(progress),
|
|
||||||
total=100,
|
|
||||||
message=f"Downloading S{season:02d}E{episode:02d}",
|
|
||||||
percentage=progress,
|
|
||||||
status=OperationStatus.RUNNING
|
|
||||||
))
|
|
||||||
|
|
||||||
# Perform download
|
|
||||||
self.loader.download(
|
|
||||||
self.directory_to_search,
|
self.directory_to_search,
|
||||||
serieFolder,
|
serie_folder,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
key,
|
key,
|
||||||
language,
|
language,
|
||||||
wrapped_callback
|
download_callback
|
||||||
)
|
)
|
||||||
|
|
||||||
self._operation_status = OperationStatus.COMPLETED
|
|
||||||
logger.info(
|
|
||||||
"Download completed: %s S%02dE%02d",
|
|
||||||
serieFolder, season, episode
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify completion
|
|
||||||
msg = f"Successfully downloaded S{season:02d}E{episode:02d}"
|
|
||||||
self._callback_manager.notify_completion(
|
|
||||||
CompletionContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
success=True,
|
|
||||||
message=msg,
|
|
||||||
statistics={
|
|
||||||
"series": serieFolder,
|
|
||||||
"season": season,
|
|
||||||
"episode": episode
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return OperationResult(
|
|
||||||
success=True,
|
|
||||||
message=msg
|
|
||||||
)
|
|
||||||
|
|
||||||
except InterruptedError as e:
|
|
||||||
self._operation_status = OperationStatus.CANCELLED
|
|
||||||
logger.warning("Download cancelled: %s", e)
|
|
||||||
|
|
||||||
# Notify cancellation
|
|
||||||
self._callback_manager.notify_completion(
|
|
||||||
CompletionContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
success=False,
|
|
||||||
message="Download cancelled"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return OperationResult(
|
|
||||||
success=False,
|
|
||||||
message="Download cancelled",
|
|
||||||
error=e
|
|
||||||
)
|
|
||||||
except (IOError, OSError, RuntimeError) as e:
|
|
||||||
self._operation_status = OperationStatus.FAILED
|
|
||||||
logger.error("Download failed: %s", e)
|
|
||||||
|
|
||||||
# Notify error
|
|
||||||
error_msg = f"Download failed: {str(e)}"
|
|
||||||
self._callback_manager.notify_error(
|
|
||||||
ErrorContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
error=e,
|
|
||||||
message=error_msg,
|
|
||||||
recoverable=False,
|
|
||||||
metadata={
|
|
||||||
"series": serieFolder,
|
|
||||||
"season": season,
|
|
||||||
"episode": episode
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify completion with failure
|
|
||||||
self._callback_manager.notify_completion(
|
|
||||||
CompletionContext(
|
|
||||||
operation_type=OperationType.DOWNLOAD,
|
|
||||||
operation_id=self._current_operation_id,
|
|
||||||
success=False,
|
|
||||||
message=error_msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._handle_error(e)
|
|
||||||
return OperationResult(
|
|
||||||
success=False,
|
|
||||||
message=error_msg,
|
|
||||||
error=e
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._current_operation = None
|
|
||||||
self._current_operation_id = None
|
|
||||||
|
|
||||||
def ReScan(
|
if download_success:
|
||||||
self,
|
logger.info(
|
||||||
callback: Optional[Callable[[str, int], None]] = None
|
"Download completed: %s S%02dE%02d", serie_folder, season, episode
|
||||||
) -> OperationResult:
|
)
|
||||||
|
|
||||||
|
# Fire download completed event
|
||||||
|
self._events.download_status(
|
||||||
|
DownloadStatusEventArgs(
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
season=season,
|
||||||
|
episode=episode,
|
||||||
|
status="completed",
|
||||||
|
progress=1.0,
|
||||||
|
message="Download completed successfully",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Download failed: %s S%02dE%02d", serie_folder, season, episode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fire download failed event
|
||||||
|
self._events.download_status(
|
||||||
|
DownloadStatusEventArgs(
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
season=season,
|
||||||
|
episode=episode,
|
||||||
|
status="failed",
|
||||||
|
message="Download failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return download_success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Download error: %s S%02dE%02d - %s",
|
||||||
|
serie_folder,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fire download error event
|
||||||
|
self._events.download_status(
|
||||||
|
DownloadStatusEventArgs(
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
season=season,
|
||||||
|
episode=episode,
|
||||||
|
status="failed",
|
||||||
|
error=e,
|
||||||
|
message=f"Download error: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def rescan(self) -> int:
|
||||||
"""
|
"""
|
||||||
Rescan directory for missing episodes.
|
Rescan directory for missing episodes (async).
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Optional progress callback (folder, current_count)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult with scan status
|
Number of series with missing episodes after rescan.
|
||||||
"""
|
"""
|
||||||
self._current_operation = "rescan"
|
logger.info("Starting directory rescan")
|
||||||
self._operation_status = OperationStatus.RUNNING
|
|
||||||
self._cancel_flag = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting directory rescan")
|
|
||||||
|
|
||||||
# Get total items to scan
|
# Get total items to scan
|
||||||
total_to_scan = self.SerieScanner.get_total_to_scan()
|
total_to_scan = await asyncio.to_thread(self.serie_scanner.get_total_to_scan)
|
||||||
logger.info("Total folders to scan: %d", total_to_scan)
|
logger.info("Total folders to scan: %d", total_to_scan)
|
||||||
|
|
||||||
|
# Fire scan started event
|
||||||
|
self._events.scan_status(
|
||||||
|
ScanStatusEventArgs(
|
||||||
|
current=0,
|
||||||
|
total=total_to_scan,
|
||||||
|
folder="",
|
||||||
|
status="started",
|
||||||
|
progress=0.0,
|
||||||
|
message="Scan started",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Reinitialize scanner
|
# Reinitialize scanner
|
||||||
self.SerieScanner.reinit()
|
await asyncio.to_thread(self.serie_scanner.reinit)
|
||||||
|
|
||||||
# Wrap the scanner callback so we can surface progress through the
|
def scan_callback(folder: str, current: int):
|
||||||
# new ProgressInfo pipeline while maintaining backwards
|
|
||||||
# compatibility with the legacy tuple-based callback signature.
|
|
||||||
def wrapped_callback(folder: str, current: int):
|
|
||||||
if self._is_cancelled():
|
|
||||||
raise InterruptedError("Scan cancelled by user")
|
|
||||||
|
|
||||||
# Calculate progress
|
# Calculate progress
|
||||||
if total_to_scan > 0:
|
if total_to_scan > 0:
|
||||||
percentage = (current / total_to_scan * 100)
|
progress = current / total_to_scan
|
||||||
else:
|
else:
|
||||||
percentage = 0
|
progress = 0.0
|
||||||
|
|
||||||
# Report progress
|
# Fire scan progress event
|
||||||
if self.progress_callback:
|
self._events.scan_status(
|
||||||
progress_info = ProgressInfo(
|
ScanStatusEventArgs(
|
||||||
current=current,
|
current=current,
|
||||||
total=total_to_scan,
|
total=total_to_scan,
|
||||||
|
folder=folder,
|
||||||
|
status="progress",
|
||||||
|
progress=progress,
|
||||||
message=f"Scanning: {folder}",
|
message=f"Scanning: {folder}",
|
||||||
percentage=percentage,
|
|
||||||
status=OperationStatus.RUNNING
|
|
||||||
)
|
)
|
||||||
self.progress_callback(progress_info)
|
|
||||||
|
|
||||||
# Call original callback if provided
|
|
||||||
if callback:
|
|
||||||
callback(folder, current)
|
|
||||||
|
|
||||||
# Perform scan
|
|
||||||
self.SerieScanner.scan(wrapped_callback)
|
|
||||||
|
|
||||||
# Reinitialize list
|
|
||||||
self.List = SerieList(self.directory_to_search)
|
|
||||||
self.__InitList__()
|
|
||||||
|
|
||||||
self._operation_status = OperationStatus.COMPLETED
|
|
||||||
logger.info("Directory rescan completed successfully")
|
|
||||||
|
|
||||||
msg = (
|
|
||||||
f"Scan completed. Found {len(self.series_list)} "
|
|
||||||
f"series."
|
|
||||||
)
|
|
||||||
return OperationResult(
|
|
||||||
success=True,
|
|
||||||
message=msg,
|
|
||||||
data={"series_count": len(self.series_list)}
|
|
||||||
)
|
|
||||||
|
|
||||||
except InterruptedError as e:
|
|
||||||
self._operation_status = OperationStatus.CANCELLED
|
|
||||||
logger.warning("Scan cancelled: %s", e)
|
|
||||||
return OperationResult(
|
|
||||||
success=False,
|
|
||||||
message="Scan cancelled",
|
|
||||||
error=e
|
|
||||||
)
|
|
||||||
except (IOError, OSError, RuntimeError) as e:
|
|
||||||
self._operation_status = OperationStatus.FAILED
|
|
||||||
logger.error("Scan failed: %s", e)
|
|
||||||
self._handle_error(e)
|
|
||||||
return OperationResult(
|
|
||||||
success=False,
|
|
||||||
message=f"Scan failed: {str(e)}",
|
|
||||||
error=e
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._current_operation = None
|
|
||||||
|
|
||||||
async def async_download(
|
|
||||||
self,
|
|
||||||
serieFolder: str,
|
|
||||||
season: int,
|
|
||||||
episode: int,
|
|
||||||
key: str,
|
|
||||||
callback: Optional[Callable[[float], None]] = None,
|
|
||||||
language: str = "German Dub"
|
|
||||||
) -> OperationResult:
|
|
||||||
"""
|
|
||||||
Async version of download method.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serieFolder: Serie folder name
|
|
||||||
season: Season number
|
|
||||||
episode: Episode number
|
|
||||||
key: Serie key
|
|
||||||
callback: Optional progress callback
|
|
||||||
language: Language preference
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OperationResult with download status
|
|
||||||
"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
self.download,
|
|
||||||
serieFolder,
|
|
||||||
season,
|
|
||||||
episode,
|
|
||||||
key,
|
|
||||||
callback,
|
|
||||||
language
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_rescan(
|
|
||||||
self,
|
|
||||||
callback: Optional[Callable[[str, int], None]] = None
|
|
||||||
) -> OperationResult:
|
|
||||||
"""
|
|
||||||
Async version of ReScan method.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Optional progress callback
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OperationResult with scan status
|
|
||||||
"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
self.ReScan,
|
|
||||||
callback
|
|
||||||
)
|
|
||||||
|
|
||||||
def cancel_operation(self) -> bool:
|
|
||||||
"""
|
|
||||||
Cancel the current operation.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if operation cancelled, False if no operation running
|
|
||||||
"""
|
|
||||||
if (self._current_operation and
|
|
||||||
self._operation_status == OperationStatus.RUNNING):
|
|
||||||
logger.info(
|
|
||||||
"Cancelling operation: %s",
|
|
||||||
self._current_operation
|
|
||||||
)
|
|
||||||
self._cancel_flag = True
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_cancelled(self) -> bool:
|
|
||||||
"""Check if the current operation has been cancelled."""
|
|
||||||
return self._cancel_flag
|
|
||||||
|
|
||||||
def _handle_error(self, error: Exception) -> None:
|
|
||||||
"""
|
|
||||||
Handle errors and notify via callback.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: Exception that occurred
|
|
||||||
"""
|
|
||||||
if self.error_callback:
|
|
||||||
try:
|
|
||||||
self.error_callback(error)
|
|
||||||
except (RuntimeError, ValueError) as callback_error:
|
|
||||||
logger.error(
|
|
||||||
"Error in error callback: %s",
|
|
||||||
callback_error
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_series_list(self) -> List[Any]:
|
# Perform scan
|
||||||
|
await asyncio.to_thread(self.serie_scanner.scan, scan_callback)
|
||||||
|
|
||||||
|
# Reinitialize list
|
||||||
|
self.list = SerieList(self.directory_to_search)
|
||||||
|
await self._init_list()
|
||||||
|
|
||||||
|
logger.info("Directory rescan completed successfully")
|
||||||
|
|
||||||
|
# Fire scan completed event
|
||||||
|
self._events.scan_status(
|
||||||
|
ScanStatusEventArgs(
|
||||||
|
current=total_to_scan,
|
||||||
|
total=total_to_scan,
|
||||||
|
folder="",
|
||||||
|
status="completed",
|
||||||
|
progress=1.0,
|
||||||
|
message=f"Scan completed. Found {len(self.series_list)} series with missing episodes.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(self.series_list)
|
||||||
|
|
||||||
|
except InterruptedError:
|
||||||
|
logger.warning("Scan cancelled by user")
|
||||||
|
|
||||||
|
# Fire scan cancelled event
|
||||||
|
self._events.scan_status(
|
||||||
|
ScanStatusEventArgs(
|
||||||
|
current=0,
|
||||||
|
total=total_to_scan if 'total_to_scan' in locals() else 0,
|
||||||
|
folder="",
|
||||||
|
status="cancelled",
|
||||||
|
message="Scan cancelled by user",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Scan error: %s", str(e), exc_info=True)
|
||||||
|
|
||||||
|
# Fire scan failed event
|
||||||
|
self._events.scan_status(
|
||||||
|
ScanStatusEventArgs(
|
||||||
|
current=0,
|
||||||
|
total=total_to_scan if 'total_to_scan' in locals() else 0,
|
||||||
|
folder="",
|
||||||
|
status="failed",
|
||||||
|
error=e,
|
||||||
|
message=f"Scan error: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_series_list(self) -> List[Any]:
|
||||||
"""
|
"""
|
||||||
Get the current series list.
|
Get the current series list (async).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of series with missing episodes
|
List of series with missing episodes
|
||||||
"""
|
"""
|
||||||
return self.series_list
|
return self.series_list
|
||||||
|
|
||||||
def refresh_series_list(self) -> None:
|
async def refresh_series_list(self) -> None:
|
||||||
"""Reload the cached series list from the underlying data store."""
|
"""Reload the cached series list from the underlying data store (async)."""
|
||||||
self.__InitList__()
|
await self._init_list()
|
||||||
|
|
||||||
def get_operation_status(self) -> OperationStatus:
|
|
||||||
"""
|
|
||||||
Get the current operation status.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current operation status
|
|
||||||
"""
|
|
||||||
return self._operation_status
|
|
||||||
|
|
||||||
def get_current_operation(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get the current operation name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Name of current operation or None
|
|
||||||
"""
|
|
||||||
return self._current_operation
|
|
||||||
|
|||||||
@ -93,12 +93,16 @@ class AniworldLoader(Loader):
|
|||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""Clear the cached HTML data."""
|
"""Clear the cached HTML data."""
|
||||||
|
logging.debug("Clearing HTML cache")
|
||||||
self._KeyHTMLDict = {}
|
self._KeyHTMLDict = {}
|
||||||
self._EpisodeHTMLDict = {}
|
self._EpisodeHTMLDict = {}
|
||||||
|
logging.debug("HTML cache cleared successfully")
|
||||||
|
|
||||||
def remove_from_cache(self):
|
def remove_from_cache(self):
|
||||||
"""Remove episode HTML from cache."""
|
"""Remove episode HTML from cache."""
|
||||||
|
logging.debug("Removing episode HTML from cache")
|
||||||
self._EpisodeHTMLDict = {}
|
self._EpisodeHTMLDict = {}
|
||||||
|
logging.debug("Episode HTML cache cleared")
|
||||||
|
|
||||||
def search(self, word: str) -> list:
|
def search(self, word: str) -> list:
|
||||||
"""Search for anime series.
|
"""Search for anime series.
|
||||||
@ -109,23 +113,30 @@ class AniworldLoader(Loader):
|
|||||||
Returns:
|
Returns:
|
||||||
List of found series
|
List of found series
|
||||||
"""
|
"""
|
||||||
|
logging.info(f"Searching for anime with keyword: '{word}'")
|
||||||
search_url = (
|
search_url = (
|
||||||
f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
|
f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Search URL: {search_url}")
|
||||||
anime_list = self.fetch_anime_list(search_url)
|
anime_list = self.fetch_anime_list(search_url)
|
||||||
|
logging.info(f"Found {len(anime_list)} anime series for keyword '{word}'")
|
||||||
|
|
||||||
return anime_list
|
return anime_list
|
||||||
|
|
||||||
def fetch_anime_list(self, url: str) -> list:
|
def fetch_anime_list(self, url: str) -> list:
|
||||||
|
logging.debug(f"Fetching anime list from URL: {url}")
|
||||||
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()
|
||||||
|
logging.debug(f"Response status code: {response.status_code}")
|
||||||
|
|
||||||
clean_text = response.text.strip()
|
clean_text = response.text.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decoded_data = json.loads(html.unescape(clean_text))
|
decoded_data = json.loads(html.unescape(clean_text))
|
||||||
|
logging.debug(f"Successfully decoded JSON data on first attempt")
|
||||||
return decoded_data if isinstance(decoded_data, list) else []
|
return decoded_data if isinstance(decoded_data, list) else []
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
logging.warning("Initial JSON decode failed, attempting cleanup")
|
||||||
try:
|
try:
|
||||||
# Remove BOM and problematic characters
|
# Remove BOM and problematic characters
|
||||||
clean_text = clean_text.encode('utf-8').decode('utf-8-sig')
|
clean_text = clean_text.encode('utf-8').decode('utf-8-sig')
|
||||||
@ -133,8 +144,10 @@ class AniworldLoader(Loader):
|
|||||||
clean_text = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', clean_text)
|
clean_text = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', clean_text)
|
||||||
# Parse the new text
|
# Parse the new text
|
||||||
decoded_data = json.loads(clean_text)
|
decoded_data = json.loads(clean_text)
|
||||||
|
logging.debug("Successfully decoded JSON after cleanup")
|
||||||
return decoded_data if isinstance(decoded_data, list) else []
|
return decoded_data if isinstance(decoded_data, list) else []
|
||||||
except (requests.RequestException, json.JSONDecodeError) as exc:
|
except (requests.RequestException, json.JSONDecodeError) as exc:
|
||||||
|
logging.error(f"Failed to decode anime list from {url}: {exc}")
|
||||||
raise ValueError("Could not get valid anime: ") from exc
|
raise ValueError("Could not get valid anime: ") from exc
|
||||||
|
|
||||||
def _get_language_key(self, language: str) -> int:
|
def _get_language_key(self, language: str) -> int:
|
||||||
@ -152,6 +165,7 @@ class AniworldLoader(Loader):
|
|||||||
language_code = 2
|
language_code = 2
|
||||||
if language == "German Sub":
|
if language == "German Sub":
|
||||||
language_code = 3
|
language_code = 3
|
||||||
|
logging.debug(f"Converted language '{language}' to code {language_code}")
|
||||||
return language_code
|
return language_code
|
||||||
|
|
||||||
def is_language(
|
def is_language(
|
||||||
@ -162,6 +176,7 @@ class AniworldLoader(Loader):
|
|||||||
language: str = "German Dub"
|
language: str = "German Dub"
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if episode is available in specified language."""
|
"""Check if episode is available in specified language."""
|
||||||
|
logging.debug(f"Checking if S{season:02}E{episode:03} ({key}) is available in {language}")
|
||||||
language_code = self._get_language_key(language)
|
language_code = self._get_language_key(language)
|
||||||
|
|
||||||
episode_soup = BeautifulSoup(
|
episode_soup = BeautifulSoup(
|
||||||
@ -179,7 +194,9 @@ class AniworldLoader(Loader):
|
|||||||
if lang_key and lang_key.isdigit():
|
if lang_key and lang_key.isdigit():
|
||||||
languages.append(int(lang_key))
|
languages.append(int(lang_key))
|
||||||
|
|
||||||
return language_code in languages
|
is_available = language_code in languages
|
||||||
|
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}")
|
||||||
|
return is_available
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
@ -192,10 +209,12 @@ class AniworldLoader(Loader):
|
|||||||
progress_callback=None
|
progress_callback=None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Download episode to specified directory."""
|
"""Download episode to specified directory."""
|
||||||
|
logging.info(f"Starting download for S{season:02}E{episode:03} ({key}) in {language}")
|
||||||
sanitized_anime_title = ''.join(
|
sanitized_anime_title = ''.join(
|
||||||
char for char in self.get_title(key)
|
char for char in self.get_title(key)
|
||||||
if char not in self.INVALID_PATH_CHARS
|
if char not in self.INVALID_PATH_CHARS
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Sanitized anime title: {sanitized_anime_title}")
|
||||||
|
|
||||||
if season == 0:
|
if season == 0:
|
||||||
output_file = (
|
output_file = (
|
||||||
@ -215,16 +234,20 @@ class AniworldLoader(Loader):
|
|||||||
f"Season {season}"
|
f"Season {season}"
|
||||||
)
|
)
|
||||||
output_path = os.path.join(folder_path, output_file)
|
output_path = os.path.join(folder_path, output_file)
|
||||||
|
logging.debug(f"Output path: {output_path}")
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
temp_dir = "./Temp/"
|
temp_dir = "./Temp/"
|
||||||
os.makedirs(os.path.dirname(temp_dir), exist_ok=True)
|
os.makedirs(os.path.dirname(temp_dir), exist_ok=True)
|
||||||
temp_path = os.path.join(temp_dir, output_file)
|
temp_path = os.path.join(temp_dir, output_file)
|
||||||
|
logging.debug(f"Temporary path: {temp_path}")
|
||||||
|
|
||||||
for provider in self.SUPPORTED_PROVIDERS:
|
for provider in self.SUPPORTED_PROVIDERS:
|
||||||
|
logging.debug(f"Attempting download with provider: {provider}")
|
||||||
link, header = self._get_direct_link_from_provider(
|
link, header = self._get_direct_link_from_provider(
|
||||||
season, episode, key, language
|
season, episode, key, language
|
||||||
)
|
)
|
||||||
|
logging.debug("Direct link obtained from provider")
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'fragment_retries': float('inf'),
|
'fragment_retries': float('inf'),
|
||||||
'outtmpl': temp_path,
|
'outtmpl': temp_path,
|
||||||
@ -236,18 +259,69 @@ class AniworldLoader(Loader):
|
|||||||
|
|
||||||
if header:
|
if header:
|
||||||
ydl_opts['http_headers'] = header
|
ydl_opts['http_headers'] = header
|
||||||
|
logging.debug("Using custom headers for download")
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
ydl_opts['progress_hooks'] = [progress_callback]
|
# Wrap the callback to add logging
|
||||||
|
def logged_progress_callback(d):
|
||||||
|
logging.debug(
|
||||||
|
f"YT-DLP progress: status={d.get('status')}, "
|
||||||
|
f"downloaded={d.get('downloaded_bytes')}, "
|
||||||
|
f"total={d.get('total_bytes')}, "
|
||||||
|
f"speed={d.get('speed')}"
|
||||||
|
)
|
||||||
|
progress_callback(d)
|
||||||
|
|
||||||
|
ydl_opts['progress_hooks'] = [logged_progress_callback]
|
||||||
|
logging.debug("Progress callback registered with YT-DLP")
|
||||||
|
|
||||||
with YoutubeDL(ydl_opts) as ydl:
|
try:
|
||||||
ydl.download([link])
|
logging.debug("Starting YoutubeDL download")
|
||||||
|
logging.debug(f"Download link: {link[:100]}...")
|
||||||
|
logging.debug(f"YDL options: {ydl_opts}")
|
||||||
|
|
||||||
|
with YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(link, download=True)
|
||||||
|
logging.debug(
|
||||||
|
f"Download info: "
|
||||||
|
f"title={info.get('title')}, "
|
||||||
|
f"filesize={info.get('filesize')}"
|
||||||
|
)
|
||||||
|
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
shutil.copy(temp_path, output_path)
|
logging.debug("Moving file from temp to final destination")
|
||||||
os.remove(temp_path)
|
shutil.copy(temp_path, output_path)
|
||||||
|
os.remove(temp_path)
|
||||||
|
logging.info(
|
||||||
|
f"Download completed successfully: {output_file}"
|
||||||
|
)
|
||||||
|
self.clear_cache()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
f"Download failed: temp file not found at {temp_path}"
|
||||||
|
)
|
||||||
|
self.clear_cache()
|
||||||
|
return False
|
||||||
|
except BrokenPipeError as e:
|
||||||
|
logging.error(
|
||||||
|
f"Broken pipe error with provider {provider}: {e}. "
|
||||||
|
f"This usually means the stream connection was closed."
|
||||||
|
)
|
||||||
|
# Try next provider if available
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"YoutubeDL download failed with provider {provider}: "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
# Try next provider if available
|
||||||
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# If we get here, all providers failed
|
||||||
|
logging.error("All download providers failed")
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return True
|
return False
|
||||||
|
|
||||||
def get_site_key(self) -> str:
|
def get_site_key(self) -> str:
|
||||||
"""Get the site key for this provider."""
|
"""Get the site key for this provider."""
|
||||||
@ -255,6 +329,7 @@ class AniworldLoader(Loader):
|
|||||||
|
|
||||||
def get_title(self, key: str) -> str:
|
def get_title(self, key: str) -> str:
|
||||||
"""Get anime title from series key."""
|
"""Get anime title from series key."""
|
||||||
|
logging.debug(f"Getting title for key: {key}")
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
self._get_key_html(key).content,
|
self._get_key_html(key).content,
|
||||||
'html.parser'
|
'html.parser'
|
||||||
@ -262,8 +337,11 @@ class AniworldLoader(Loader):
|
|||||||
title_div = soup.find('div', class_='series-title')
|
title_div = soup.find('div', class_='series-title')
|
||||||
|
|
||||||
if title_div:
|
if title_div:
|
||||||
return title_div.find('h1').find('span').text
|
title = title_div.find('h1').find('span').text
|
||||||
|
logging.debug(f"Found title: {title}")
|
||||||
|
return title
|
||||||
|
|
||||||
|
logging.warning(f"No title found for key: {key}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _get_key_html(self, key: str):
|
def _get_key_html(self, key: str):
|
||||||
@ -276,14 +354,18 @@ class AniworldLoader(Loader):
|
|||||||
Cached or fetched HTML response
|
Cached or fetched HTML response
|
||||||
"""
|
"""
|
||||||
if key in self._KeyHTMLDict:
|
if key in self._KeyHTMLDict:
|
||||||
|
logging.debug(f"Using cached HTML for key: {key}")
|
||||||
return self._KeyHTMLDict[key]
|
return self._KeyHTMLDict[key]
|
||||||
|
|
||||||
# Sanitize key parameter for URL
|
# Sanitize key parameter for URL
|
||||||
safe_key = quote(key, safe='')
|
safe_key = quote(key, safe='')
|
||||||
|
url = f"{self.ANIWORLD_TO}/anime/stream/{safe_key}"
|
||||||
|
logging.debug(f"Fetching HTML for key: {key} from {url}")
|
||||||
self._KeyHTMLDict[key] = self.session.get(
|
self._KeyHTMLDict[key] = self.session.get(
|
||||||
f"{self.ANIWORLD_TO}/anime/stream/{safe_key}",
|
url,
|
||||||
timeout=self.DEFAULT_REQUEST_TIMEOUT
|
timeout=self.DEFAULT_REQUEST_TIMEOUT
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Cached HTML for key: {key}")
|
||||||
return self._KeyHTMLDict[key]
|
return self._KeyHTMLDict[key]
|
||||||
|
|
||||||
def _get_episode_html(self, season: int, episode: int, key: str):
|
def _get_episode_html(self, season: int, episode: int, key: str):
|
||||||
@ -302,11 +384,14 @@ class AniworldLoader(Loader):
|
|||||||
"""
|
"""
|
||||||
# Validate season and episode numbers
|
# Validate season and episode numbers
|
||||||
if season < 1 or season > 999:
|
if season < 1 or season > 999:
|
||||||
|
logging.error(f"Invalid season number: {season}")
|
||||||
raise ValueError(f"Invalid season number: {season}")
|
raise ValueError(f"Invalid season number: {season}")
|
||||||
if episode < 1 or episode > 9999:
|
if episode < 1 or episode > 9999:
|
||||||
|
logging.error(f"Invalid episode number: {episode}")
|
||||||
raise ValueError(f"Invalid episode number: {episode}")
|
raise ValueError(f"Invalid episode number: {episode}")
|
||||||
|
|
||||||
if key in self._EpisodeHTMLDict:
|
if key in self._EpisodeHTMLDict:
|
||||||
|
logging.debug(f"Using cached HTML for S{season:02}E{episode:03} ({key})")
|
||||||
return self._EpisodeHTMLDict[(key, season, episode)]
|
return self._EpisodeHTMLDict[(key, season, episode)]
|
||||||
|
|
||||||
# Sanitize key parameter for URL
|
# Sanitize key parameter for URL
|
||||||
@ -315,8 +400,10 @@ class AniworldLoader(Loader):
|
|||||||
f"{self.ANIWORLD_TO}/anime/stream/{safe_key}/"
|
f"{self.ANIWORLD_TO}/anime/stream/{safe_key}/"
|
||||||
f"staffel-{season}/episode-{episode}"
|
f"staffel-{season}/episode-{episode}"
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Fetching episode HTML from: {link}")
|
||||||
html = self.session.get(link, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
html = self.session.get(link, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
||||||
self._EpisodeHTMLDict[(key, season, episode)] = html
|
self._EpisodeHTMLDict[(key, season, episode)] = html
|
||||||
|
logging.debug(f"Cached episode HTML for S{season:02}E{episode:03} ({key})")
|
||||||
return self._EpisodeHTMLDict[(key, season, episode)]
|
return self._EpisodeHTMLDict[(key, season, episode)]
|
||||||
|
|
||||||
def _get_provider_from_html(
|
def _get_provider_from_html(
|
||||||
@ -336,6 +423,7 @@ class AniworldLoader(Loader):
|
|||||||
2: 'https://aniworld.to/redirect/1766405'},
|
2: 'https://aniworld.to/redirect/1766405'},
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
logging.debug(f"Extracting providers from HTML for S{season:02}E{episode:03} ({key})")
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
self._get_episode_html(season, episode, key).content,
|
self._get_episode_html(season, episode, key).content,
|
||||||
'html.parser'
|
'html.parser'
|
||||||
@ -347,6 +435,7 @@ class AniworldLoader(Loader):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not episode_links:
|
if not episode_links:
|
||||||
|
logging.warning(f"No episode links found for S{season:02}E{episode:03} ({key})")
|
||||||
return providers
|
return providers
|
||||||
|
|
||||||
for link in episode_links:
|
for link in episode_links:
|
||||||
@ -374,7 +463,9 @@ class AniworldLoader(Loader):
|
|||||||
providers[provider_name][lang_key] = (
|
providers[provider_name][lang_key] = (
|
||||||
f"{self.ANIWORLD_TO}{redirect_link}"
|
f"{self.ANIWORLD_TO}{redirect_link}"
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Found provider: {provider_name}, lang_key: {lang_key}")
|
||||||
|
|
||||||
|
logging.debug(f"Total providers found: {len(providers)}")
|
||||||
return providers
|
return providers
|
||||||
|
|
||||||
def _get_redirect_link(
|
def _get_redirect_link(
|
||||||
@ -385,6 +476,7 @@ class AniworldLoader(Loader):
|
|||||||
language: str = "German Dub"
|
language: str = "German Dub"
|
||||||
):
|
):
|
||||||
"""Get redirect link for episode in specified language."""
|
"""Get redirect link for episode in specified language."""
|
||||||
|
logging.debug(f"Getting redirect link for S{season:02}E{episode:03} ({key}) in {language}")
|
||||||
language_code = self._get_language_key(language)
|
language_code = self._get_language_key(language)
|
||||||
if self.is_language(season, episode, key, language):
|
if self.is_language(season, episode, key, language):
|
||||||
for (provider_name, lang_dict) in (
|
for (provider_name, lang_dict) in (
|
||||||
@ -393,7 +485,9 @@ class AniworldLoader(Loader):
|
|||||||
).items()
|
).items()
|
||||||
):
|
):
|
||||||
if language_code in lang_dict:
|
if language_code in lang_dict:
|
||||||
|
logging.debug(f"Found redirect link with provider: {provider_name}")
|
||||||
return (lang_dict[language_code], provider_name)
|
return (lang_dict[language_code], provider_name)
|
||||||
|
logging.warning(f"No redirect link found for S{season:02}E{episode:03} ({key}) in {language}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_embeded_link(
|
def _get_embeded_link(
|
||||||
@ -404,15 +498,18 @@ class AniworldLoader(Loader):
|
|||||||
language: str = "German Dub"
|
language: str = "German Dub"
|
||||||
):
|
):
|
||||||
"""Get embedded link from redirect link."""
|
"""Get embedded link from redirect link."""
|
||||||
|
logging.debug(f"Getting embedded link for S{season:02}E{episode:03} ({key}) in {language}")
|
||||||
redirect_link, provider_name = (
|
redirect_link, provider_name = (
|
||||||
self._get_redirect_link(season, episode, key, language)
|
self._get_redirect_link(season, episode, key, language)
|
||||||
)
|
)
|
||||||
|
logging.debug(f"Redirect link: {redirect_link}, provider: {provider_name}")
|
||||||
|
|
||||||
embeded_link = self.session.get(
|
embeded_link = self.session.get(
|
||||||
redirect_link,
|
redirect_link,
|
||||||
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
headers={'User-Agent': self.RANDOM_USER_AGENT}
|
headers={'User-Agent': self.RANDOM_USER_AGENT}
|
||||||
).url
|
).url
|
||||||
|
logging.debug(f"Embedded link: {embeded_link}")
|
||||||
return embeded_link
|
return embeded_link
|
||||||
|
|
||||||
def _get_direct_link_from_provider(
|
def _get_direct_link_from_provider(
|
||||||
@ -423,12 +520,15 @@ class AniworldLoader(Loader):
|
|||||||
language: str = "German Dub"
|
language: str = "German Dub"
|
||||||
):
|
):
|
||||||
"""Get direct download link from streaming provider."""
|
"""Get direct download link from streaming provider."""
|
||||||
|
logging.debug(f"Getting direct link from provider for S{season:02}E{episode:03} ({key}) in {language}")
|
||||||
embeded_link = self._get_embeded_link(
|
embeded_link = self._get_embeded_link(
|
||||||
season, episode, key, language
|
season, episode, key, language
|
||||||
)
|
)
|
||||||
if embeded_link is None:
|
if embeded_link is None:
|
||||||
|
logging.error(f"No embedded link found for S{season:02}E{episode:03} ({key})")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
logging.debug(f"Using VOE provider to extract direct link")
|
||||||
return self.Providers.GetProvider(
|
return self.Providers.GetProvider(
|
||||||
"VOE"
|
"VOE"
|
||||||
).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT)
|
).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT)
|
||||||
@ -442,19 +542,23 @@ class AniworldLoader(Loader):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping season numbers to episode counts
|
Dictionary mapping season numbers to episode counts
|
||||||
"""
|
"""
|
||||||
|
logging.info(f"Getting season and episode count for slug: {slug}")
|
||||||
# Sanitize slug parameter for URL
|
# Sanitize slug parameter for URL
|
||||||
safe_slug = quote(slug, safe='')
|
safe_slug = quote(slug, safe='')
|
||||||
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
|
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
|
||||||
|
logging.debug(f"Base URL: {base_url}")
|
||||||
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
|
||||||
season_meta = soup.find('meta', itemprop='numberOfSeasons')
|
season_meta = soup.find('meta', itemprop='numberOfSeasons')
|
||||||
number_of_seasons = int(season_meta['content']) if season_meta else 0
|
number_of_seasons = int(season_meta['content']) if season_meta else 0
|
||||||
|
logging.info(f"Found {number_of_seasons} seasons for '{slug}'")
|
||||||
|
|
||||||
episode_counts = {}
|
episode_counts = {}
|
||||||
|
|
||||||
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}"
|
||||||
|
logging.debug(f"Fetching episodes for season {season} from: {season_url}")
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
season_url,
|
season_url,
|
||||||
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
@ -469,5 +573,7 @@ class AniworldLoader(Loader):
|
|||||||
)
|
)
|
||||||
|
|
||||||
episode_counts[season] = len(unique_links)
|
episode_counts[season] = len(unique_links)
|
||||||
|
logging.debug(f"Season {season} has {episode_counts[season]} episodes")
|
||||||
|
|
||||||
|
logging.info(f"Episode count retrieval complete for '{slug}': {episode_counts}")
|
||||||
return episode_counts
|
return episode_counts
|
||||||
|
|||||||
@ -1,258 +0,0 @@
|
|||||||
"""Analytics API endpoints for accessing system analytics and reports.
|
|
||||||
|
|
||||||
Provides REST API endpoints for querying analytics data including download
|
|
||||||
statistics, series popularity, storage analysis, and performance reports.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.services.analytics_service import get_analytics_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadStatsResponse(BaseModel):
|
|
||||||
"""Download statistics response model."""
|
|
||||||
|
|
||||||
total_downloads: int
|
|
||||||
successful_downloads: int
|
|
||||||
failed_downloads: int
|
|
||||||
total_bytes_downloaded: int
|
|
||||||
average_speed_mbps: float
|
|
||||||
success_rate: float
|
|
||||||
average_duration_seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
class SeriesPopularityResponse(BaseModel):
|
|
||||||
"""Series popularity response model."""
|
|
||||||
|
|
||||||
series_name: str
|
|
||||||
download_count: int
|
|
||||||
total_size_bytes: int
|
|
||||||
last_download: Optional[str]
|
|
||||||
success_rate: float
|
|
||||||
|
|
||||||
|
|
||||||
class StorageAnalysisResponse(BaseModel):
|
|
||||||
"""Storage analysis response model."""
|
|
||||||
|
|
||||||
total_storage_bytes: int
|
|
||||||
used_storage_bytes: int
|
|
||||||
free_storage_bytes: int
|
|
||||||
storage_percent_used: float
|
|
||||||
downloads_directory_size_bytes: int
|
|
||||||
cache_directory_size_bytes: int
|
|
||||||
logs_directory_size_bytes: int
|
|
||||||
|
|
||||||
|
|
||||||
class PerformanceReportResponse(BaseModel):
|
|
||||||
"""Performance report response model."""
|
|
||||||
|
|
||||||
period_start: str
|
|
||||||
period_end: str
|
|
||||||
downloads_per_hour: float
|
|
||||||
average_queue_size: float
|
|
||||||
peak_memory_usage_mb: float
|
|
||||||
average_cpu_percent: float
|
|
||||||
uptime_seconds: float
|
|
||||||
error_rate: float
|
|
||||||
|
|
||||||
|
|
||||||
class SummaryReportResponse(BaseModel):
|
|
||||||
"""Comprehensive analytics summary response."""
|
|
||||||
|
|
||||||
timestamp: str
|
|
||||||
download_stats: DownloadStatsResponse
|
|
||||||
series_popularity: list[SeriesPopularityResponse]
|
|
||||||
storage_analysis: StorageAnalysisResponse
|
|
||||||
performance_report: PerformanceReportResponse
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/downloads", response_model=DownloadStatsResponse)
|
|
||||||
async def get_download_statistics(
|
|
||||||
days: int = 30,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> DownloadStatsResponse:
|
|
||||||
"""Get download statistics for specified period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to analyze (default: 30)
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Download statistics including success rates and speeds
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_analytics_service()
|
|
||||||
stats = await service.get_download_stats(db, days=days)
|
|
||||||
|
|
||||||
return DownloadStatsResponse(
|
|
||||||
total_downloads=stats.total_downloads,
|
|
||||||
successful_downloads=stats.successful_downloads,
|
|
||||||
failed_downloads=stats.failed_downloads,
|
|
||||||
total_bytes_downloaded=stats.total_bytes_downloaded,
|
|
||||||
average_speed_mbps=stats.average_speed_mbps,
|
|
||||||
success_rate=stats.success_rate,
|
|
||||||
average_duration_seconds=stats.average_duration_seconds,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to get download statistics: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/series-popularity",
|
|
||||||
response_model=list[SeriesPopularityResponse]
|
|
||||||
)
|
|
||||||
async def get_series_popularity(
|
|
||||||
limit: int = 10,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[SeriesPopularityResponse]:
|
|
||||||
"""Get most popular series by download count.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of series (default: 10)
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of series sorted by popularity
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_analytics_service()
|
|
||||||
popularity = await service.get_series_popularity(db, limit=limit)
|
|
||||||
|
|
||||||
return [
|
|
||||||
SeriesPopularityResponse(
|
|
||||||
series_name=p.series_name,
|
|
||||||
download_count=p.download_count,
|
|
||||||
total_size_bytes=p.total_size_bytes,
|
|
||||||
last_download=p.last_download,
|
|
||||||
success_rate=p.success_rate,
|
|
||||||
)
|
|
||||||
for p in popularity
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to get series popularity: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/storage",
|
|
||||||
response_model=StorageAnalysisResponse
|
|
||||||
)
|
|
||||||
async def get_storage_analysis() -> StorageAnalysisResponse:
|
|
||||||
"""Get current storage usage analysis.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Storage breakdown including disk and directory usage
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_analytics_service()
|
|
||||||
analysis = service.get_storage_analysis()
|
|
||||||
|
|
||||||
return StorageAnalysisResponse(
|
|
||||||
total_storage_bytes=analysis.total_storage_bytes,
|
|
||||||
used_storage_bytes=analysis.used_storage_bytes,
|
|
||||||
free_storage_bytes=analysis.free_storage_bytes,
|
|
||||||
storage_percent_used=analysis.storage_percent_used,
|
|
||||||
downloads_directory_size_bytes=(
|
|
||||||
analysis.downloads_directory_size_bytes
|
|
||||||
),
|
|
||||||
cache_directory_size_bytes=(
|
|
||||||
analysis.cache_directory_size_bytes
|
|
||||||
),
|
|
||||||
logs_directory_size_bytes=(
|
|
||||||
analysis.logs_directory_size_bytes
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to get storage analysis: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/performance",
|
|
||||||
response_model=PerformanceReportResponse
|
|
||||||
)
|
|
||||||
async def get_performance_report(
|
|
||||||
hours: int = 24,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> PerformanceReportResponse:
|
|
||||||
"""Get performance metrics for specified period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hours: Number of hours to analyze (default: 24)
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Performance metrics including speeds and system usage
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_analytics_service()
|
|
||||||
report = await service.get_performance_report(db, hours=hours)
|
|
||||||
|
|
||||||
return PerformanceReportResponse(
|
|
||||||
period_start=report.period_start,
|
|
||||||
period_end=report.period_end,
|
|
||||||
downloads_per_hour=report.downloads_per_hour,
|
|
||||||
average_queue_size=report.average_queue_size,
|
|
||||||
peak_memory_usage_mb=report.peak_memory_usage_mb,
|
|
||||||
average_cpu_percent=report.average_cpu_percent,
|
|
||||||
uptime_seconds=report.uptime_seconds,
|
|
||||||
error_rate=report.error_rate,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to get performance report: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=SummaryReportResponse)
|
|
||||||
async def get_summary_report(
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> SummaryReportResponse:
|
|
||||||
"""Get comprehensive analytics summary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete analytics report with all metrics
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = get_analytics_service()
|
|
||||||
summary = await service.generate_summary_report(db)
|
|
||||||
|
|
||||||
return SummaryReportResponse(
|
|
||||||
timestamp=summary["timestamp"],
|
|
||||||
download_stats=DownloadStatsResponse(
|
|
||||||
**summary["download_stats"]
|
|
||||||
),
|
|
||||||
series_popularity=[
|
|
||||||
SeriesPopularityResponse(**p)
|
|
||||||
for p in summary["series_popularity"]
|
|
||||||
],
|
|
||||||
storage_analysis=StorageAnalysisResponse(
|
|
||||||
**summary["storage_analysis"]
|
|
||||||
),
|
|
||||||
performance_report=PerformanceReportResponse(
|
|
||||||
**summary["performance_report"]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to generate summary report: {str(e)}",
|
|
||||||
)
|
|
||||||
@ -1,13 +1,10 @@
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.server.utils.dependencies import (
|
from src.core.entities.series import Serie
|
||||||
get_optional_series_app,
|
from src.server.utils.dependencies import get_series_app, require_auth
|
||||||
get_series_app,
|
|
||||||
require_auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
||||||
|
|
||||||
@ -30,12 +27,15 @@ async def get_anime_status(
|
|||||||
HTTPException: If status retrieval fails
|
HTTPException: If status retrieval fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
directory = getattr(series_app, "directory", "") if series_app else ""
|
directory = (
|
||||||
|
getattr(series_app, "directory_to_search", "")
|
||||||
|
if series_app else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Get series count
|
# Get series count
|
||||||
series_count = 0
|
series_count = 0
|
||||||
if series_app and hasattr(series_app, "List"):
|
if series_app and hasattr(series_app, "list"):
|
||||||
series = series_app.List.GetList()
|
series = series_app.list.GetList()
|
||||||
series_count = len(series) if series else 0
|
series_count = len(series) if series else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -49,51 +49,6 @@ async def get_anime_status(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/process/locks")
|
|
||||||
async def get_process_locks(
|
|
||||||
_auth: dict = Depends(require_auth),
|
|
||||||
series_app: Any = Depends(get_series_app),
|
|
||||||
) -> dict:
|
|
||||||
"""Get process lock status for rescan and download operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_auth: Ensures the caller is authenticated (value unused)
|
|
||||||
series_app: Core `SeriesApp` instance provided via dependency
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: Lock status information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If lock status retrieval fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
locks = {
|
|
||||||
"rescan": {"is_locked": False},
|
|
||||||
"download": {"is_locked": False}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if SeriesApp has lock status methods
|
|
||||||
if series_app:
|
|
||||||
if hasattr(series_app, "isRescanning"):
|
|
||||||
locks["rescan"]["is_locked"] = series_app.isRescanning()
|
|
||||||
if hasattr(series_app, "isDownloading"):
|
|
||||||
locks["download"]["is_locked"] = series_app.isDownloading()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"locks": locks
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": str(exc),
|
|
||||||
"locks": {
|
|
||||||
"rescan": {"is_locked": False},
|
|
||||||
"download": {"is_locked": False}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSummary(BaseModel):
|
class AnimeSummary(BaseModel):
|
||||||
"""Summary of an anime series with missing episodes."""
|
"""Summary of an anime series with missing episodes."""
|
||||||
key: str # Unique identifier (used as id in frontend)
|
key: str # Unique identifier (used as id in frontend)
|
||||||
@ -101,6 +56,7 @@ class AnimeSummary(BaseModel):
|
|||||||
site: str # Provider site
|
site: str # Provider site
|
||||||
folder: str # Local folder name
|
folder: str # Local folder name
|
||||||
missing_episodes: dict # Episode dictionary: {season: [episode_numbers]}
|
missing_episodes: dict # Episode dictionary: {season: [episode_numbers]}
|
||||||
|
link: Optional[str] = "" # Link to the series page (for adding new series)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Pydantic model configuration."""
|
"""Pydantic model configuration."""
|
||||||
@ -110,7 +66,8 @@ class AnimeSummary(BaseModel):
|
|||||||
"name": "Beheneko",
|
"name": "Beheneko",
|
||||||
"site": "aniworld.to",
|
"site": "aniworld.to",
|
||||||
"folder": "beheneko the elf girls cat (2025)",
|
"folder": "beheneko the elf girls cat (2025)",
|
||||||
"missing_episodes": {"1": [1, 2, 3, 4]}
|
"missing_episodes": {"1": [1, 2, 3, 4]},
|
||||||
|
"link": "https://aniworld.to/anime/stream/beheneko"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,10 +169,10 @@ async def list_anime(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get missing episodes from series app
|
# Get missing episodes from series app
|
||||||
if not hasattr(series_app, "List"):
|
if not hasattr(series_app, "list"):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
series = series_app.List.GetMissingEpisode()
|
series = series_app.list.GetMissingEpisode()
|
||||||
summaries: List[AnimeSummary] = []
|
summaries: List[AnimeSummary] = []
|
||||||
for serie in series:
|
for serie in series:
|
||||||
# Get all properties from the serie object
|
# Get all properties from the serie object
|
||||||
@ -338,12 +295,6 @@ class AddSeriesRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class DownloadFoldersRequest(BaseModel):
|
|
||||||
"""Request model for downloading missing episodes from folders."""
|
|
||||||
|
|
||||||
folders: List[str]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_search_query(query: str) -> str:
|
def validate_search_query(query: str) -> str:
|
||||||
"""Validate and sanitize search query.
|
"""Validate and sanitize search query.
|
||||||
|
|
||||||
@ -397,17 +348,17 @@ def validate_search_query(query: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class SearchAnimeRequest(BaseModel):
|
||||||
|
"""Request model for searching anime."""
|
||||||
|
query: str = Field(..., min_length=1, description="Search query string")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=List[AnimeSummary])
|
@router.get("/search", response_model=List[AnimeSummary])
|
||||||
@router.post(
|
async def search_anime_get(
|
||||||
"/search",
|
|
||||||
response_model=List[AnimeSummary],
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
async def search_anime(
|
|
||||||
query: str,
|
query: str,
|
||||||
series_app: Optional[Any] = Depends(get_optional_series_app),
|
series_app: Optional[Any] = Depends(get_series_app),
|
||||||
) -> List[AnimeSummary]:
|
) -> List[AnimeSummary]:
|
||||||
"""Search the provider for additional series matching a query.
|
"""Search the provider for additional series matching a query (GET).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search term passed as query parameter
|
query: Search term passed as query parameter
|
||||||
@ -418,9 +369,48 @@ async def search_anime(
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: When provider communication fails or query is invalid.
|
HTTPException: When provider communication fails or query is invalid.
|
||||||
|
"""
|
||||||
Note: Authentication removed for input validation testing.
|
return await _perform_search(query, series_app)
|
||||||
Note: POST method added for compatibility with security tests.
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/search",
|
||||||
|
response_model=List[AnimeSummary],
|
||||||
|
)
|
||||||
|
async def search_anime_post(
|
||||||
|
request: SearchAnimeRequest,
|
||||||
|
series_app: Optional[Any] = Depends(get_series_app),
|
||||||
|
) -> List[AnimeSummary]:
|
||||||
|
"""Search the provider for additional series matching a query (POST).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Request containing the search query
|
||||||
|
series_app: Optional SeriesApp instance provided via dependency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[AnimeSummary]: Discovered matches returned from the provider.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: When provider communication fails or query is invalid.
|
||||||
|
"""
|
||||||
|
return await _perform_search(request.query, series_app)
|
||||||
|
|
||||||
|
|
||||||
|
async def _perform_search(
|
||||||
|
query: str,
|
||||||
|
series_app: Optional[Any],
|
||||||
|
) -> List[AnimeSummary]:
|
||||||
|
"""Internal function to perform the search logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term
|
||||||
|
series_app: Optional SeriesApp instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[AnimeSummary]: Discovered matches returned from the provider.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: When provider communication fails or query is invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate and sanitize the query
|
# Validate and sanitize the query
|
||||||
@ -444,6 +434,7 @@ async def search_anime(
|
|||||||
title = match.get("title") or match.get("name") or ""
|
title = match.get("title") or match.get("name") or ""
|
||||||
site = match.get("site") or ""
|
site = match.get("site") or ""
|
||||||
folder = match.get("folder") or ""
|
folder = match.get("folder") or ""
|
||||||
|
link = match.get("link") or match.get("url") or ""
|
||||||
missing = (
|
missing = (
|
||||||
match.get("missing_episodes")
|
match.get("missing_episodes")
|
||||||
or match.get("missing")
|
or match.get("missing")
|
||||||
@ -454,6 +445,7 @@ async def search_anime(
|
|||||||
title = getattr(match, "title", getattr(match, "name", ""))
|
title = getattr(match, "title", getattr(match, "name", ""))
|
||||||
site = getattr(match, "site", "")
|
site = getattr(match, "site", "")
|
||||||
folder = getattr(match, "folder", "")
|
folder = getattr(match, "folder", "")
|
||||||
|
link = getattr(match, "link", getattr(match, "url", ""))
|
||||||
missing = getattr(match, "missing_episodes", {})
|
missing = getattr(match, "missing_episodes", {})
|
||||||
|
|
||||||
summaries.append(
|
summaries.append(
|
||||||
@ -462,6 +454,7 @@ async def search_anime(
|
|||||||
name=title,
|
name=title,
|
||||||
site=site,
|
site=site,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
link=link,
|
||||||
missing_episodes=missing,
|
missing_episodes=missing,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -496,24 +489,50 @@ async def add_series(
|
|||||||
HTTPException: If adding the series fails
|
HTTPException: If adding the series fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not hasattr(series_app, "AddSeries"):
|
# Validate inputs
|
||||||
raise HTTPException(
|
if not request.link or not request.link.strip():
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="Add series functionality not available",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = series_app.AddSeries(request.link, request.name)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"Successfully added series: {request.name}"
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Failed to add series - series may already exist",
|
detail="Series link cannot be empty",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not request.name or not request.name.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Series name cannot be empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if series_app has the list attribute
|
||||||
|
if not hasattr(series_app, "list"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Series list functionality not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new Serie object
|
||||||
|
# Following the pattern from CLI:
|
||||||
|
# Serie(key, name, site, folder, episodeDict)
|
||||||
|
# The key and folder are both the link in this case
|
||||||
|
# episodeDict is empty {} for a new series
|
||||||
|
serie = Serie(
|
||||||
|
key=request.link.strip(),
|
||||||
|
name=request.name.strip(),
|
||||||
|
site="aniworld.to",
|
||||||
|
folder=request.name.strip(),
|
||||||
|
episodeDict={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the series to the list
|
||||||
|
series_app.list.add(serie)
|
||||||
|
|
||||||
|
# Refresh the series list to update the cache
|
||||||
|
if hasattr(series_app, "refresh_series_list"):
|
||||||
|
series_app.refresh_series_list()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Successfully added series: {request.name}"
|
||||||
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -523,52 +542,10 @@ async def add_series(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/download")
|
|
||||||
async def download_folders(
|
|
||||||
request: DownloadFoldersRequest,
|
|
||||||
_auth: dict = Depends(require_auth),
|
|
||||||
series_app: Any = Depends(get_series_app),
|
|
||||||
) -> dict:
|
|
||||||
"""Start downloading missing episodes from the specified folders.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Request containing list of folder names
|
|
||||||
_auth: Ensures the caller is authenticated (value unused)
|
|
||||||
series_app: Core `SeriesApp` instance provided via dependency
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict[str, Any]: Status payload with success message
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If download initiation fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not hasattr(series_app, "Download"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="Download functionality not available",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call Download with the folders and a no-op callback
|
|
||||||
series_app.Download(request.folders, lambda *args, **kwargs: None)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"Download started for {len(request.folders)} series"
|
|
||||||
}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to start download: {str(exc)}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{anime_id}", response_model=AnimeDetail)
|
@router.get("/{anime_id}", response_model=AnimeDetail)
|
||||||
async def get_anime(
|
async def get_anime(
|
||||||
anime_id: str,
|
anime_id: str,
|
||||||
series_app: Optional[Any] = Depends(get_optional_series_app)
|
series_app: Optional[Any] = Depends(get_series_app)
|
||||||
) -> AnimeDetail:
|
) -> AnimeDetail:
|
||||||
"""Return detailed information about a specific series.
|
"""Return detailed information about a specific series.
|
||||||
|
|
||||||
@ -584,13 +561,13 @@ async def get_anime(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if series_app is available
|
# Check if series_app is available
|
||||||
if not series_app or not hasattr(series_app, "List"):
|
if not series_app or not hasattr(series_app, "list"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Series not found",
|
detail="Series not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
series = series_app.List.GetList()
|
series = series_app.list.GetList()
|
||||||
found = None
|
found = None
|
||||||
for serie in series:
|
for serie in series:
|
||||||
matches_key = getattr(serie, "key", None) == anime_id
|
matches_key = getattr(serie, "key", None) == anime_id
|
||||||
@ -626,47 +603,6 @@ async def get_anime(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
# Test endpoint for input validation
|
|
||||||
class AnimeCreateRequest(BaseModel):
|
|
||||||
"""Request model for creating anime (test endpoint)."""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum allowed input size for security
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|
||||||
@router.post("", include_in_schema=False, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_anime_test(request: AnimeCreateRequest):
|
|
||||||
"""Test endpoint for input validation testing.
|
|
||||||
|
|
||||||
This endpoint validates input sizes and content for security testing.
|
|
||||||
Not used in production - only for validation tests.
|
|
||||||
"""
|
|
||||||
# Validate input size
|
|
||||||
if len(request.title) > MAX_INPUT_LENGTH:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
||||||
detail="Title exceeds maximum allowed length",
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.description and len(request.description) > MAX_INPUT_LENGTH:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
||||||
detail="Description exceeds maximum allowed length",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return success for valid input
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Anime created (test mode)",
|
|
||||||
"data": {
|
|
||||||
"title": request.title[:100], # Truncated for response
|
|
||||||
"description": (
|
|
||||||
request.description[:100] if request.description else None
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,304 +0,0 @@
|
|||||||
"""Backup management API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from src.server.services.backup_service import BackupService, get_backup_service
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/backup", tags=["backup"])
|
|
||||||
|
|
||||||
|
|
||||||
class BackupCreateRequest(BaseModel):
|
|
||||||
"""Request to create a backup."""
|
|
||||||
|
|
||||||
backup_type: str # 'config', 'database', 'full'
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BackupResponse(BaseModel):
|
|
||||||
"""Response for backup creation."""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
backup_name: Optional[str] = None
|
|
||||||
size_bytes: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BackupListResponse(BaseModel):
|
|
||||||
"""Response for listing backups."""
|
|
||||||
|
|
||||||
backups: List[Dict[str, Any]]
|
|
||||||
total_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class RestoreRequest(BaseModel):
|
|
||||||
"""Request to restore from backup."""
|
|
||||||
|
|
||||||
backup_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class RestoreResponse(BaseModel):
|
|
||||||
"""Response for restore operation."""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
def get_backup_service_dep() -> BackupService:
|
|
||||||
"""Dependency to get backup service."""
|
|
||||||
return get_backup_service()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create", response_model=BackupResponse)
|
|
||||||
async def create_backup(
|
|
||||||
request: BackupCreateRequest,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> BackupResponse:
|
|
||||||
"""Create a new backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Backup creation request.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupResponse: Result of backup creation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backup_info = None
|
|
||||||
|
|
||||||
if request.backup_type == "config":
|
|
||||||
backup_info = backup_service.backup_configuration(
|
|
||||||
request.description or ""
|
|
||||||
)
|
|
||||||
elif request.backup_type == "database":
|
|
||||||
backup_info = backup_service.backup_database(
|
|
||||||
request.description or ""
|
|
||||||
)
|
|
||||||
elif request.backup_type == "full":
|
|
||||||
backup_info = backup_service.backup_full(
|
|
||||||
request.description or ""
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Invalid backup type: {request.backup_type}")
|
|
||||||
|
|
||||||
if backup_info is None:
|
|
||||||
return BackupResponse(
|
|
||||||
success=False,
|
|
||||||
message=f"Failed to create {request.backup_type} backup",
|
|
||||||
)
|
|
||||||
|
|
||||||
return BackupResponse(
|
|
||||||
success=True,
|
|
||||||
message=(
|
|
||||||
f"{request.backup_type.capitalize()} backup created "
|
|
||||||
"successfully"
|
|
||||||
),
|
|
||||||
backup_name=backup_info.name,
|
|
||||||
size_bytes=backup_info.size_bytes,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create backup: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=BackupListResponse)
|
|
||||||
async def list_backups(
|
|
||||||
backup_type: Optional[str] = None,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> BackupListResponse:
|
|
||||||
"""List available backups.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_type: Optional filter by backup type.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupListResponse: List of available backups.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backups = backup_service.list_backups(backup_type)
|
|
||||||
return BackupListResponse(backups=backups, total_count=len(backups))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list backups: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restore", response_model=RestoreResponse)
|
|
||||||
async def restore_backup(
|
|
||||||
request: RestoreRequest,
|
|
||||||
backup_type: Optional[str] = None,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> RestoreResponse:
|
|
||||||
"""Restore from a backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Restore request.
|
|
||||||
backup_type: Type of backup to restore.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RestoreResponse: Result of restore operation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Determine backup type from filename if not provided
|
|
||||||
if backup_type is None:
|
|
||||||
if "config" in request.backup_name:
|
|
||||||
backup_type = "config"
|
|
||||||
elif "database" in request.backup_name:
|
|
||||||
backup_type = "database"
|
|
||||||
else:
|
|
||||||
backup_type = "full"
|
|
||||||
|
|
||||||
success = False
|
|
||||||
|
|
||||||
if backup_type == "config":
|
|
||||||
success = backup_service.restore_configuration(
|
|
||||||
request.backup_name
|
|
||||||
)
|
|
||||||
elif backup_type == "database":
|
|
||||||
success = backup_service.restore_database(request.backup_name)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Cannot restore backup type: {backup_type}")
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
return RestoreResponse(
|
|
||||||
success=False,
|
|
||||||
message=f"Failed to restore {backup_type} backup",
|
|
||||||
)
|
|
||||||
|
|
||||||
return RestoreResponse(
|
|
||||||
success=True,
|
|
||||||
message=f"{backup_type.capitalize()} backup restored successfully",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to restore backup: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{backup_name}", response_model=Dict[str, Any])
|
|
||||||
async def delete_backup(
|
|
||||||
backup_name: str,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Delete a backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_name: Name of the backup to delete.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Result of delete operation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
success = backup_service.delete_backup(backup_name)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="Backup not found")
|
|
||||||
|
|
||||||
return {"success": True, "message": "Backup deleted successfully"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete backup: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup", response_model=Dict[str, Any])
|
|
||||||
async def cleanup_backups(
|
|
||||||
max_backups: int = 10,
|
|
||||||
backup_type: Optional[str] = None,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Clean up old backups.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_backups: Maximum number of backups to keep.
|
|
||||||
backup_type: Optional filter by backup type.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Number of backups deleted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deleted_count = backup_service.cleanup_old_backups(
|
|
||||||
max_backups, backup_type
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Cleanup completed",
|
|
||||||
"deleted_count": deleted_count,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to cleanup backups: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/export/anime", response_model=Dict[str, Any])
|
|
||||||
async def export_anime_data(
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Export anime library data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Result of export operation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
output_file = "data/backups/anime_export.json"
|
|
||||||
success = backup_service.export_anime_data(output_file)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail="Failed to export anime data"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Anime data exported successfully",
|
|
||||||
"export_file": output_file,
|
|
||||||
}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to export anime data: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/anime", response_model=Dict[str, Any])
|
|
||||||
async def import_anime_data(
|
|
||||||
import_file: str,
|
|
||||||
backup_service: BackupService = Depends(get_backup_service_dep),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Import anime library data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
import_file: Path to import file.
|
|
||||||
backup_service: Backup service dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Result of import operation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
success = backup_service.import_anime_data(import_file)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="Failed to import anime data"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Anime data imported successfully",
|
|
||||||
}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to import anime data: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
"""Diagnostics API endpoints for Aniworld.
|
|
||||||
|
|
||||||
This module provides endpoints for system diagnostics and health checks.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from src.server.utils.dependencies import require_auth
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkTestResult(BaseModel):
|
|
||||||
"""Result of a network connectivity test."""
|
|
||||||
|
|
||||||
host: str = Field(..., description="Hostname or URL tested")
|
|
||||||
reachable: bool = Field(..., description="Whether host is reachable")
|
|
||||||
response_time_ms: Optional[float] = Field(
|
|
||||||
None, description="Response time in milliseconds"
|
|
||||||
)
|
|
||||||
error: Optional[str] = Field(None, description="Error message if failed")
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkDiagnostics(BaseModel):
|
|
||||||
"""Network diagnostics results."""
|
|
||||||
|
|
||||||
internet_connected: bool = Field(
|
|
||||||
..., description="Overall internet connectivity status"
|
|
||||||
)
|
|
||||||
dns_working: bool = Field(..., description="DNS resolution status")
|
|
||||||
aniworld_reachable: bool = Field(
|
|
||||||
..., description="Aniworld.to connectivity status"
|
|
||||||
)
|
|
||||||
tests: List[NetworkTestResult] = Field(
|
|
||||||
..., description="Individual network tests"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_dns() -> bool:
|
|
||||||
"""Check if DNS resolution is working.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if DNS is working
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
socket.gethostbyname("google.com")
|
|
||||||
return True
|
|
||||||
except socket.gaierror:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def check_host_connectivity(
|
|
||||||
host: str, port: int = 80, timeout: float = 5.0
|
|
||||||
) -> NetworkTestResult:
|
|
||||||
"""Test connectivity to a specific host.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
host: Hostname or IP address to test
|
|
||||||
port: Port to test (default: 80)
|
|
||||||
timeout: Timeout in seconds (default: 5.0)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
NetworkTestResult with test results
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to establish a connection
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
lambda: socket.create_connection(
|
|
||||||
(host, port), timeout=timeout
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
response_time = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
return NetworkTestResult(
|
|
||||||
host=host,
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=round(response_time, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return NetworkTestResult(
|
|
||||||
host=host, reachable=False, error="Connection timeout"
|
|
||||||
)
|
|
||||||
except socket.gaierror as e:
|
|
||||||
return NetworkTestResult(
|
|
||||||
host=host, reachable=False, error=f"DNS resolution failed: {e}"
|
|
||||||
)
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
return NetworkTestResult(
|
|
||||||
host=host, reachable=False, error="Connection refused"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return NetworkTestResult(
|
|
||||||
host=host, reachable=False, error=f"Connection error: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/network")
|
|
||||||
async def network_diagnostics(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict:
|
|
||||||
"""Run network connectivity diagnostics.
|
|
||||||
|
|
||||||
Tests DNS resolution and connectivity to common services including
|
|
||||||
aniworld.to.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with status and diagnostics data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If diagnostics fail
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info("Running network diagnostics")
|
|
||||||
|
|
||||||
# Check DNS
|
|
||||||
dns_working = await check_dns()
|
|
||||||
|
|
||||||
# Test connectivity to various hosts including aniworld.to
|
|
||||||
test_hosts = [
|
|
||||||
("google.com", 80),
|
|
||||||
("cloudflare.com", 80),
|
|
||||||
("github.com", 443),
|
|
||||||
("aniworld.to", 443),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Run all tests concurrently
|
|
||||||
test_tasks = [
|
|
||||||
check_host_connectivity(host, port) for host, port in test_hosts
|
|
||||||
]
|
|
||||||
test_results = await asyncio.gather(*test_tasks)
|
|
||||||
|
|
||||||
# Determine overall internet connectivity
|
|
||||||
internet_connected = any(result.reachable for result in test_results)
|
|
||||||
|
|
||||||
# Check if aniworld.to is reachable
|
|
||||||
aniworld_result = next(
|
|
||||||
(r for r in test_results if r.host == "aniworld.to"),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
aniworld_reachable = (
|
|
||||||
aniworld_result.reachable if aniworld_result else False
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Network diagnostics complete: "
|
|
||||||
f"DNS={dns_working}, Internet={internet_connected}, "
|
|
||||||
f"Aniworld={aniworld_reachable}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create diagnostics data
|
|
||||||
diagnostics_data = NetworkDiagnostics(
|
|
||||||
internet_connected=internet_connected,
|
|
||||||
dns_working=dns_working,
|
|
||||||
aniworld_reachable=aniworld_reachable,
|
|
||||||
tests=test_results,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return in standard format expected by frontend
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"data": diagnostics_data.model_dump(),
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to run network diagnostics")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to run network diagnostics: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system", response_model=Dict[str, str])
|
|
||||||
async def system_info(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Get basic system information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with system information
|
|
||||||
"""
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
|
|
||||||
return {
|
|
||||||
"platform": platform.platform(),
|
|
||||||
"python_version": sys.version,
|
|
||||||
"architecture": platform.machine(),
|
|
||||||
"processor": platform.processor(),
|
|
||||||
"hostname": socket.gethostname(),
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@ from fastapi.responses import JSONResponse
|
|||||||
from src.server.models.download import (
|
from src.server.models.download import (
|
||||||
DownloadRequest,
|
DownloadRequest,
|
||||||
QueueOperationRequest,
|
QueueOperationRequest,
|
||||||
QueueReorderRequest,
|
|
||||||
QueueStatusResponse,
|
QueueStatusResponse,
|
||||||
)
|
)
|
||||||
from src.server.services.download_service import DownloadService, DownloadServiceError
|
from src.server.services.download_service import DownloadService, DownloadServiceError
|
||||||
@ -18,9 +17,6 @@ from src.server.utils.dependencies import get_download_service, require_auth
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/queue", tags=["download"])
|
router = APIRouter(prefix="/api/queue", tags=["download"])
|
||||||
|
|
||||||
# Secondary router for test compatibility (no prefix)
|
|
||||||
downloads_router = APIRouter(prefix="/api", tags=["download"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=QueueStatusResponse)
|
@router.get("/status", response_model=QueueStatusResponse)
|
||||||
async def get_queue_status(
|
async def get_queue_status(
|
||||||
@ -47,39 +43,27 @@ async def get_queue_status(
|
|||||||
queue_status = await download_service.get_queue_status()
|
queue_status = await download_service.get_queue_status()
|
||||||
queue_stats = await download_service.get_queue_stats()
|
queue_stats = await download_service.get_queue_stats()
|
||||||
|
|
||||||
# Preserve the legacy response contract expected by the original CLI
|
# Build response with field names expected by frontend
|
||||||
# client and existing integration tests. Those consumers still parse
|
# Frontend expects top-level arrays (active_downloads, pending_queue, etc.)
|
||||||
# the bare dictionaries that the pre-FastAPI implementation emitted,
|
# not nested under a 'status' object
|
||||||
# so we keep the canonical field names (``active``/``pending``/
|
active_downloads = [
|
||||||
# ``completed``/``failed``) and dump each Pydantic object to plain
|
it.model_dump(mode="json")
|
||||||
# JSON-compatible dicts instead of returning the richer
|
for it in queue_status.active_downloads
|
||||||
# ``QueueStatusResponse`` shape directly. This guarantees both the
|
]
|
||||||
# CLI and older dashboard widgets do not need schema migrations while
|
pending_queue = [
|
||||||
# the new web UI can continue to evolve independently.
|
it.model_dump(mode="json")
|
||||||
status_payload = {
|
for it in queue_status.pending_queue
|
||||||
"is_running": queue_status.is_running,
|
]
|
||||||
"is_paused": queue_status.is_paused,
|
completed_downloads = [
|
||||||
"active": [
|
it.model_dump(mode="json")
|
||||||
it.model_dump(mode="json")
|
for it in queue_status.completed_downloads
|
||||||
for it in queue_status.active_downloads
|
]
|
||||||
],
|
failed_downloads = [
|
||||||
"pending": [
|
it.model_dump(mode="json")
|
||||||
it.model_dump(mode="json")
|
for it in queue_status.failed_downloads
|
||||||
for it in queue_status.pending_queue
|
]
|
||||||
],
|
|
||||||
"completed": [
|
|
||||||
it.model_dump(mode="json")
|
|
||||||
for it in queue_status.completed_downloads
|
|
||||||
],
|
|
||||||
"failed": [
|
|
||||||
it.model_dump(mode="json")
|
|
||||||
for it in queue_status.failed_downloads
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add the derived ``success_rate`` metric so dashboards built against
|
# Calculate success rate
|
||||||
# the previous API continue to function without recalculating it
|
|
||||||
# client-side.
|
|
||||||
completed = queue_stats.completed_count
|
completed = queue_stats.completed_count
|
||||||
failed = queue_stats.failed_count
|
failed = queue_stats.failed_count
|
||||||
success_rate = None
|
success_rate = None
|
||||||
@ -91,7 +75,12 @@ async def get_queue_status(
|
|||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"status": status_payload,
|
"is_running": queue_status.is_running,
|
||||||
|
"is_paused": queue_status.is_paused,
|
||||||
|
"active_downloads": active_downloads,
|
||||||
|
"pending_queue": pending_queue,
|
||||||
|
"completed_downloads": completed_downloads,
|
||||||
|
"failed_downloads": failed_downloads,
|
||||||
"statistics": stats_payload,
|
"statistics": stats_payload,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -139,6 +128,7 @@ async def add_to_queue(
|
|||||||
# Add to queue
|
# Add to queue
|
||||||
added_ids = await download_service.add_to_queue(
|
added_ids = await download_service.add_to_queue(
|
||||||
serie_id=request.serie_id,
|
serie_id=request.serie_id,
|
||||||
|
serie_folder=request.serie_folder,
|
||||||
serie_name=request.serie_name,
|
serie_name=request.serie_name,
|
||||||
episodes=request.episodes,
|
episodes=request.episodes,
|
||||||
priority=request.priority,
|
priority=request.priority,
|
||||||
@ -208,6 +198,74 @@ async def clear_completed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/failed", status_code=status.HTTP_200_OK)
|
||||||
|
async def clear_failed(
|
||||||
|
_: dict = Depends(require_auth),
|
||||||
|
download_service: DownloadService = Depends(get_download_service),
|
||||||
|
):
|
||||||
|
"""Clear failed downloads from history.
|
||||||
|
|
||||||
|
Removes all failed download items from the queue history. This helps
|
||||||
|
keep the queue display clean and manageable.
|
||||||
|
|
||||||
|
Requires authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Status message with count of cleared items
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 401 if not authenticated, 500 on service error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cleared_count = await download_service.clear_failed()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Cleared {cleared_count} failed item(s)",
|
||||||
|
"count": cleared_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to clear failed items: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pending", status_code=status.HTTP_200_OK)
|
||||||
|
async def clear_pending(
|
||||||
|
_: dict = Depends(require_auth),
|
||||||
|
download_service: DownloadService = Depends(get_download_service),
|
||||||
|
):
|
||||||
|
"""Clear all pending downloads from the queue.
|
||||||
|
|
||||||
|
Removes all pending download items from the queue. This is useful for
|
||||||
|
clearing the entire queue at once instead of removing items one by one.
|
||||||
|
|
||||||
|
Requires authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Status message with count of cleared items
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 401 if not authenticated, 500 on service error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cleared_count = await download_service.clear_pending()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Removed {cleared_count} pending item(s)",
|
||||||
|
"count": cleared_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to clear pending items: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def remove_from_queue(
|
async def remove_from_queue(
|
||||||
item_id: str = Path(..., description="Download item ID to remove"),
|
item_id: str = Path(..., description="Download item ID to remove"),
|
||||||
@ -252,39 +310,44 @@ async def remove_from_queue(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/start", status_code=status.HTTP_200_OK)
|
||||||
async def remove_multiple_from_queue(
|
async def start_queue(
|
||||||
request: QueueOperationRequest,
|
|
||||||
_: dict = Depends(require_auth),
|
_: dict = Depends(require_auth),
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
download_service: DownloadService = Depends(get_download_service),
|
||||||
):
|
):
|
||||||
"""Remove multiple items from the download queue.
|
"""Start automatic queue processing.
|
||||||
|
|
||||||
Batch removal of multiple download items. Each item is processed
|
Starts processing all pending downloads sequentially, one at a time.
|
||||||
individually, and the operation continues even if some items are not
|
The queue will continue processing until all items are complete or
|
||||||
found.
|
the queue is manually stopped. Processing continues even if the browser
|
||||||
|
is closed.
|
||||||
|
|
||||||
|
Only one download can be active at a time. If a download is already
|
||||||
|
active or queue processing is running, an error is returned.
|
||||||
|
|
||||||
Requires authentication.
|
Requires authentication.
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
request: List of download item IDs to remove
|
dict: Status message confirming queue processing started
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if not authenticated, 400 for invalid request,
|
HTTPException: 401 if not authenticated, 400 if queue is empty or
|
||||||
500 on service error
|
processing already active, 500 on service error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not request.item_ids:
|
result = await download_service.start_queue_processing()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="At least one item ID must be specified",
|
detail="No pending downloads in queue",
|
||||||
)
|
)
|
||||||
|
|
||||||
await download_service.remove_from_queue(request.item_ids)
|
return {
|
||||||
|
"status": "success",
|
||||||
# Note: We don't raise 404 if some items weren't found, as this is
|
"message": "Queue processing started",
|
||||||
# a batch operation and partial success is acceptable
|
}
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
except DownloadServiceError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -295,41 +358,7 @@ async def remove_multiple_from_queue(
|
|||||||
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 remove items from queue: {str(e)}",
|
detail=f"Failed to start queue processing: {str(e)}",
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", status_code=status.HTTP_200_OK)
|
|
||||||
async def start_queue(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
"""Start the download queue processor.
|
|
||||||
|
|
||||||
Starts processing the download queue. Downloads will be processed according
|
|
||||||
to priority and concurrency limits. If the queue is already running, this
|
|
||||||
operation is idempotent.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been started
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.start()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue processing started",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to start download queue: {str(e)}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -338,230 +367,34 @@ async def stop_queue(
|
|||||||
_: dict = Depends(require_auth),
|
_: dict = Depends(require_auth),
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
download_service: DownloadService = Depends(get_download_service),
|
||||||
):
|
):
|
||||||
"""Stop the download queue processor.
|
"""Stop processing new downloads from queue.
|
||||||
|
|
||||||
Stops processing the download queue. Active downloads will be allowed to
|
Prevents new downloads from starting. The current active download will
|
||||||
complete (with a timeout), then the queue processor will shut down.
|
continue to completion, but no new downloads will be started from the
|
||||||
Queue state is persisted before shutdown.
|
pending queue.
|
||||||
|
|
||||||
Requires authentication.
|
Requires authentication.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Status message indicating queue has been stopped
|
dict: Status message indicating queue processing has been stopped
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
HTTPException: 401 if not authenticated, 500 on service error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await download_service.stop()
|
await download_service.stop_downloads()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Download queue processing stopped",
|
"message": (
|
||||||
|
"Queue processing stopped (current download will continue)"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
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 stop download queue: {str(e)}",
|
detail=f"Failed to stop queue processing: {str(e)}",
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause", status_code=status.HTTP_200_OK)
|
|
||||||
async def pause_queue(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
"""Pause the download queue processor.
|
|
||||||
|
|
||||||
Pauses download processing. Active downloads will continue, but no new
|
|
||||||
downloads will be started until the queue is resumed.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been paused
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.pause_queue()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue paused",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to pause download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume", status_code=status.HTTP_200_OK)
|
|
||||||
async def resume_queue(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
"""Resume the download queue processor.
|
|
||||||
|
|
||||||
Resumes download processing after being paused. The queue will continue
|
|
||||||
processing pending items according to priority.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been resumed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.resume_queue()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue resumed",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to resume download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Backwards-compatible control endpoints (some integration tests and older
|
|
||||||
# clients call `/api/queue/control/<action>`). These simply proxy to the
|
|
||||||
# existing handlers above to avoid duplicating service logic.
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/start", status_code=status.HTTP_200_OK)
|
|
||||||
async def control_start(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
return await start_queue(_, download_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/stop", status_code=status.HTTP_200_OK)
|
|
||||||
async def control_stop(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
return await stop_queue(_, download_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/pause", status_code=status.HTTP_200_OK)
|
|
||||||
async def control_pause(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
return await pause_queue(_, download_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/resume", status_code=status.HTTP_200_OK)
|
|
||||||
async def control_resume(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
return await resume_queue(_, download_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/control/clear_completed", status_code=status.HTTP_200_OK)
|
|
||||||
async def control_clear_completed(
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
# Call the existing clear_completed implementation which returns a dict
|
|
||||||
return await clear_completed(_, download_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reorder", status_code=status.HTTP_200_OK)
|
|
||||||
async def reorder_queue(
|
|
||||||
request: dict,
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
"""Reorder an item in the pending queue.
|
|
||||||
|
|
||||||
Changes the position of a pending download item in the queue. This only
|
|
||||||
affects items that haven't started downloading yet. The position is
|
|
||||||
0-based.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Item ID and new position in queue
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating item has been reordered
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
|
||||||
400 for invalid request, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Support legacy bulk reorder payload used by some integration tests:
|
|
||||||
# {"item_order": ["id1", "id2", ...]}
|
|
||||||
if "item_order" in request:
|
|
||||||
item_order = request.get("item_order", [])
|
|
||||||
if not isinstance(item_order, list):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="item_order must be a list of item IDs",
|
|
||||||
)
|
|
||||||
|
|
||||||
success = await download_service.reorder_queue_bulk(item_order)
|
|
||||||
else:
|
|
||||||
# Fallback to single-item reorder shape
|
|
||||||
# Validate request
|
|
||||||
try:
|
|
||||||
req = QueueReorderRequest(**request)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
success = await download_service.reorder_queue(
|
|
||||||
item_id=req.item_id,
|
|
||||||
new_position=req.new_position,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# Provide an appropriate 404 message depending on request shape
|
|
||||||
if "item_order" in request:
|
|
||||||
detail = (
|
|
||||||
"One or more items in item_order were not "
|
|
||||||
"found in pending queue"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
detail = f"Item {req.item_id} not found in pending queue"
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=detail,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Queue item reordered successfully",
|
|
||||||
}
|
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to reorder queue item: {str(e)}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -596,6 +429,7 @@ async def retry_failed(
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Retrying {len(retried_ids)} failed item(s)",
|
"message": f"Retrying {len(retried_ids)} failed item(s)",
|
||||||
|
"retried_count": len(retried_ids),
|
||||||
"retried_ids": retried_ids,
|
"retried_ids": retried_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,50 +438,3 @@ async def retry_failed(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to retry downloads: {str(e)}",
|
detail=f"Failed to retry downloads: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Alternative endpoint for compatibility with input validation tests
|
|
||||||
@downloads_router.post(
|
|
||||||
"/downloads",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
include_in_schema=False,
|
|
||||||
)
|
|
||||||
async def add_download_item(
|
|
||||||
request: DownloadRequest,
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
):
|
|
||||||
"""Add item to download queue (alternative endpoint for testing).
|
|
||||||
|
|
||||||
This is an alias for POST /api/queue/add for input validation testing.
|
|
||||||
Uses the same validation logic as the main queue endpoint.
|
|
||||||
Note: Authentication check removed for input validation testing.
|
|
||||||
"""
|
|
||||||
# Validate that values are not negative
|
|
||||||
try:
|
|
||||||
anime_id_val = int(request.anime_id)
|
|
||||||
if anime_id_val < 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="anime_id must be a positive number",
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="anime_id must be a valid number",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate episode numbers if provided
|
|
||||||
if request.episodes:
|
|
||||||
for ep in request.episodes:
|
|
||||||
if ep < 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="Episode numbers must be positive",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download request validated",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,426 +0,0 @@
|
|||||||
"""Logging API endpoints for Aniworld.
|
|
||||||
|
|
||||||
This module provides endpoints for managing application logging
|
|
||||||
configuration and accessing log files.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from src.server.models.config import LoggingConfig
|
|
||||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
|
||||||
from src.server.utils.dependencies import require_auth
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logging", tags=["logging"])
|
|
||||||
|
|
||||||
|
|
||||||
class LogFileInfo(BaseModel):
|
|
||||||
"""Information about a log file."""
|
|
||||||
|
|
||||||
name: str = Field(..., description="File name")
|
|
||||||
size: int = Field(..., description="File size in bytes")
|
|
||||||
modified: float = Field(..., description="Last modified timestamp")
|
|
||||||
path: str = Field(..., description="Relative path from logs directory")
|
|
||||||
|
|
||||||
|
|
||||||
class LogCleanupResult(BaseModel):
|
|
||||||
"""Result of log cleanup operation."""
|
|
||||||
|
|
||||||
files_deleted: int = Field(..., description="Number of files deleted")
|
|
||||||
space_freed: int = Field(..., description="Space freed in bytes")
|
|
||||||
errors: List[str] = Field(
|
|
||||||
default_factory=list, description="Any errors encountered"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_logs_directory() -> Path:
|
|
||||||
"""Get the logs directory path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path: Logs directory path
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If logs directory doesn't exist
|
|
||||||
"""
|
|
||||||
# Check both common locations
|
|
||||||
possible_paths = [
|
|
||||||
Path("logs"),
|
|
||||||
Path("src/cli/logs"),
|
|
||||||
Path("data/logs"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for log_path in possible_paths:
|
|
||||||
if log_path.exists() and log_path.is_dir():
|
|
||||||
return log_path
|
|
||||||
|
|
||||||
# Default to logs directory even if it doesn't exist
|
|
||||||
logs_dir = Path("logs")
|
|
||||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return logs_dir
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=LoggingConfig)
|
|
||||||
def get_logging_config(
|
|
||||||
auth: Optional[dict] = Depends(require_auth)
|
|
||||||
) -> LoggingConfig:
|
|
||||||
"""Get current logging configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional for read operations)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LoggingConfig: Current logging configuration
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If configuration cannot be loaded
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_service = get_config_service()
|
|
||||||
app_config = config_service.load_config()
|
|
||||||
return app_config.logging
|
|
||||||
except ConfigServiceError as e:
|
|
||||||
logger.error(f"Failed to load logging config: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to load logging configuration: {e}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=LoggingConfig)
|
|
||||||
def update_logging_config(
|
|
||||||
logging_config: LoggingConfig,
|
|
||||||
auth: dict = Depends(require_auth),
|
|
||||||
) -> LoggingConfig:
|
|
||||||
"""Update logging configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
logging_config: New logging configuration
|
|
||||||
auth: Authentication token (required)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LoggingConfig: Updated logging configuration
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If configuration update fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_service = get_config_service()
|
|
||||||
app_config = config_service.load_config()
|
|
||||||
|
|
||||||
# Update logging section
|
|
||||||
app_config.logging = logging_config
|
|
||||||
|
|
||||||
# Save and return
|
|
||||||
config_service.save_config(app_config)
|
|
||||||
logger.info(
|
|
||||||
f"Logging config updated by {auth.get('username', 'unknown')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply the new logging configuration
|
|
||||||
_apply_logging_config(logging_config)
|
|
||||||
|
|
||||||
return logging_config
|
|
||||||
except ConfigServiceError as e:
|
|
||||||
logger.error(f"Failed to update logging config: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to update logging configuration: {e}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_logging_config(config: LoggingConfig) -> None:
|
|
||||||
"""Apply logging configuration to the Python logging system.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Logging configuration to apply
|
|
||||||
"""
|
|
||||||
# Set the root logger level
|
|
||||||
logging.getLogger().setLevel(config.level)
|
|
||||||
|
|
||||||
# If a file is specified, configure file handler
|
|
||||||
if config.file:
|
|
||||||
file_path = Path(config.file)
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Remove existing file handlers
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
for handler in root_logger.handlers[:]:
|
|
||||||
if isinstance(handler, logging.FileHandler):
|
|
||||||
root_logger.removeHandler(handler)
|
|
||||||
|
|
||||||
# Add new file handler with rotation if configured
|
|
||||||
if config.max_bytes and config.max_bytes > 0:
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
|
|
||||||
handler = RotatingFileHandler(
|
|
||||||
config.file,
|
|
||||||
maxBytes=config.max_bytes,
|
|
||||||
backupCount=config.backup_count or 3,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
handler = logging.FileHandler(config.file)
|
|
||||||
|
|
||||||
handler.setFormatter(
|
|
||||||
logging.Formatter(
|
|
||||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
root_logger.addHandler(handler)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files", response_model=List[LogFileInfo])
|
|
||||||
def list_log_files(
|
|
||||||
auth: Optional[dict] = Depends(require_auth)
|
|
||||||
) -> List[LogFileInfo]:
|
|
||||||
"""List available log files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional for read operations)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of log file information
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If logs directory cannot be accessed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logs_dir = get_logs_directory()
|
|
||||||
files: List[LogFileInfo] = []
|
|
||||||
|
|
||||||
for file_path in logs_dir.rglob("*.log*"):
|
|
||||||
if file_path.is_file():
|
|
||||||
stat = file_path.stat()
|
|
||||||
rel_path = file_path.relative_to(logs_dir)
|
|
||||||
files.append(
|
|
||||||
LogFileInfo(
|
|
||||||
name=file_path.name,
|
|
||||||
size=stat.st_size,
|
|
||||||
modified=stat.st_mtime,
|
|
||||||
path=str(rel_path),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by modified time, newest first
|
|
||||||
files.sort(key=lambda x: x.modified, reverse=True)
|
|
||||||
return files
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to list log files")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to list log files: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files/{filename:path}/download")
|
|
||||||
async def download_log_file(
|
|
||||||
filename: str, auth: dict = Depends(require_auth)
|
|
||||||
) -> FileResponse:
|
|
||||||
"""Download a specific log file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Name or relative path of the log file
|
|
||||||
auth: Authentication token (required)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
File download response
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If file not found or access denied
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logs_dir = get_logs_directory()
|
|
||||||
file_path = logs_dir / filename
|
|
||||||
|
|
||||||
# Security: Ensure the file is within logs directory
|
|
||||||
if not file_path.resolve().is_relative_to(logs_dir.resolve()):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied to file outside logs directory",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Log file not found: {filename}",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Log file download: {filename} "
|
|
||||||
f"by {auth.get('username', 'unknown')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return FileResponse(
|
|
||||||
path=str(file_path),
|
|
||||||
filename=file_path.name,
|
|
||||||
media_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Failed to download log file: {filename}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to download log file: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files/{filename:path}/tail")
|
|
||||||
async def tail_log_file(
|
|
||||||
filename: str,
|
|
||||||
lines: int = 100,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> PlainTextResponse:
|
|
||||||
"""Get the last N lines of a log file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Name or relative path of the log file
|
|
||||||
lines: Number of lines to retrieve (default: 100)
|
|
||||||
auth: Authentication token (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Plain text response with log file tail
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If file not found or access denied
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logs_dir = get_logs_directory()
|
|
||||||
file_path = logs_dir / filename
|
|
||||||
|
|
||||||
# Security: Ensure the file is within logs directory
|
|
||||||
if not file_path.resolve().is_relative_to(logs_dir.resolve()):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied to file outside logs directory",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Log file not found: {filename}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read the last N lines efficiently
|
|
||||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
||||||
# For small files, just read all
|
|
||||||
content = f.readlines()
|
|
||||||
tail_lines = content[-lines:] if len(content) > lines else content
|
|
||||||
|
|
||||||
return PlainTextResponse(content="".join(tail_lines))
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Failed to tail log file: {filename}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to tail log file: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test", response_model=Dict[str, str])
|
|
||||||
async def test_logging(
|
|
||||||
auth: dict = Depends(require_auth)
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Test logging by writing messages at all levels.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (required)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
test_logger = logging.getLogger("aniworld.test")
|
|
||||||
|
|
||||||
test_logger.debug("Test DEBUG message")
|
|
||||||
test_logger.info("Test INFO message")
|
|
||||||
test_logger.warning("Test WARNING message")
|
|
||||||
test_logger.error("Test ERROR message")
|
|
||||||
test_logger.critical("Test CRITICAL message")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Logging test triggered by {auth.get('username', 'unknown')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Test messages logged at all levels",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to test logging")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to test logging: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup", response_model=LogCleanupResult)
|
|
||||||
async def cleanup_logs(
|
|
||||||
max_age_days: int = 30, auth: dict = Depends(require_auth)
|
|
||||||
) -> LogCleanupResult:
|
|
||||||
"""Clean up old log files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_age_days: Maximum age in days for log files to keep
|
|
||||||
auth: Authentication token (required)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cleanup result with statistics
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If cleanup fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logs_dir = get_logs_directory()
|
|
||||||
current_time = os.path.getmtime(logs_dir)
|
|
||||||
max_age_seconds = max_age_days * 24 * 60 * 60
|
|
||||||
|
|
||||||
files_deleted = 0
|
|
||||||
space_freed = 0
|
|
||||||
errors: List[str] = []
|
|
||||||
|
|
||||||
for file_path in logs_dir.rglob("*.log*"):
|
|
||||||
if not file_path.is_file():
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
file_age = current_time - file_path.stat().st_mtime
|
|
||||||
if file_age > max_age_seconds:
|
|
||||||
file_size = file_path.stat().st_size
|
|
||||||
file_path.unlink()
|
|
||||||
files_deleted += 1
|
|
||||||
space_freed += file_size
|
|
||||||
logger.info(f"Deleted old log file: {file_path.name}")
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Failed to delete {file_path.name}: {str(e)}"
|
|
||||||
errors.append(error_msg)
|
|
||||||
logger.warning(error_msg)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Log cleanup by {auth.get('username', 'unknown')}: "
|
|
||||||
f"{files_deleted} files, {space_freed} bytes"
|
|
||||||
)
|
|
||||||
|
|
||||||
return LogCleanupResult(
|
|
||||||
files_deleted=files_deleted,
|
|
||||||
space_freed=space_freed,
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to cleanup logs")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to cleanup logs: {str(e)}",
|
|
||||||
) from e
|
|
||||||
@ -1,459 +0,0 @@
|
|||||||
"""Maintenance API endpoints for system housekeeping and diagnostics.
|
|
||||||
|
|
||||||
This module exposes cleanup routines, system statistics, maintenance
|
|
||||||
operations, and health reporting endpoints that rely on the shared system
|
|
||||||
utilities and monitoring services. The routes allow administrators to
|
|
||||||
prune logs, inspect disk usage, vacuum or analyze the database, and gather
|
|
||||||
holistic health metrics for AniWorld deployments."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.infrastructure.security.database_integrity import DatabaseIntegrityChecker
|
|
||||||
from src.server.services.monitoring_service import get_monitoring_service
|
|
||||||
from src.server.utils.dependencies import get_database_session
|
|
||||||
from src.server.utils.system import get_system_utilities
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/maintenance", tags=["maintenance"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_system_utils():
|
|
||||||
"""Dependency to get system utilities."""
|
|
||||||
return get_system_utilities()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup")
|
|
||||||
async def cleanup_temporary_files(
|
|
||||||
max_age_days: int = 30,
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Clean up temporary and old files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_age_days: Delete files older than this many days.
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Cleanup results.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deleted_logs = system_utils.cleanup_directory(
|
|
||||||
"logs", "*.log", max_age_days
|
|
||||||
)
|
|
||||||
deleted_temp = system_utils.cleanup_directory(
|
|
||||||
"Temp", "*", max_age_days
|
|
||||||
)
|
|
||||||
deleted_dirs = system_utils.cleanup_empty_directories("logs")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"deleted_logs": deleted_logs,
|
|
||||||
"deleted_temp_files": deleted_temp,
|
|
||||||
"deleted_empty_dirs": deleted_dirs,
|
|
||||||
"total_deleted": deleted_logs + deleted_temp + deleted_dirs,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Cleanup failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
async def get_maintenance_stats(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Get system maintenance statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Maintenance statistics.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
monitoring = get_monitoring_service()
|
|
||||||
|
|
||||||
# Get disk usage
|
|
||||||
disk_info = system_utils.get_disk_usage("/")
|
|
||||||
|
|
||||||
# Get logs directory size
|
|
||||||
logs_size = system_utils.get_directory_size("logs")
|
|
||||||
data_size = system_utils.get_directory_size("data")
|
|
||||||
temp_size = system_utils.get_directory_size("Temp")
|
|
||||||
|
|
||||||
# Get system info
|
|
||||||
system_info = system_utils.get_system_info()
|
|
||||||
|
|
||||||
# Get queue metrics
|
|
||||||
queue_metrics = await monitoring.get_queue_metrics(db)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"disk": {
|
|
||||||
"total_gb": disk_info.total_bytes / (1024**3),
|
|
||||||
"used_gb": disk_info.used_bytes / (1024**3),
|
|
||||||
"free_gb": disk_info.free_bytes / (1024**3),
|
|
||||||
"percent_used": disk_info.percent_used,
|
|
||||||
},
|
|
||||||
"directories": {
|
|
||||||
"logs_mb": logs_size / (1024 * 1024),
|
|
||||||
"data_mb": data_size / (1024 * 1024),
|
|
||||||
"temp_mb": temp_size / (1024 * 1024),
|
|
||||||
},
|
|
||||||
"system": system_info,
|
|
||||||
"queue": {
|
|
||||||
"total_items": queue_metrics.total_items,
|
|
||||||
"downloaded_gb": queue_metrics.downloaded_bytes / (1024**3),
|
|
||||||
"total_gb": queue_metrics.total_size_bytes / (1024**3),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get maintenance stats: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vacuum")
|
|
||||||
async def vacuum_database(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Optimize database (vacuum).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Vacuum result.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# VACUUM command to optimize database
|
|
||||||
await db.execute(text("VACUUM"))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info("Database vacuumed successfully")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Database optimized successfully",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Database vacuum failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rebuild-index")
|
|
||||||
async def rebuild_database_indexes(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Rebuild database indexes.
|
|
||||||
|
|
||||||
Note: This is a placeholder as SQLite doesn't have REINDEX
|
|
||||||
for most operations. For production databases, implement
|
|
||||||
specific index rebuilding logic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Rebuild result.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
# Analyze database for query optimization
|
|
||||||
await db.execute(text("ANALYZE"))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info("Database indexes analyzed successfully")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Database indexes analyzed successfully",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Index rebuild failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/prune-logs")
|
|
||||||
async def prune_old_logs(
|
|
||||||
days: int = 7,
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Remove log files older than specified days.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Keep logs from last N days.
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Pruning results.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deleted = system_utils.cleanup_directory(
|
|
||||||
"logs", "*.log", max_age_days=days
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Pruned {deleted} log files")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"deleted_count": deleted,
|
|
||||||
"message": f"Deleted {deleted} log files older than {days} days",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Log pruning failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/disk-usage")
|
|
||||||
async def get_disk_usage(
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Get detailed disk usage information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Disk usage for all partitions.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
disk_infos = system_utils.get_all_disk_usage()
|
|
||||||
|
|
||||||
partitions = []
|
|
||||||
for disk_info in disk_infos:
|
|
||||||
partitions.append(
|
|
||||||
{
|
|
||||||
"path": disk_info.path,
|
|
||||||
"total_gb": disk_info.total_bytes / (1024**3),
|
|
||||||
"used_gb": disk_info.used_bytes / (1024**3),
|
|
||||||
"free_gb": disk_info.free_bytes / (1024**3),
|
|
||||||
"percent_used": disk_info.percent_used,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"partitions": partitions,
|
|
||||||
"total_partitions": len(partitions),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get disk usage: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/processes")
|
|
||||||
async def get_running_processes(
|
|
||||||
limit: int = 10,
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Get running processes information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of processes to return.
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Running processes information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
processes = system_utils.get_all_processes()
|
|
||||||
|
|
||||||
# Sort by memory usage and get top N
|
|
||||||
sorted_processes = sorted(
|
|
||||||
processes, key=lambda x: x.memory_mb, reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
top_processes = []
|
|
||||||
for proc in sorted_processes[:limit]:
|
|
||||||
top_processes.append(
|
|
||||||
{
|
|
||||||
"pid": proc.pid,
|
|
||||||
"name": proc.name,
|
|
||||||
"cpu_percent": round(proc.cpu_percent, 2),
|
|
||||||
"memory_mb": round(proc.memory_mb, 2),
|
|
||||||
"status": proc.status,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"processes": top_processes,
|
|
||||||
"total_processes": len(processes),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get processes: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/health-check")
|
|
||||||
async def full_health_check(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
system_utils=Depends(get_system_utils),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Perform full system health check and generate report.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
system_utils: System utilities dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Complete health check report.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
monitoring = get_monitoring_service()
|
|
||||||
|
|
||||||
# Check database and filesystem
|
|
||||||
from src.server.api.health import check_database_health
|
|
||||||
from src.server.api.health import check_filesystem_health as check_fs
|
|
||||||
db_health = await check_database_health(db)
|
|
||||||
fs_health = check_fs()
|
|
||||||
|
|
||||||
# Get system metrics
|
|
||||||
system_metrics = monitoring.get_system_metrics()
|
|
||||||
|
|
||||||
# Get error metrics
|
|
||||||
error_metrics = monitoring.get_error_metrics()
|
|
||||||
|
|
||||||
# Get queue metrics
|
|
||||||
queue_metrics = await monitoring.get_queue_metrics(db)
|
|
||||||
|
|
||||||
# Determine overall health
|
|
||||||
issues = []
|
|
||||||
if db_health.status != "healthy":
|
|
||||||
issues.append("Database connectivity issue")
|
|
||||||
if fs_health.get("status") != "healthy":
|
|
||||||
issues.append("Filesystem accessibility issue")
|
|
||||||
if system_metrics.cpu_percent > 80:
|
|
||||||
issues.append(f"High CPU usage: {system_metrics.cpu_percent}%")
|
|
||||||
if system_metrics.memory_percent > 80:
|
|
||||||
issues.append(
|
|
||||||
f"High memory usage: {system_metrics.memory_percent}%"
|
|
||||||
)
|
|
||||||
if error_metrics.error_rate_per_hour > 1.0:
|
|
||||||
issues.append(
|
|
||||||
f"High error rate: "
|
|
||||||
f"{error_metrics.error_rate_per_hour:.2f} errors/hour"
|
|
||||||
)
|
|
||||||
|
|
||||||
overall_health = "healthy"
|
|
||||||
if issues:
|
|
||||||
overall_health = "degraded" if len(issues) < 3 else "unhealthy"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_health": overall_health,
|
|
||||||
"issues": issues,
|
|
||||||
"metrics": {
|
|
||||||
"database": {
|
|
||||||
"status": db_health.status,
|
|
||||||
"connection_time_ms": db_health.connection_time_ms,
|
|
||||||
},
|
|
||||||
"filesystem": fs_health,
|
|
||||||
"system": {
|
|
||||||
"cpu_percent": system_metrics.cpu_percent,
|
|
||||||
"memory_percent": system_metrics.memory_percent,
|
|
||||||
"disk_percent": system_metrics.disk_percent,
|
|
||||||
},
|
|
||||||
"queue": {
|
|
||||||
"total_items": queue_metrics.total_items,
|
|
||||||
"failed_items": queue_metrics.failed_items,
|
|
||||||
"success_rate": round(queue_metrics.success_rate, 2),
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"errors_24h": error_metrics.errors_24h,
|
|
||||||
"rate_per_hour": round(
|
|
||||||
error_metrics.error_rate_per_hour, 2
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Health check failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/integrity/check")
|
|
||||||
async def check_database_integrity(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Check database integrity.
|
|
||||||
|
|
||||||
Verifies:
|
|
||||||
- No orphaned records
|
|
||||||
- Valid foreign key references
|
|
||||||
- No duplicate keys
|
|
||||||
- Data consistency
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Integrity check results with issues found.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Convert async session to sync for the checker
|
|
||||||
# Note: This is a temporary solution. In production,
|
|
||||||
# consider implementing async version of integrity checker.
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
sync_session = Session(bind=db.sync_session.bind)
|
|
||||||
|
|
||||||
checker = DatabaseIntegrityChecker(sync_session)
|
|
||||||
results = checker.check_all()
|
|
||||||
|
|
||||||
if results["total_issues"] > 0:
|
|
||||||
logger.warning(
|
|
||||||
f"Database integrity check found {results['total_issues']} "
|
|
||||||
f"issues"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("Database integrity check passed")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"timestamp": None, # Add timestamp if needed
|
|
||||||
"results": results,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Integrity check failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/integrity/repair")
|
|
||||||
async def repair_database_integrity(
|
|
||||||
db: AsyncSession = Depends(get_database_session),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Repair database integrity by removing orphaned records.
|
|
||||||
|
|
||||||
**Warning**: This operation will delete orphaned records permanently.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session dependency.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Repair results with count of records removed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
sync_session = Session(bind=db.sync_session.bind)
|
|
||||||
|
|
||||||
checker = DatabaseIntegrityChecker(sync_session)
|
|
||||||
removed_count = checker.repair_orphaned_records()
|
|
||||||
|
|
||||||
logger.info(f"Removed {removed_count} orphaned records")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"removed_records": removed_count,
|
|
||||||
"message": (
|
|
||||||
f"Successfully removed {removed_count} orphaned records"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Integrity repair failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@ -1,531 +0,0 @@
|
|||||||
"""Provider management API endpoints.
|
|
||||||
|
|
||||||
This module provides REST API endpoints for monitoring and managing
|
|
||||||
anime providers, including health checks, configuration, and failover.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from src.core.providers.config_manager import ProviderSettings, get_config_manager
|
|
||||||
from src.core.providers.failover import get_failover
|
|
||||||
from src.core.providers.health_monitor import get_health_monitor
|
|
||||||
from src.server.utils.dependencies import require_auth
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/providers", tags=["providers"])
|
|
||||||
|
|
||||||
|
|
||||||
# Request/Response Models
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderHealthResponse(BaseModel):
|
|
||||||
"""Response model for provider health status."""
|
|
||||||
|
|
||||||
provider_name: str
|
|
||||||
is_available: bool
|
|
||||||
last_check_time: Optional[str] = None
|
|
||||||
total_requests: int
|
|
||||||
successful_requests: int
|
|
||||||
failed_requests: int
|
|
||||||
success_rate: float
|
|
||||||
average_response_time_ms: float
|
|
||||||
last_error: Optional[str] = None
|
|
||||||
last_error_time: Optional[str] = None
|
|
||||||
consecutive_failures: int
|
|
||||||
total_bytes_downloaded: int
|
|
||||||
uptime_percentage: float
|
|
||||||
|
|
||||||
|
|
||||||
class HealthSummaryResponse(BaseModel):
|
|
||||||
"""Response model for overall health summary."""
|
|
||||||
|
|
||||||
total_providers: int
|
|
||||||
available_providers: int
|
|
||||||
availability_percentage: float
|
|
||||||
average_success_rate: float
|
|
||||||
average_response_time_ms: float
|
|
||||||
providers: Dict[str, Dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderSettingsRequest(BaseModel):
|
|
||||||
"""Request model for updating provider settings."""
|
|
||||||
|
|
||||||
enabled: Optional[bool] = None
|
|
||||||
priority: Optional[int] = None
|
|
||||||
timeout_seconds: Optional[int] = Field(None, gt=0)
|
|
||||||
max_retries: Optional[int] = Field(None, ge=0)
|
|
||||||
retry_delay_seconds: Optional[float] = Field(None, gt=0)
|
|
||||||
max_concurrent_downloads: Optional[int] = Field(None, gt=0)
|
|
||||||
bandwidth_limit_mbps: Optional[float] = Field(None, gt=0)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderSettingsResponse(BaseModel):
|
|
||||||
"""Response model for provider settings."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
enabled: bool
|
|
||||||
priority: int
|
|
||||||
timeout_seconds: int
|
|
||||||
max_retries: int
|
|
||||||
retry_delay_seconds: float
|
|
||||||
max_concurrent_downloads: int
|
|
||||||
bandwidth_limit_mbps: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FailoverStatsResponse(BaseModel):
|
|
||||||
"""Response model for failover statistics."""
|
|
||||||
|
|
||||||
total_providers: int
|
|
||||||
providers: List[str]
|
|
||||||
current_provider: str
|
|
||||||
max_retries: int
|
|
||||||
retry_delay: float
|
|
||||||
health_monitoring_enabled: bool
|
|
||||||
available_providers: Optional[List[str]] = None
|
|
||||||
unavailable_providers: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
# Health Monitoring Endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthSummaryResponse)
|
|
||||||
async def get_providers_health(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> HealthSummaryResponse:
|
|
||||||
"""Get overall provider health summary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Health summary for all providers.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_monitor = get_health_monitor()
|
|
||||||
summary = health_monitor.get_health_summary()
|
|
||||||
return HealthSummaryResponse(**summary)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get provider health: {e}", exc_info=True)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve provider health: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health/{provider_name}", response_model=ProviderHealthResponse) # noqa: E501
|
|
||||||
async def get_provider_health(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> ProviderHealthResponse:
|
|
||||||
"""Get health status for a specific provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Health metrics for the provider.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If provider not found or error occurs.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_monitor = get_health_monitor()
|
|
||||||
metrics = health_monitor.get_provider_metrics(provider_name)
|
|
||||||
|
|
||||||
if not metrics:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Provider '{provider_name}' not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProviderHealthResponse(**metrics.to_dict())
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to get health for {provider_name}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve provider health: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/available", response_model=List[str])
|
|
||||||
async def get_available_providers(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> List[str]:
|
|
||||||
"""Get list of currently available providers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of available provider names.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_monitor = get_health_monitor()
|
|
||||||
return health_monitor.get_available_providers()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get available providers: {e}", exc_info=True) # noqa: E501
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve available providers: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/best", response_model=Dict[str, str])
|
|
||||||
async def get_best_provider(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Get the best performing provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with best provider name.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_monitor = get_health_monitor()
|
|
||||||
best = health_monitor.get_best_provider()
|
|
||||||
|
|
||||||
if not best:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="No available providers",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"provider": best}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get best provider: {e}", exc_info=True)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to determine best provider: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/health/{provider_name}/reset")
|
|
||||||
async def reset_provider_health(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Reset health metrics for a specific provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If provider not found or error occurs.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_monitor = get_health_monitor()
|
|
||||||
success = health_monitor.reset_provider_metrics(provider_name)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Provider '{provider_name}' not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"message": f"Reset metrics for provider: {provider_name}"}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to reset health for {provider_name}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to reset provider health: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Configuration Endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=List[ProviderSettingsResponse])
|
|
||||||
async def get_all_provider_configs(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> List[ProviderSettingsResponse]:
|
|
||||||
"""Get configuration for all providers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of provider configurations.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_manager = get_config_manager()
|
|
||||||
all_settings = config_manager.get_all_provider_settings()
|
|
||||||
return [
|
|
||||||
ProviderSettingsResponse(**settings.to_dict())
|
|
||||||
for settings in all_settings.values()
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get provider configs: {e}", exc_info=True)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve provider configurations: {str(e)}", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/config/{provider_name}", response_model=ProviderSettingsResponse
|
|
||||||
)
|
|
||||||
async def get_provider_config(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> ProviderSettingsResponse:
|
|
||||||
"""Get configuration for a specific provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Provider configuration.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If provider not found or error occurs.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_manager = get_config_manager()
|
|
||||||
settings = config_manager.get_provider_settings(provider_name)
|
|
||||||
|
|
||||||
if not settings:
|
|
||||||
# Return default settings
|
|
||||||
settings = ProviderSettings(name=provider_name)
|
|
||||||
|
|
||||||
return ProviderSettingsResponse(**settings.to_dict())
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to get config for {provider_name}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve provider configuration: {str(e)}", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/config/{provider_name}", response_model=ProviderSettingsResponse
|
|
||||||
)
|
|
||||||
async def update_provider_config(
|
|
||||||
provider_name: str,
|
|
||||||
settings: ProviderSettingsRequest,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> ProviderSettingsResponse:
|
|
||||||
"""Update configuration for a specific provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
settings: Settings to update.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated provider configuration.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_manager = get_config_manager()
|
|
||||||
|
|
||||||
# Update settings
|
|
||||||
update_dict = settings.dict(exclude_unset=True)
|
|
||||||
config_manager.update_provider_settings(
|
|
||||||
provider_name, **update_dict
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get updated settings
|
|
||||||
updated = config_manager.get_provider_settings(provider_name)
|
|
||||||
if not updated:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to retrieve updated configuration",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProviderSettingsResponse(**updated.to_dict())
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to update config for {provider_name}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to update provider configuration: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/{provider_name}/enable")
|
|
||||||
async def enable_provider(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Enable a provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_manager = get_config_manager()
|
|
||||||
config_manager.update_provider_settings(
|
|
||||||
provider_name, enabled=True
|
|
||||||
)
|
|
||||||
return {"message": f"Enabled provider: {provider_name}"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to enable {provider_name}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to enable provider: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/{provider_name}/disable")
|
|
||||||
async def disable_provider(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Disable a provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_manager = get_config_manager()
|
|
||||||
config_manager.update_provider_settings(
|
|
||||||
provider_name, enabled=False
|
|
||||||
)
|
|
||||||
return {"message": f"Disabled provider: {provider_name}"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to disable {provider_name}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to disable provider: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Failover Endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/failover", response_model=FailoverStatsResponse)
|
|
||||||
async def get_failover_stats(
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> FailoverStatsResponse:
|
|
||||||
"""Get failover statistics and configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Failover statistics.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
failover = get_failover()
|
|
||||||
stats = failover.get_failover_stats()
|
|
||||||
return FailoverStatsResponse(**stats)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get failover stats: {e}", exc_info=True)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve failover statistics: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/failover/{provider_name}/add")
|
|
||||||
async def add_provider_to_failover(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Add a provider to the failover chain.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
failover = get_failover()
|
|
||||||
failover.add_provider(provider_name)
|
|
||||||
return {"message": f"Added provider to failover: {provider_name}"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to add {provider_name} to failover: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to add provider to failover: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/failover/{provider_name}")
|
|
||||||
async def remove_provider_from_failover(
|
|
||||||
provider_name: str,
|
|
||||||
auth: Optional[dict] = Depends(require_auth),
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""Remove a provider from the failover chain.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_name: Name of the provider.
|
|
||||||
auth: Authentication token (optional).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success message.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If provider not found in failover chain.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
failover = get_failover()
|
|
||||||
success = failover.remove_provider(provider_name)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Provider '{provider_name}' not in failover chain", # noqa: E501
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": f"Removed provider from failover: {provider_name}"
|
|
||||||
}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to remove {provider_name} from failover: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to remove provider from failover: {str(e)}",
|
|
||||||
)
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
"""File upload API endpoints with security validation.
|
|
||||||
|
|
||||||
This module provides secure file upload endpoints with comprehensive
|
|
||||||
validation for file size, type, extensions, and content.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
|
||||||
|
|
||||||
# Security configurations
|
|
||||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
||||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".txt", ".json", ".xml"}
|
|
||||||
DANGEROUS_EXTENSIONS = {
|
|
||||||
".exe",
|
|
||||||
".sh",
|
|
||||||
".bat",
|
|
||||||
".cmd",
|
|
||||||
".php",
|
|
||||||
".jsp",
|
|
||||||
".asp",
|
|
||||||
".aspx",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".pl",
|
|
||||||
".cgi",
|
|
||||||
}
|
|
||||||
ALLOWED_MIME_TYPES = {
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"text/plain",
|
|
||||||
"application/json",
|
|
||||||
"application/xml",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_file_extension(filename: str) -> None:
|
|
||||||
"""Validate file extension against security rules.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Name of the file to validate
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 415 if extension is dangerous or not allowed
|
|
||||||
"""
|
|
||||||
# Check for double extensions (e.g., file.jpg.php)
|
|
||||||
parts = filename.split(".")
|
|
||||||
if len(parts) > 2:
|
|
||||||
# Check all extension parts, not just the last one
|
|
||||||
for part in parts[1:]:
|
|
||||||
ext = f".{part.lower()}"
|
|
||||||
if ext in DANGEROUS_EXTENSIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail=f"Dangerous file extension detected: {ext}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the actual extension
|
|
||||||
if "." not in filename:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail="File must have an extension",
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = "." + filename.rsplit(".", 1)[1].lower()
|
|
||||||
|
|
||||||
if ext in DANGEROUS_EXTENSIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail=f"File extension not allowed: {ext}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if ext not in ALLOWED_EXTENSIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail=(
|
|
||||||
f"File extension not allowed: {ext}. "
|
|
||||||
f"Allowed: {ALLOWED_EXTENSIONS}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_mime_type(content_type: str, content: bytes) -> None:
|
|
||||||
"""Validate MIME type and content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_type: Declared MIME type
|
|
||||||
content: Actual file content
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 415 if MIME type is not allowed or content is suspicious
|
|
||||||
"""
|
|
||||||
if content_type not in ALLOWED_MIME_TYPES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail=f"MIME type not allowed: {content_type}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Basic content validation for PHP code
|
|
||||||
dangerous_patterns = [
|
|
||||||
b"<?php",
|
|
||||||
b"<script",
|
|
||||||
b"javascript:",
|
|
||||||
b"<iframe",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in dangerous_patterns:
|
|
||||||
if pattern in content[:1024]: # Check first 1KB
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail="Suspicious file content detected",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def upload_file(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
):
|
|
||||||
"""Upload a file with comprehensive security validation.
|
|
||||||
|
|
||||||
Validates:
|
|
||||||
- File size (max 50MB)
|
|
||||||
- File extension (blocks dangerous extensions)
|
|
||||||
- Double extension bypass attempts
|
|
||||||
- MIME type
|
|
||||||
- Content inspection for malicious code
|
|
||||||
|
|
||||||
Note: Authentication removed for security testing purposes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file: The file to upload
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Upload confirmation with file details
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 413 if file too large
|
|
||||||
HTTPException: 415 if file type not allowed
|
|
||||||
HTTPException: 400 if validation fails
|
|
||||||
"""
|
|
||||||
# Validate filename exists
|
|
||||||
if not file.filename:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Filename is required",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate file extension
|
|
||||||
validate_file_extension(file.filename)
|
|
||||||
|
|
||||||
# Read file content
|
|
||||||
content = await file.read()
|
|
||||||
|
|
||||||
# Validate file size
|
|
||||||
if len(content) > MAX_FILE_SIZE:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
||||||
detail=(
|
|
||||||
f"File size exceeds maximum allowed size "
|
|
||||||
f"of {MAX_FILE_SIZE} bytes"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate MIME type and content
|
|
||||||
content_type = file.content_type or "application/octet-stream"
|
|
||||||
validate_mime_type(content_type, content)
|
|
||||||
|
|
||||||
# In a real implementation, save the file here
|
|
||||||
# For now, just return success
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"filename": file.filename,
|
|
||||||
"size": len(content),
|
|
||||||
"content_type": content_type,
|
|
||||||
}
|
|
||||||
@ -8,14 +8,14 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
from src.server.utils.dependencies import get_optional_series_app
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
router = APIRouter(prefix="/health", tags=["health"])
|
router = APIRouter(prefix="/health", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def health_check(
|
async def health_check(
|
||||||
series_app: Optional[SeriesApp] = Depends(get_optional_series_app)
|
series_app: Optional[SeriesApp] = Depends(get_series_app)
|
||||||
):
|
):
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -5,10 +5,8 @@ This module provides the main FastAPI application with proper CORS
|
|||||||
configuration, middleware setup, static file serving, and Jinja2 template
|
configuration, middleware setup, static file serving, and Jinja2 template
|
||||||
integration.
|
integration.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@ -18,19 +16,12 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
|
|
||||||
# Import core functionality
|
# Import core functionality
|
||||||
from src.core.SeriesApp import SeriesApp
|
|
||||||
from src.infrastructure.logging import setup_logging
|
from src.infrastructure.logging import setup_logging
|
||||||
from src.server.api.analytics import router as analytics_router
|
|
||||||
from src.server.api.anime import router as anime_router
|
from src.server.api.anime import router as anime_router
|
||||||
from src.server.api.auth import router as auth_router
|
from src.server.api.auth import router as auth_router
|
||||||
from src.server.api.config import router as config_router
|
from src.server.api.config import router as config_router
|
||||||
from src.server.api.diagnostics import router as diagnostics_router
|
|
||||||
from src.server.api.download import downloads_router
|
|
||||||
from src.server.api.download import router as download_router
|
from src.server.api.download import router as download_router
|
||||||
from src.server.api.logging import router as logging_router
|
|
||||||
from src.server.api.providers import router as providers_router
|
|
||||||
from src.server.api.scheduler import router as scheduler_router
|
from src.server.api.scheduler import router as scheduler_router
|
||||||
from src.server.api.upload import router as upload_router
|
|
||||||
from src.server.api.websocket import router as websocket_router
|
from src.server.api.websocket import router as websocket_router
|
||||||
from src.server.controllers.error_controller import (
|
from src.server.controllers.error_controller import (
|
||||||
not_found_handler,
|
not_found_handler,
|
||||||
@ -53,8 +44,8 @@ from src.server.services.websocket_service import get_websocket_service
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Manage application lifespan (startup and shutdown)."""
|
"""Manage application lifespan (startup and shutdown)."""
|
||||||
# Setup logging first
|
# Setup logging first with DEBUG level
|
||||||
logger = setup_logging()
|
logger = setup_logging(log_level="DEBUG")
|
||||||
|
|
||||||
# Startup
|
# Startup
|
||||||
try:
|
try:
|
||||||
@ -75,37 +66,25 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load config from config.json: %s", e)
|
logger.warning("Failed to load config from config.json: %s", e)
|
||||||
|
|
||||||
# Initialize SeriesApp with configured directory and store it on
|
# Initialize progress service with event subscription
|
||||||
# application state so it can be injected via dependencies.
|
|
||||||
if settings.anime_directory:
|
|
||||||
app.state.series_app = SeriesApp(settings.anime_directory)
|
|
||||||
logger.info(
|
|
||||||
"SeriesApp initialized with directory: %s",
|
|
||||||
settings.anime_directory
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Log warning when anime directory is not configured
|
|
||||||
logger.warning(
|
|
||||||
"ANIME_DIRECTORY not configured. "
|
|
||||||
"Some features may be unavailable."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize progress service with websocket callback
|
|
||||||
progress_service = get_progress_service()
|
progress_service = get_progress_service()
|
||||||
ws_service = get_websocket_service()
|
ws_service = get_websocket_service()
|
||||||
|
|
||||||
async def broadcast_callback(
|
async def progress_event_handler(event) -> None:
|
||||||
message_type: str, data: dict, room: str
|
"""Handle progress events and broadcast via WebSocket.
|
||||||
) -> None:
|
|
||||||
"""Broadcast progress updates via WebSocket."""
|
Args:
|
||||||
|
event: ProgressEvent containing progress update data
|
||||||
|
"""
|
||||||
message = {
|
message = {
|
||||||
"type": message_type,
|
"type": event.event_type,
|
||||||
"data": data,
|
"data": event.progress.to_dict(),
|
||||||
}
|
}
|
||||||
await ws_service.manager.broadcast_to_room(message, room)
|
await ws_service.manager.broadcast_to_room(message, event.room)
|
||||||
|
|
||||||
progress_service.set_broadcast_callback(broadcast_callback)
|
# Subscribe to progress events
|
||||||
|
progress_service.subscribe("progress_updated", progress_event_handler)
|
||||||
|
|
||||||
logger.info("FastAPI application started successfully")
|
logger.info("FastAPI application started successfully")
|
||||||
logger.info("Server running on http://127.0.0.1:8000")
|
logger.info("Server running on http://127.0.0.1:8000")
|
||||||
@ -123,15 +102,6 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("FastAPI application shutting down")
|
logger.info("FastAPI application shutting down")
|
||||||
|
|
||||||
|
|
||||||
def get_series_app() -> Optional[SeriesApp]:
|
|
||||||
"""Dependency to retrieve the SeriesApp instance from application state.
|
|
||||||
|
|
||||||
Returns None when the application wasn't configured with an anime
|
|
||||||
directory (for example during certain test runs).
|
|
||||||
"""
|
|
||||||
return getattr(app.state, "series_app", None)
|
|
||||||
|
|
||||||
|
|
||||||
# Initialize FastAPI app with lifespan
|
# Initialize FastAPI app with lifespan
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aniworld Download Manager",
|
title="Aniworld Download Manager",
|
||||||
@ -172,14 +142,8 @@ app.include_router(page_router)
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(config_router)
|
app.include_router(config_router)
|
||||||
app.include_router(scheduler_router)
|
app.include_router(scheduler_router)
|
||||||
app.include_router(logging_router)
|
|
||||||
app.include_router(diagnostics_router)
|
|
||||||
app.include_router(analytics_router)
|
|
||||||
app.include_router(anime_router)
|
app.include_router(anime_router)
|
||||||
app.include_router(download_router)
|
app.include_router(download_router)
|
||||||
app.include_router(downloads_router) # Alias for input validation tests
|
|
||||||
app.include_router(providers_router)
|
|
||||||
app.include_router(upload_router)
|
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket_router)
|
||||||
|
|
||||||
# Register exception handlers
|
# Register exception handlers
|
||||||
@ -204,5 +168,5 @@ if __name__ == "__main__":
|
|||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=True,
|
reload=True,
|
||||||
log_level="info"
|
log_level="debug"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,331 +0,0 @@
|
|||||||
"""Rate limiting middleware for API endpoints.
|
|
||||||
|
|
||||||
This module provides comprehensive rate limiting with support for:
|
|
||||||
- Endpoint-specific rate limits
|
|
||||||
- IP-based limiting
|
|
||||||
- User-based rate limiting
|
|
||||||
- Bypass mechanisms for authenticated users
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from collections import defaultdict
|
|
||||||
from typing import Callable, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
from fastapi import Request, status
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.responses import JSONResponse
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitConfig:
|
|
||||||
"""Configuration for rate limiting rules."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
requests_per_minute: int = 60,
|
|
||||||
requests_per_hour: int = 1000,
|
|
||||||
authenticated_multiplier: float = 2.0,
|
|
||||||
):
|
|
||||||
"""Initialize rate limit configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
requests_per_minute: Max requests per minute for
|
|
||||||
unauthenticated users
|
|
||||||
requests_per_hour: Max requests per hour for
|
|
||||||
unauthenticated users
|
|
||||||
authenticated_multiplier: Multiplier for authenticated users
|
|
||||||
"""
|
|
||||||
self.requests_per_minute = requests_per_minute
|
|
||||||
self.requests_per_hour = requests_per_hour
|
|
||||||
self.authenticated_multiplier = authenticated_multiplier
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitStore:
|
|
||||||
"""In-memory store for rate limit tracking."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the rate limit store."""
|
|
||||||
# Store format: {identifier: [(timestamp, count), ...]}
|
|
||||||
self._minute_store: Dict[str, list] = defaultdict(list)
|
|
||||||
self._hour_store: Dict[str, list] = defaultdict(list)
|
|
||||||
|
|
||||||
def check_limit(
|
|
||||||
self,
|
|
||||||
identifier: str,
|
|
||||||
max_per_minute: int,
|
|
||||||
max_per_hour: int,
|
|
||||||
) -> Tuple[bool, Optional[int]]:
|
|
||||||
"""Check if the identifier has exceeded rate limits.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier: Unique identifier (IP or user ID)
|
|
||||||
max_per_minute: Maximum requests allowed per minute
|
|
||||||
max_per_hour: Maximum requests allowed per hour
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (allowed, retry_after_seconds)
|
|
||||||
"""
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
# Clean up old entries
|
|
||||||
self._cleanup_old_entries(identifier, current_time)
|
|
||||||
|
|
||||||
# Check minute limit
|
|
||||||
minute_count = len(self._minute_store[identifier])
|
|
||||||
if minute_count >= max_per_minute:
|
|
||||||
# Calculate retry after time
|
|
||||||
oldest_entry = self._minute_store[identifier][0]
|
|
||||||
retry_after = int(60 - (current_time - oldest_entry))
|
|
||||||
return False, max(retry_after, 1)
|
|
||||||
|
|
||||||
# Check hour limit
|
|
||||||
hour_count = len(self._hour_store[identifier])
|
|
||||||
if hour_count >= max_per_hour:
|
|
||||||
# Calculate retry after time
|
|
||||||
oldest_entry = self._hour_store[identifier][0]
|
|
||||||
retry_after = int(3600 - (current_time - oldest_entry))
|
|
||||||
return False, max(retry_after, 1)
|
|
||||||
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
def record_request(self, identifier: str) -> None:
|
|
||||||
"""Record a request for the identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier: Unique identifier (IP or user ID)
|
|
||||||
"""
|
|
||||||
current_time = time.time()
|
|
||||||
self._minute_store[identifier].append(current_time)
|
|
||||||
self._hour_store[identifier].append(current_time)
|
|
||||||
|
|
||||||
def get_remaining_requests(
|
|
||||||
self, identifier: str, max_per_minute: int, max_per_hour: int
|
|
||||||
) -> Tuple[int, int]:
|
|
||||||
"""Get remaining requests for the identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier: Unique identifier
|
|
||||||
max_per_minute: Maximum per minute
|
|
||||||
max_per_hour: Maximum per hour
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (remaining_per_minute, remaining_per_hour)
|
|
||||||
"""
|
|
||||||
minute_used = len(self._minute_store.get(identifier, []))
|
|
||||||
hour_used = len(self._hour_store.get(identifier, []))
|
|
||||||
return (
|
|
||||||
max(0, max_per_minute - minute_used),
|
|
||||||
max(0, max_per_hour - hour_used)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cleanup_old_entries(
|
|
||||||
self, identifier: str, current_time: float
|
|
||||||
) -> None:
|
|
||||||
"""Remove entries older than the time windows.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier: Unique identifier
|
|
||||||
current_time: Current timestamp
|
|
||||||
"""
|
|
||||||
# Remove entries older than 1 minute
|
|
||||||
minute_cutoff = current_time - 60
|
|
||||||
self._minute_store[identifier] = [
|
|
||||||
ts for ts in self._minute_store[identifier] if ts > minute_cutoff
|
|
||||||
]
|
|
||||||
|
|
||||||
# Remove entries older than 1 hour
|
|
||||||
hour_cutoff = current_time - 3600
|
|
||||||
self._hour_store[identifier] = [
|
|
||||||
ts for ts in self._hour_store[identifier] if ts > hour_cutoff
|
|
||||||
]
|
|
||||||
|
|
||||||
# Clean up empty entries
|
|
||||||
if not self._minute_store[identifier]:
|
|
||||||
del self._minute_store[identifier]
|
|
||||||
if not self._hour_store[identifier]:
|
|
||||||
del self._hour_store[identifier]
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
||||||
"""Middleware for API rate limiting."""
|
|
||||||
|
|
||||||
# Endpoint-specific rate limits (overrides defaults)
|
|
||||||
ENDPOINT_LIMITS: Dict[str, RateLimitConfig] = {
|
|
||||||
"/api/auth/login": RateLimitConfig(
|
|
||||||
requests_per_minute=5,
|
|
||||||
requests_per_hour=20,
|
|
||||||
),
|
|
||||||
"/api/auth/register": RateLimitConfig(
|
|
||||||
requests_per_minute=3,
|
|
||||||
requests_per_hour=10,
|
|
||||||
),
|
|
||||||
"/api/download": RateLimitConfig(
|
|
||||||
requests_per_minute=10,
|
|
||||||
requests_per_hour=100,
|
|
||||||
authenticated_multiplier=3.0,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Paths that bypass rate limiting
|
|
||||||
BYPASS_PATHS = {
|
|
||||||
"/health",
|
|
||||||
"/health/detailed",
|
|
||||||
"/docs",
|
|
||||||
"/redoc",
|
|
||||||
"/openapi.json",
|
|
||||||
"/static",
|
|
||||||
"/ws",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
app,
|
|
||||||
default_config: Optional[RateLimitConfig] = None,
|
|
||||||
):
|
|
||||||
"""Initialize rate limiting middleware.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
app: FastAPI application
|
|
||||||
default_config: Default rate limit configuration
|
|
||||||
"""
|
|
||||||
super().__init__(app)
|
|
||||||
self.default_config = default_config or RateLimitConfig()
|
|
||||||
self.store = RateLimitStore()
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable):
|
|
||||||
"""Process request and apply rate limiting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Incoming HTTP request
|
|
||||||
call_next: Next middleware or endpoint handler
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTTP response (either rate limit error or normal response)
|
|
||||||
"""
|
|
||||||
# Check if path should bypass rate limiting
|
|
||||||
if self._should_bypass(request.url.path):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# Get identifier (user ID if authenticated, otherwise IP)
|
|
||||||
identifier = self._get_identifier(request)
|
|
||||||
|
|
||||||
# Get rate limit configuration for this endpoint
|
|
||||||
config = self._get_endpoint_config(request.url.path)
|
|
||||||
|
|
||||||
# Apply authenticated user multiplier if applicable
|
|
||||||
is_authenticated = self._is_authenticated(request)
|
|
||||||
max_per_minute = int(
|
|
||||||
config.requests_per_minute *
|
|
||||||
(config.authenticated_multiplier if is_authenticated else 1.0)
|
|
||||||
)
|
|
||||||
max_per_hour = int(
|
|
||||||
config.requests_per_hour *
|
|
||||||
(config.authenticated_multiplier if is_authenticated else 1.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check rate limit
|
|
||||||
allowed, retry_after = self.store.check_limit(
|
|
||||||
identifier,
|
|
||||||
max_per_minute,
|
|
||||||
max_per_hour,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not allowed:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
content={"detail": "Rate limit exceeded"},
|
|
||||||
headers={"Retry-After": str(retry_after)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record the request
|
|
||||||
self.store.record_request(identifier)
|
|
||||||
|
|
||||||
# Add rate limit headers to response
|
|
||||||
response = await call_next(request)
|
|
||||||
response.headers["X-RateLimit-Limit-Minute"] = str(max_per_minute)
|
|
||||||
response.headers["X-RateLimit-Limit-Hour"] = str(max_per_hour)
|
|
||||||
|
|
||||||
minute_remaining, hour_remaining = self.store.get_remaining_requests(
|
|
||||||
identifier, max_per_minute, max_per_hour
|
|
||||||
)
|
|
||||||
|
|
||||||
response.headers["X-RateLimit-Remaining-Minute"] = str(
|
|
||||||
minute_remaining
|
|
||||||
)
|
|
||||||
response.headers["X-RateLimit-Remaining-Hour"] = str(
|
|
||||||
hour_remaining
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _should_bypass(self, path: str) -> bool:
|
|
||||||
"""Check if path should bypass rate limiting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Request path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if path should bypass rate limiting
|
|
||||||
"""
|
|
||||||
for bypass_path in self.BYPASS_PATHS:
|
|
||||||
if path.startswith(bypass_path):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_identifier(self, request: Request) -> str:
|
|
||||||
"""Get unique identifier for rate limiting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: HTTP request
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Unique identifier (user ID or IP address)
|
|
||||||
"""
|
|
||||||
# Try to get user ID from request state (set by auth middleware)
|
|
||||||
user_id = getattr(request.state, "user_id", None)
|
|
||||||
if user_id:
|
|
||||||
return f"user:{user_id}"
|
|
||||||
|
|
||||||
# Fall back to IP address
|
|
||||||
# Check for X-Forwarded-For header (proxy/load balancer)
|
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded_for:
|
|
||||||
# Take the first IP in the chain
|
|
||||||
client_ip = forwarded_for.split(",")[0].strip()
|
|
||||||
else:
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
|
|
||||||
return f"ip:{client_ip}"
|
|
||||||
|
|
||||||
def _get_endpoint_config(self, path: str) -> RateLimitConfig:
|
|
||||||
"""Get rate limit configuration for endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Request path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rate limit configuration
|
|
||||||
"""
|
|
||||||
# Check for exact match
|
|
||||||
if path in self.ENDPOINT_LIMITS:
|
|
||||||
return self.ENDPOINT_LIMITS[path]
|
|
||||||
|
|
||||||
# Check for prefix match
|
|
||||||
for endpoint_path, config in self.ENDPOINT_LIMITS.items():
|
|
||||||
if path.startswith(endpoint_path):
|
|
||||||
return config
|
|
||||||
|
|
||||||
return self.default_config
|
|
||||||
|
|
||||||
def _is_authenticated(self, request: Request) -> bool:
|
|
||||||
"""Check if request is from authenticated user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: HTTP request
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user is authenticated
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
hasattr(request.state, "user_id") and
|
|
||||||
request.state.user_id is not None
|
|
||||||
)
|
|
||||||
@ -10,7 +10,7 @@ from datetime import datetime, timezone
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
class DownloadStatus(str, Enum):
|
class DownloadStatus(str, Enum):
|
||||||
@ -27,9 +27,9 @@ class DownloadStatus(str, Enum):
|
|||||||
class DownloadPriority(str, Enum):
|
class DownloadPriority(str, Enum):
|
||||||
"""Priority level for download queue items."""
|
"""Priority level for download queue items."""
|
||||||
|
|
||||||
LOW = "low"
|
LOW = "LOW"
|
||||||
NORMAL = "normal"
|
NORMAL = "NORMAL"
|
||||||
HIGH = "high"
|
HIGH = "HIGH"
|
||||||
|
|
||||||
|
|
||||||
class EpisodeIdentifier(BaseModel):
|
class EpisodeIdentifier(BaseModel):
|
||||||
@ -66,7 +66,10 @@ class DownloadItem(BaseModel):
|
|||||||
"""Represents a single download item in the queue."""
|
"""Represents a single download item in the queue."""
|
||||||
|
|
||||||
id: str = Field(..., description="Unique download item identifier")
|
id: str = Field(..., description="Unique download item identifier")
|
||||||
serie_id: str = Field(..., description="Series identifier")
|
serie_id: str = Field(..., description="Series identifier (provider key)")
|
||||||
|
serie_folder: Optional[str] = Field(
|
||||||
|
None, description="Series folder name on disk"
|
||||||
|
)
|
||||||
serie_name: str = Field(..., min_length=1, description="Series name")
|
serie_name: str = Field(..., min_length=1, description="Series name")
|
||||||
episode: EpisodeIdentifier = Field(
|
episode: EpisodeIdentifier = Field(
|
||||||
..., description="Episode identification"
|
..., description="Episode identification"
|
||||||
@ -157,7 +160,10 @@ class QueueStats(BaseModel):
|
|||||||
class DownloadRequest(BaseModel):
|
class DownloadRequest(BaseModel):
|
||||||
"""Request to add episode(s) to the download queue."""
|
"""Request to add episode(s) to the download queue."""
|
||||||
|
|
||||||
serie_id: str = Field(..., description="Series identifier")
|
serie_id: str = Field(..., description="Series identifier (provider key)")
|
||||||
|
serie_folder: Optional[str] = Field(
|
||||||
|
None, description="Series folder name on disk"
|
||||||
|
)
|
||||||
serie_name: str = Field(
|
serie_name: str = Field(
|
||||||
..., min_length=1, description="Series name for display"
|
..., min_length=1, description="Series name for display"
|
||||||
)
|
)
|
||||||
@ -167,6 +173,14 @@ class DownloadRequest(BaseModel):
|
|||||||
priority: DownloadPriority = Field(
|
priority: DownloadPriority = Field(
|
||||||
DownloadPriority.NORMAL, description="Priority level for queue items"
|
DownloadPriority.NORMAL, description="Priority level for queue items"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator('priority', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_priority(cls, v):
|
||||||
|
"""Normalize priority to uppercase for case-insensitive matching."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.upper()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class DownloadResponse(BaseModel):
|
class DownloadResponse(BaseModel):
|
||||||
|
|||||||
@ -1,423 +0,0 @@
|
|||||||
"""Analytics service for downloads, popularity, and performance metrics.
|
|
||||||
|
|
||||||
This module provides comprehensive analytics tracking including download
|
|
||||||
statistics, series popularity analysis, storage usage trends, and
|
|
||||||
performance reporting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.server.database.models import DownloadQueueItem, DownloadStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ANALYTICS_FILE = Path("data") / "analytics.json"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DownloadStats:
|
|
||||||
"""Download statistics snapshot."""
|
|
||||||
|
|
||||||
total_downloads: int = 0
|
|
||||||
successful_downloads: int = 0
|
|
||||||
failed_downloads: int = 0
|
|
||||||
total_bytes_downloaded: int = 0
|
|
||||||
average_speed_mbps: float = 0.0
|
|
||||||
success_rate: float = 0.0
|
|
||||||
average_duration_seconds: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SeriesPopularity:
|
|
||||||
"""Series popularity metrics."""
|
|
||||||
|
|
||||||
series_name: str
|
|
||||||
download_count: int
|
|
||||||
total_size_bytes: int
|
|
||||||
last_download: Optional[str] = None
|
|
||||||
success_rate: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StorageAnalysis:
|
|
||||||
"""Storage usage analysis."""
|
|
||||||
|
|
||||||
total_storage_bytes: int = 0
|
|
||||||
used_storage_bytes: int = 0
|
|
||||||
free_storage_bytes: int = 0
|
|
||||||
storage_percent_used: float = 0.0
|
|
||||||
downloads_directory_size_bytes: int = 0
|
|
||||||
cache_directory_size_bytes: int = 0
|
|
||||||
logs_directory_size_bytes: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PerformanceReport:
|
|
||||||
"""Performance metrics and trends."""
|
|
||||||
|
|
||||||
period_start: str
|
|
||||||
period_end: str
|
|
||||||
downloads_per_hour: float = 0.0
|
|
||||||
average_queue_size: float = 0.0
|
|
||||||
peak_memory_usage_mb: float = 0.0
|
|
||||||
average_cpu_percent: float = 0.0
|
|
||||||
uptime_seconds: float = 0.0
|
|
||||||
error_rate: float = 0.0
|
|
||||||
samples: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsService:
|
|
||||||
"""Service for tracking and reporting analytics data."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the analytics service."""
|
|
||||||
self.analytics_file = ANALYTICS_FILE
|
|
||||||
self._ensure_analytics_file()
|
|
||||||
|
|
||||||
def _ensure_analytics_file(self) -> None:
|
|
||||||
"""Ensure analytics file exists with default data."""
|
|
||||||
if not self.analytics_file.exists():
|
|
||||||
default_data = {
|
|
||||||
"created_at": datetime.now().isoformat(),
|
|
||||||
"last_updated": datetime.now().isoformat(),
|
|
||||||
"download_stats": asdict(DownloadStats()),
|
|
||||||
"series_popularity": [],
|
|
||||||
"storage_history": [],
|
|
||||||
"performance_samples": [],
|
|
||||||
}
|
|
||||||
self.analytics_file.write_text(json.dumps(default_data, indent=2))
|
|
||||||
|
|
||||||
def _load_analytics(self) -> Dict[str, Any]:
|
|
||||||
"""Load analytics data from file."""
|
|
||||||
try:
|
|
||||||
return json.loads(self.analytics_file.read_text())
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
self._ensure_analytics_file()
|
|
||||||
return json.loads(self.analytics_file.read_text())
|
|
||||||
|
|
||||||
def _save_analytics(self, data: Dict[str, Any]) -> None:
|
|
||||||
"""Save analytics data to file."""
|
|
||||||
data["last_updated"] = datetime.now().isoformat()
|
|
||||||
self.analytics_file.write_text(json.dumps(data, indent=2))
|
|
||||||
|
|
||||||
async def get_download_stats(
|
|
||||||
self, db: AsyncSession, days: int = 30
|
|
||||||
) -> DownloadStats:
|
|
||||||
"""Get download statistics for the specified period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
days: Number of days to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DownloadStats with aggregated download data
|
|
||||||
"""
|
|
||||||
cutoff_date = datetime.now() - timedelta(days=days)
|
|
||||||
|
|
||||||
# Query downloads within period
|
|
||||||
stmt = select(DownloadQueueItem).where(
|
|
||||||
DownloadQueueItem.created_at >= cutoff_date
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
downloads = result.scalars().all()
|
|
||||||
|
|
||||||
if not downloads:
|
|
||||||
return DownloadStats()
|
|
||||||
|
|
||||||
successful = [d for d in downloads
|
|
||||||
if d.status == DownloadStatus.COMPLETED]
|
|
||||||
failed = [d for d in downloads
|
|
||||||
if d.status == DownloadStatus.FAILED]
|
|
||||||
|
|
||||||
total_bytes = sum(d.total_bytes or 0 for d in successful)
|
|
||||||
avg_speed_list = [
|
|
||||||
d.download_speed or 0.0 for d in successful if d.download_speed
|
|
||||||
]
|
|
||||||
avg_speed_mbps = (
|
|
||||||
sum(avg_speed_list) / len(avg_speed_list) / (1024 * 1024)
|
|
||||||
if avg_speed_list
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
success_rate = (
|
|
||||||
len(successful) / len(downloads) * 100 if downloads else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
return DownloadStats(
|
|
||||||
total_downloads=len(downloads),
|
|
||||||
successful_downloads=len(successful),
|
|
||||||
failed_downloads=len(failed),
|
|
||||||
total_bytes_downloaded=total_bytes,
|
|
||||||
average_speed_mbps=avg_speed_mbps,
|
|
||||||
success_rate=success_rate,
|
|
||||||
average_duration_seconds=0.0, # Not available in model
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_series_popularity(
|
|
||||||
self, db: AsyncSession, limit: int = 10
|
|
||||||
) -> List[SeriesPopularity]:
|
|
||||||
"""Get most popular series by download count.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
limit: Maximum number of series to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SeriesPopularity objects
|
|
||||||
"""
|
|
||||||
# Use raw SQL approach since we need to group and join
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
query = text("""
|
|
||||||
SELECT
|
|
||||||
s.title as series_name,
|
|
||||||
COUNT(d.id) as download_count,
|
|
||||||
SUM(d.total_bytes) as total_size,
|
|
||||||
MAX(d.created_at) as last_download,
|
|
||||||
SUM(CASE WHEN d.status = 'COMPLETED'
|
|
||||||
THEN 1 ELSE 0 END) as successful
|
|
||||||
FROM download_queue d
|
|
||||||
JOIN anime_series s ON d.series_id = s.id
|
|
||||||
GROUP BY s.id, s.title
|
|
||||||
ORDER BY download_count DESC
|
|
||||||
LIMIT :limit
|
|
||||||
""")
|
|
||||||
|
|
||||||
result = await db.execute(query, {"limit": limit})
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
popularity = []
|
|
||||||
for row in rows:
|
|
||||||
success_rate = 0.0
|
|
||||||
download_count = row[1] or 0
|
|
||||||
if download_count > 0:
|
|
||||||
successful = row[4] or 0
|
|
||||||
success_rate = (successful / download_count * 100)
|
|
||||||
|
|
||||||
popularity.append(
|
|
||||||
SeriesPopularity(
|
|
||||||
series_name=row[0] or "Unknown",
|
|
||||||
download_count=download_count,
|
|
||||||
total_size_bytes=row[2] or 0,
|
|
||||||
last_download=row[3].isoformat()
|
|
||||||
if row[3]
|
|
||||||
else None,
|
|
||||||
success_rate=success_rate,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return popularity
|
|
||||||
|
|
||||||
def get_storage_analysis(self) -> StorageAnalysis:
|
|
||||||
"""Get current storage usage analysis.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
StorageAnalysis with storage breakdown
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get disk usage for data directory
|
|
||||||
disk = psutil.disk_usage("/")
|
|
||||||
total = disk.total
|
|
||||||
used = disk.used
|
|
||||||
free = disk.free
|
|
||||||
|
|
||||||
analysis = StorageAnalysis(
|
|
||||||
total_storage_bytes=total,
|
|
||||||
used_storage_bytes=used,
|
|
||||||
free_storage_bytes=free,
|
|
||||||
storage_percent_used=disk.percent,
|
|
||||||
downloads_directory_size_bytes=self._get_dir_size(
|
|
||||||
Path("data")
|
|
||||||
),
|
|
||||||
cache_directory_size_bytes=self._get_dir_size(
|
|
||||||
Path("data") / "cache"
|
|
||||||
),
|
|
||||||
logs_directory_size_bytes=self._get_dir_size(
|
|
||||||
Path("logs")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Storage analysis failed: {e}")
|
|
||||||
return StorageAnalysis()
|
|
||||||
|
|
||||||
def _get_dir_size(self, path: Path) -> int:
|
|
||||||
"""Calculate total size of directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Directory path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total size in bytes
|
|
||||||
"""
|
|
||||||
if not path.exists():
|
|
||||||
return 0
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
try:
|
|
||||||
for item in path.rglob("*"):
|
|
||||||
if item.is_file():
|
|
||||||
total += item.stat().st_size
|
|
||||||
except (OSError, PermissionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return total
|
|
||||||
|
|
||||||
async def get_performance_report(
|
|
||||||
self, db: AsyncSession, hours: int = 24
|
|
||||||
) -> PerformanceReport:
|
|
||||||
"""Get performance metrics for the specified period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
hours: Number of hours to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PerformanceReport with performance metrics
|
|
||||||
"""
|
|
||||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
|
||||||
|
|
||||||
# Get download metrics
|
|
||||||
stmt = select(DownloadQueueItem).where(
|
|
||||||
DownloadQueueItem.created_at >= cutoff_time
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
downloads = result.scalars().all()
|
|
||||||
|
|
||||||
downloads_per_hour = len(downloads) / max(hours, 1)
|
|
||||||
|
|
||||||
# Get queue size over time (estimated from analytics)
|
|
||||||
analytics = self._load_analytics()
|
|
||||||
performance_samples = analytics.get("performance_samples", [])
|
|
||||||
|
|
||||||
# Filter recent samples
|
|
||||||
recent_samples = [
|
|
||||||
s
|
|
||||||
for s in performance_samples
|
|
||||||
if datetime.fromisoformat(s.get("timestamp", "2000-01-01"))
|
|
||||||
>= cutoff_time
|
|
||||||
]
|
|
||||||
|
|
||||||
avg_queue = sum(
|
|
||||||
s.get("queue_size", 0) for s in recent_samples
|
|
||||||
) / len(recent_samples) if recent_samples else 0.0
|
|
||||||
|
|
||||||
# Get memory and CPU stats
|
|
||||||
process = psutil.Process()
|
|
||||||
memory_info = process.memory_info()
|
|
||||||
peak_memory_mb = memory_info.rss / (1024 * 1024)
|
|
||||||
|
|
||||||
cpu_percent = process.cpu_percent(interval=1)
|
|
||||||
|
|
||||||
# Calculate error rate
|
|
||||||
failed_count = sum(
|
|
||||||
1 for d in downloads
|
|
||||||
if d.status == DownloadStatus.FAILED
|
|
||||||
)
|
|
||||||
error_rate = (
|
|
||||||
failed_count / len(downloads) * 100 if downloads else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get uptime
|
|
||||||
boot_time = datetime.fromtimestamp(psutil.boot_time())
|
|
||||||
uptime_seconds = (datetime.now() - boot_time).total_seconds()
|
|
||||||
|
|
||||||
return PerformanceReport(
|
|
||||||
period_start=cutoff_time.isoformat(),
|
|
||||||
period_end=datetime.now().isoformat(),
|
|
||||||
downloads_per_hour=downloads_per_hour,
|
|
||||||
average_queue_size=avg_queue,
|
|
||||||
peak_memory_usage_mb=peak_memory_mb,
|
|
||||||
average_cpu_percent=cpu_percent,
|
|
||||||
uptime_seconds=uptime_seconds,
|
|
||||||
error_rate=error_rate,
|
|
||||||
samples=recent_samples[-100:], # Keep last 100 samples
|
|
||||||
)
|
|
||||||
|
|
||||||
def record_performance_sample(
|
|
||||||
self,
|
|
||||||
queue_size: int,
|
|
||||||
active_downloads: int,
|
|
||||||
cpu_percent: float,
|
|
||||||
memory_mb: float,
|
|
||||||
) -> None:
|
|
||||||
"""Record a performance metric sample.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
queue_size: Current queue size
|
|
||||||
active_downloads: Number of active downloads
|
|
||||||
cpu_percent: Current CPU usage percentage
|
|
||||||
memory_mb: Current memory usage in MB
|
|
||||||
"""
|
|
||||||
analytics = self._load_analytics()
|
|
||||||
samples = analytics.get("performance_samples", [])
|
|
||||||
|
|
||||||
sample = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"queue_size": queue_size,
|
|
||||||
"active_downloads": active_downloads,
|
|
||||||
"cpu_percent": cpu_percent,
|
|
||||||
"memory_mb": memory_mb,
|
|
||||||
}
|
|
||||||
|
|
||||||
samples.append(sample)
|
|
||||||
|
|
||||||
# Keep only recent samples (7 days worth at 1 sample per minute)
|
|
||||||
max_samples = 7 * 24 * 60
|
|
||||||
if len(samples) > max_samples:
|
|
||||||
samples = samples[-max_samples:]
|
|
||||||
|
|
||||||
analytics["performance_samples"] = samples
|
|
||||||
self._save_analytics(analytics)
|
|
||||||
|
|
||||||
async def generate_summary_report(
|
|
||||||
self, db: AsyncSession
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Generate comprehensive analytics summary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Summary report with all analytics
|
|
||||||
"""
|
|
||||||
download_stats = await self.get_download_stats(db)
|
|
||||||
series_popularity = await self.get_series_popularity(db, limit=5)
|
|
||||||
storage = self.get_storage_analysis()
|
|
||||||
performance = await self.get_performance_report(db)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"download_stats": asdict(download_stats),
|
|
||||||
"series_popularity": [
|
|
||||||
asdict(s) for s in series_popularity
|
|
||||||
],
|
|
||||||
"storage_analysis": asdict(storage),
|
|
||||||
"performance_report": asdict(performance),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_analytics_service_instance: Optional[AnalyticsService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_analytics_service() -> AnalyticsService:
|
|
||||||
"""Get or create singleton analytics service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AnalyticsService instance
|
|
||||||
"""
|
|
||||||
global _analytics_service_instance
|
|
||||||
if _analytics_service_instance is None:
|
|
||||||
_analytics_service_instance = AnalyticsService()
|
|
||||||
return _analytics_service_instance
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Callable, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@ -22,153 +21,222 @@ class AnimeServiceError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class AnimeService:
|
class AnimeService:
|
||||||
"""Wraps the blocking SeriesApp for use in the FastAPI web layer.
|
"""Wraps SeriesApp for use in the FastAPI web layer.
|
||||||
|
|
||||||
- Runs blocking operations in a threadpool
|
- SeriesApp methods are now async, no need for threadpool
|
||||||
|
- Subscribes to SeriesApp events for progress tracking
|
||||||
- Exposes async methods
|
- Exposes async methods
|
||||||
- Adds simple in-memory caching for read operations
|
- Adds simple in-memory caching for read operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
directory: str,
|
series_app: SeriesApp,
|
||||||
max_workers: int = 4,
|
|
||||||
progress_service: Optional[ProgressService] = None,
|
progress_service: Optional[ProgressService] = None,
|
||||||
):
|
):
|
||||||
self._directory = directory
|
self._app = series_app
|
||||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
self._directory = series_app.directory_to_search
|
||||||
self._progress_service = progress_service or get_progress_service()
|
self._progress_service = progress_service or get_progress_service()
|
||||||
# SeriesApp is blocking; instantiate per-service
|
# Subscribe to SeriesApp events
|
||||||
|
# Note: Events library uses assignment (=), not += operator
|
||||||
try:
|
try:
|
||||||
self._app = SeriesApp(directory)
|
self._app.download_status = self._on_download_status
|
||||||
|
self._app.scan_status = self._on_scan_status
|
||||||
|
logger.debug("Successfully subscribed to SeriesApp events")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to initialize SeriesApp")
|
logger.exception("Failed to subscribe to SeriesApp events")
|
||||||
raise AnimeServiceError("Initialization failed") from e
|
raise AnimeServiceError("Initialization failed") from e
|
||||||
|
|
||||||
async def _run_in_executor(self, func, *args, **kwargs):
|
def _on_download_status(self, args) -> None:
|
||||||
loop = asyncio.get_event_loop()
|
"""Handle download status events from SeriesApp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: DownloadStatusEventArgs from SeriesApp
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs))
|
# Map SeriesApp download events to progress service
|
||||||
except Exception as e:
|
if args.status == "started":
|
||||||
logger.exception("Executor task failed")
|
asyncio.create_task(
|
||||||
raise AnimeServiceError(str(e)) from e
|
self._progress_service.start_progress(
|
||||||
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
|
progress_type=ProgressType.DOWNLOAD,
|
||||||
|
title=f"Downloading {args.serie_folder}",
|
||||||
|
message=f"S{args.season:02d}E{args.episode:02d}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "progress":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.update_progress(
|
||||||
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
|
current=int(args.progress),
|
||||||
|
total=100,
|
||||||
|
message=args.message or "Downloading...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "completed":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.complete_progress(
|
||||||
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
|
message="Download completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "failed":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.fail_progress(
|
||||||
|
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
|
||||||
|
error_message=args.message or str(args.error),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error handling download status event",
|
||||||
|
error=str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_scan_status(self, args) -> None:
|
||||||
|
"""Handle scan status events from SeriesApp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: ScanStatusEventArgs from SeriesApp
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scan_id = "library_scan"
|
||||||
|
|
||||||
|
# Map SeriesApp scan events to progress service
|
||||||
|
if args.status == "started":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.start_progress(
|
||||||
|
progress_id=scan_id,
|
||||||
|
progress_type=ProgressType.SCAN,
|
||||||
|
title="Scanning anime library",
|
||||||
|
message=args.message or "Initializing scan...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "progress":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.update_progress(
|
||||||
|
progress_id=scan_id,
|
||||||
|
current=args.current,
|
||||||
|
total=args.total,
|
||||||
|
message=args.message or f"Scanning: {args.folder}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "completed":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.complete_progress(
|
||||||
|
progress_id=scan_id,
|
||||||
|
message=args.message or "Scan completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "failed":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.fail_progress(
|
||||||
|
progress_id=scan_id,
|
||||||
|
error_message=args.message or str(args.error),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif args.status == "cancelled":
|
||||||
|
asyncio.create_task(
|
||||||
|
self._progress_service.fail_progress(
|
||||||
|
progress_id=scan_id,
|
||||||
|
error_message=args.message or "Scan cancelled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error handling scan status event", error=str(exc))
|
||||||
|
|
||||||
@lru_cache(maxsize=128)
|
@lru_cache(maxsize=128)
|
||||||
def _cached_list_missing(self) -> List[dict]:
|
def _cached_list_missing(self) -> List[dict]:
|
||||||
# Synchronous cached call used by async wrapper
|
# Synchronous cached call - SeriesApp.series_list is populated
|
||||||
|
# during initialization
|
||||||
try:
|
try:
|
||||||
series = self._app.series_list
|
series = self._app.series_list
|
||||||
# normalize to simple dicts
|
# normalize to simple dicts
|
||||||
return [s.to_dict() if hasattr(s, "to_dict") else s for s in series]
|
return [
|
||||||
except Exception as e:
|
s.to_dict() if hasattr(s, "to_dict") else s
|
||||||
|
for s in series
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
logger.exception("Failed to get missing episodes list")
|
logger.exception("Failed to get missing episodes list")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def list_missing(self) -> List[dict]:
|
async def list_missing(self) -> List[dict]:
|
||||||
"""Return list of series with missing episodes."""
|
"""Return list of series with missing episodes."""
|
||||||
try:
|
try:
|
||||||
return await self._run_in_executor(self._cached_list_missing)
|
# series_list is already populated, just access it
|
||||||
|
return self._cached_list_missing()
|
||||||
except AnimeServiceError:
|
except AnimeServiceError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.exception("list_missing failed")
|
logger.exception("list_missing failed")
|
||||||
raise AnimeServiceError("Failed to list missing series") from e
|
raise AnimeServiceError("Failed to list missing series") from exc
|
||||||
|
|
||||||
async def search(self, query: str) -> List[dict]:
|
async def search(self, query: str) -> List[dict]:
|
||||||
"""Search for series using underlying loader.Search."""
|
"""Search for series using underlying loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of search results as dictionaries
|
||||||
|
"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
result = await self._run_in_executor(self._app.search, query)
|
# SeriesApp.search is now async
|
||||||
# result may already be list of dicts or objects
|
result = await self._app.search(query)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.exception("search failed")
|
logger.exception("search failed")
|
||||||
raise AnimeServiceError("Search failed") from e
|
raise AnimeServiceError("Search failed") from exc
|
||||||
|
|
||||||
async def rescan(self, callback: Optional[Callable] = None) -> None:
|
async def rescan(self) -> None:
|
||||||
"""Trigger a re-scan. Accepts an optional callback function.
|
"""Trigger a re-scan.
|
||||||
|
|
||||||
The callback is executed in the threadpool by SeriesApp.
|
The SeriesApp now handles progress tracking via events which are
|
||||||
Progress updates are tracked and broadcasted via ProgressService.
|
forwarded to the ProgressService through event handlers.
|
||||||
"""
|
"""
|
||||||
scan_id = "library_scan"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start progress tracking
|
# SeriesApp.re_scan is now async and handles events internally
|
||||||
await self._progress_service.start_progress(
|
await self._app.re_scan()
|
||||||
progress_id=scan_id,
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Scanning anime library",
|
|
||||||
message="Initializing scan...",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create wrapped callback for progress updates
|
|
||||||
def progress_callback(progress_data: dict) -> None:
|
|
||||||
"""Update progress during scan."""
|
|
||||||
try:
|
|
||||||
if callback:
|
|
||||||
callback(progress_data)
|
|
||||||
|
|
||||||
# Update progress service
|
|
||||||
current = progress_data.get("current", 0)
|
|
||||||
total = progress_data.get("total", 0)
|
|
||||||
message = progress_data.get("message", "Scanning...")
|
|
||||||
|
|
||||||
# Schedule the coroutine without waiting for it
|
|
||||||
# This is safe because we don't need the result
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if loop.is_running():
|
|
||||||
asyncio.ensure_future(
|
|
||||||
self._progress_service.update_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
current=current,
|
|
||||||
total=total,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Scan progress callback error", error=str(e))
|
|
||||||
|
|
||||||
# Run scan
|
|
||||||
await self._run_in_executor(self._app.ReScan, progress_callback)
|
|
||||||
|
|
||||||
# invalidate cache
|
# invalidate cache
|
||||||
try:
|
try:
|
||||||
self._cached_list_missing.cache_clear()
|
self._cached_list_missing.cache_clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Complete progress tracking
|
|
||||||
await self._progress_service.complete_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
message="Scan completed successfully",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("rescan failed")
|
|
||||||
|
|
||||||
# Fail progress tracking
|
|
||||||
await self._progress_service.fail_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
raise AnimeServiceError("Rescan failed") from e
|
|
||||||
|
|
||||||
async def download(self, serie_folder: str, season: int, episode: int, key: str, callback=None) -> bool:
|
except Exception as exc:
|
||||||
"""Start a download via the underlying loader.
|
logger.exception("rescan failed")
|
||||||
|
raise AnimeServiceError("Rescan failed") from exc
|
||||||
|
|
||||||
|
async def download(
|
||||||
|
self,
|
||||||
|
serie_folder: str,
|
||||||
|
season: int,
|
||||||
|
episode: int,
|
||||||
|
key: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Start a download.
|
||||||
|
|
||||||
|
The SeriesApp now handles progress tracking via events which are
|
||||||
|
forwarded to the ProgressService through event handlers.
|
||||||
|
|
||||||
Returns True on success or raises AnimeServiceError on failure.
|
Returns True on success or raises AnimeServiceError on failure.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await self._run_in_executor(self._app.download, serie_folder, season, episode, key, callback)
|
# SeriesApp.download is now async and handles events internally
|
||||||
return bool(result)
|
return await self._app.download(
|
||||||
except Exception as e:
|
serie_folder=serie_folder,
|
||||||
|
season=season,
|
||||||
|
episode=episode,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
logger.exception("download failed")
|
logger.exception("download failed")
|
||||||
raise AnimeServiceError("Download failed") from e
|
raise AnimeServiceError("Download failed") from exc
|
||||||
|
|
||||||
|
|
||||||
def get_anime_service(directory: str = "./") -> AnimeService:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used by FastAPI dependency injection."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
return AnimeService(directory)
|
return AnimeService(series_app)
|
||||||
|
|||||||
@ -1,610 +0,0 @@
|
|||||||
"""
|
|
||||||
Audit Service for AniWorld.
|
|
||||||
|
|
||||||
This module provides comprehensive audit logging for security-critical
|
|
||||||
operations including authentication, configuration changes, and downloads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AuditEventType(str, Enum):
|
|
||||||
"""Types of audit events."""
|
|
||||||
|
|
||||||
# Authentication events
|
|
||||||
AUTH_SETUP = "auth.setup"
|
|
||||||
AUTH_LOGIN_SUCCESS = "auth.login.success"
|
|
||||||
AUTH_LOGIN_FAILURE = "auth.login.failure"
|
|
||||||
AUTH_LOGOUT = "auth.logout"
|
|
||||||
AUTH_TOKEN_REFRESH = "auth.token.refresh"
|
|
||||||
AUTH_TOKEN_INVALID = "auth.token.invalid"
|
|
||||||
|
|
||||||
# Configuration events
|
|
||||||
CONFIG_READ = "config.read"
|
|
||||||
CONFIG_UPDATE = "config.update"
|
|
||||||
CONFIG_BACKUP = "config.backup"
|
|
||||||
CONFIG_RESTORE = "config.restore"
|
|
||||||
CONFIG_DELETE = "config.delete"
|
|
||||||
|
|
||||||
# Download events
|
|
||||||
DOWNLOAD_ADDED = "download.added"
|
|
||||||
DOWNLOAD_STARTED = "download.started"
|
|
||||||
DOWNLOAD_COMPLETED = "download.completed"
|
|
||||||
DOWNLOAD_FAILED = "download.failed"
|
|
||||||
DOWNLOAD_CANCELLED = "download.cancelled"
|
|
||||||
DOWNLOAD_REMOVED = "download.removed"
|
|
||||||
|
|
||||||
# Queue events
|
|
||||||
QUEUE_STARTED = "queue.started"
|
|
||||||
QUEUE_STOPPED = "queue.stopped"
|
|
||||||
QUEUE_PAUSED = "queue.paused"
|
|
||||||
QUEUE_RESUMED = "queue.resumed"
|
|
||||||
QUEUE_CLEARED = "queue.cleared"
|
|
||||||
|
|
||||||
# System events
|
|
||||||
SYSTEM_STARTUP = "system.startup"
|
|
||||||
SYSTEM_SHUTDOWN = "system.shutdown"
|
|
||||||
SYSTEM_ERROR = "system.error"
|
|
||||||
|
|
||||||
|
|
||||||
class AuditEventSeverity(str, Enum):
|
|
||||||
"""Severity levels for audit events."""
|
|
||||||
|
|
||||||
DEBUG = "debug"
|
|
||||||
INFO = "info"
|
|
||||||
WARNING = "warning"
|
|
||||||
ERROR = "error"
|
|
||||||
CRITICAL = "critical"
|
|
||||||
|
|
||||||
|
|
||||||
class AuditEvent(BaseModel):
|
|
||||||
"""Audit event model."""
|
|
||||||
|
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
event_type: AuditEventType
|
|
||||||
severity: AuditEventSeverity = AuditEventSeverity.INFO
|
|
||||||
user_id: Optional[str] = None
|
|
||||||
ip_address: Optional[str] = None
|
|
||||||
user_agent: Optional[str] = None
|
|
||||||
resource: Optional[str] = None
|
|
||||||
action: Optional[str] = None
|
|
||||||
status: str = "success"
|
|
||||||
message: str
|
|
||||||
details: Optional[Dict[str, Any]] = None
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
"""Pydantic config."""
|
|
||||||
|
|
||||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogStorage:
|
|
||||||
"""Base class for audit log storage backends."""
|
|
||||||
|
|
||||||
async def write_event(self, event: AuditEvent) -> None:
|
|
||||||
"""
|
|
||||||
Write an audit event to storage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Audit event to write
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
async def read_events(
|
|
||||||
self,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
event_types: Optional[List[AuditEventType]] = None,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> List[AuditEvent]:
|
|
||||||
"""
|
|
||||||
Read audit events from storage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_time: Start of time range
|
|
||||||
end_time: End of time range
|
|
||||||
event_types: Filter by event types
|
|
||||||
user_id: Filter by user ID
|
|
||||||
limit: Maximum number of events to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of audit events
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
async def cleanup_old_events(self, days: int = 90) -> int:
|
|
||||||
"""
|
|
||||||
Clean up audit events older than specified days.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to retain
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of events deleted
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class FileAuditLogStorage(AuditLogStorage):
|
|
||||||
"""File-based audit log storage."""
|
|
||||||
|
|
||||||
def __init__(self, log_directory: str = "logs/audit"):
|
|
||||||
"""
|
|
||||||
Initialize file-based audit log storage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_directory: Directory to store audit logs
|
|
||||||
"""
|
|
||||||
self.log_directory = Path(log_directory)
|
|
||||||
self.log_directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._current_date: Optional[str] = None
|
|
||||||
self._current_file: Optional[Path] = None
|
|
||||||
|
|
||||||
def _get_log_file(self, date: datetime) -> Path:
|
|
||||||
"""
|
|
||||||
Get log file path for a specific date.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
date: Date for log file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to log file
|
|
||||||
"""
|
|
||||||
date_str = date.strftime("%Y-%m-%d")
|
|
||||||
return self.log_directory / f"audit_{date_str}.jsonl"
|
|
||||||
|
|
||||||
async def write_event(self, event: AuditEvent) -> None:
|
|
||||||
"""
|
|
||||||
Write an audit event to file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Audit event to write
|
|
||||||
"""
|
|
||||||
log_file = self._get_log_file(event.timestamp)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
|
||||||
f.write(event.model_dump_json() + "\n")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to write audit event to file: {e}")
|
|
||||||
|
|
||||||
async def read_events(
|
|
||||||
self,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
event_types: Optional[List[AuditEventType]] = None,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> List[AuditEvent]:
|
|
||||||
"""
|
|
||||||
Read audit events from files.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_time: Start of time range
|
|
||||||
end_time: End of time range
|
|
||||||
event_types: Filter by event types
|
|
||||||
user_id: Filter by user ID
|
|
||||||
limit: Maximum number of events to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of audit events
|
|
||||||
"""
|
|
||||||
if start_time is None:
|
|
||||||
start_time = datetime.utcnow() - timedelta(days=7)
|
|
||||||
if end_time is None:
|
|
||||||
end_time = datetime.utcnow()
|
|
||||||
|
|
||||||
events: List[AuditEvent] = []
|
|
||||||
current_date = start_time.date()
|
|
||||||
end_date = end_time.date()
|
|
||||||
|
|
||||||
# Read from all log files in date range
|
|
||||||
while current_date <= end_date and len(events) < limit:
|
|
||||||
log_file = self._get_log_file(datetime.combine(current_date, datetime.min.time()))
|
|
||||||
|
|
||||||
if log_file.exists():
|
|
||||||
try:
|
|
||||||
with open(log_file, "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if len(events) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
event_data = json.loads(line.strip())
|
|
||||||
event = AuditEvent(**event_data)
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if event.timestamp < start_time or event.timestamp > end_time:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if event_types and event.event_type not in event_types:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if user_id and event.user_id != user_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
events.append(event)
|
|
||||||
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
|
||||||
logger.warning(f"Failed to parse audit event: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to read audit log file {log_file}: {e}")
|
|
||||||
|
|
||||||
current_date += timedelta(days=1)
|
|
||||||
|
|
||||||
# Sort by timestamp descending
|
|
||||||
events.sort(key=lambda e: e.timestamp, reverse=True)
|
|
||||||
return events[:limit]
|
|
||||||
|
|
||||||
async def cleanup_old_events(self, days: int = 90) -> int:
|
|
||||||
"""
|
|
||||||
Clean up audit events older than specified days.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to retain
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of files deleted
|
|
||||||
"""
|
|
||||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
|
||||||
deleted_count = 0
|
|
||||||
|
|
||||||
for log_file in self.log_directory.glob("audit_*.jsonl"):
|
|
||||||
try:
|
|
||||||
# Extract date from filename
|
|
||||||
date_str = log_file.stem.replace("audit_", "")
|
|
||||||
file_date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
||||||
|
|
||||||
if file_date < cutoff_date:
|
|
||||||
log_file.unlink()
|
|
||||||
deleted_count += 1
|
|
||||||
logger.info(f"Deleted old audit log: {log_file}")
|
|
||||||
|
|
||||||
except (ValueError, OSError) as e:
|
|
||||||
logger.warning(f"Failed to process audit log file {log_file}: {e}")
|
|
||||||
|
|
||||||
return deleted_count
|
|
||||||
|
|
||||||
|
|
||||||
class AuditService:
|
|
||||||
"""Main audit service for logging security events."""
|
|
||||||
|
|
||||||
def __init__(self, storage: Optional[AuditLogStorage] = None):
|
|
||||||
"""
|
|
||||||
Initialize audit service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
storage: Storage backend for audit logs
|
|
||||||
"""
|
|
||||||
self.storage = storage or FileAuditLogStorage()
|
|
||||||
|
|
||||||
async def log_event(
|
|
||||||
self,
|
|
||||||
event_type: AuditEventType,
|
|
||||||
message: str,
|
|
||||||
severity: AuditEventSeverity = AuditEventSeverity.INFO,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
user_agent: Optional[str] = None,
|
|
||||||
resource: Optional[str] = None,
|
|
||||||
action: Optional[str] = None,
|
|
||||||
status: str = "success",
|
|
||||||
details: Optional[Dict[str, Any]] = None,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Log an audit event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_type: Type of event
|
|
||||||
message: Human-readable message
|
|
||||||
severity: Event severity
|
|
||||||
user_id: User identifier
|
|
||||||
ip_address: Client IP address
|
|
||||||
user_agent: Client user agent
|
|
||||||
resource: Resource being accessed
|
|
||||||
action: Action performed
|
|
||||||
status: Operation status
|
|
||||||
details: Additional details
|
|
||||||
session_id: Session identifier
|
|
||||||
"""
|
|
||||||
event = AuditEvent(
|
|
||||||
event_type=event_type,
|
|
||||||
severity=severity,
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
resource=resource,
|
|
||||||
action=action,
|
|
||||||
status=status,
|
|
||||||
message=message,
|
|
||||||
details=details,
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.storage.write_event(event)
|
|
||||||
|
|
||||||
# Also log to application logger for high severity events
|
|
||||||
if severity in [AuditEventSeverity.ERROR, AuditEventSeverity.CRITICAL]:
|
|
||||||
logger.error(f"Audit: {message}", extra={"audit_event": event.model_dump()})
|
|
||||||
elif severity == AuditEventSeverity.WARNING:
|
|
||||||
logger.warning(f"Audit: {message}", extra={"audit_event": event.model_dump()})
|
|
||||||
|
|
||||||
async def log_auth_setup(
|
|
||||||
self, user_id: str, ip_address: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""Log initial authentication setup."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.AUTH_SETUP,
|
|
||||||
message=f"Authentication configured by user {user_id}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
action="setup",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_login_success(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
user_agent: Optional[str] = None,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Log successful login."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.AUTH_LOGIN_SUCCESS,
|
|
||||||
message=f"User {user_id} logged in successfully",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
session_id=session_id,
|
|
||||||
action="login",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_login_failure(
|
|
||||||
self,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
user_agent: Optional[str] = None,
|
|
||||||
reason: str = "Invalid credentials",
|
|
||||||
) -> None:
|
|
||||||
"""Log failed login attempt."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.AUTH_LOGIN_FAILURE,
|
|
||||||
message=f"Login failed for user {user_id or 'unknown'}: {reason}",
|
|
||||||
severity=AuditEventSeverity.WARNING,
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
status="failure",
|
|
||||||
action="login",
|
|
||||||
details={"reason": reason},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_logout(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Log user logout."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.AUTH_LOGOUT,
|
|
||||||
message=f"User {user_id} logged out",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
session_id=session_id,
|
|
||||||
action="logout",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_config_update(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
changes: Dict[str, Any],
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Log configuration update."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.CONFIG_UPDATE,
|
|
||||||
message=f"Configuration updated by user {user_id}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
resource="config",
|
|
||||||
action="update",
|
|
||||||
details={"changes": changes},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_config_backup(
|
|
||||||
self, user_id: str, backup_file: str, ip_address: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""Log configuration backup."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.CONFIG_BACKUP,
|
|
||||||
message=f"Configuration backed up by user {user_id}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
resource="config",
|
|
||||||
action="backup",
|
|
||||||
details={"backup_file": backup_file},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_config_restore(
|
|
||||||
self, user_id: str, backup_file: str, ip_address: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""Log configuration restore."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.CONFIG_RESTORE,
|
|
||||||
message=f"Configuration restored by user {user_id}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
resource="config",
|
|
||||||
action="restore",
|
|
||||||
details={"backup_file": backup_file},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_download_added(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
series_name: str,
|
|
||||||
episodes: List[str],
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Log download added to queue."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.DOWNLOAD_ADDED,
|
|
||||||
message=f"Download added by user {user_id}: {series_name}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
resource=series_name,
|
|
||||||
action="add",
|
|
||||||
details={"episodes": episodes},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_download_completed(
|
|
||||||
self, series_name: str, episode: str, file_path: str
|
|
||||||
) -> None:
|
|
||||||
"""Log completed download."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.DOWNLOAD_COMPLETED,
|
|
||||||
message=f"Download completed: {series_name} - {episode}",
|
|
||||||
resource=series_name,
|
|
||||||
action="download",
|
|
||||||
details={"episode": episode, "file_path": file_path},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_download_failed(
|
|
||||||
self, series_name: str, episode: str, error: str
|
|
||||||
) -> None:
|
|
||||||
"""Log failed download."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.DOWNLOAD_FAILED,
|
|
||||||
message=f"Download failed: {series_name} - {episode}",
|
|
||||||
severity=AuditEventSeverity.ERROR,
|
|
||||||
resource=series_name,
|
|
||||||
action="download",
|
|
||||||
status="failure",
|
|
||||||
details={"episode": episode, "error": error},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_queue_operation(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
operation: str,
|
|
||||||
ip_address: Optional[str] = None,
|
|
||||||
details: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Log queue operation."""
|
|
||||||
event_type_map = {
|
|
||||||
"start": AuditEventType.QUEUE_STARTED,
|
|
||||||
"stop": AuditEventType.QUEUE_STOPPED,
|
|
||||||
"pause": AuditEventType.QUEUE_PAUSED,
|
|
||||||
"resume": AuditEventType.QUEUE_RESUMED,
|
|
||||||
"clear": AuditEventType.QUEUE_CLEARED,
|
|
||||||
}
|
|
||||||
|
|
||||||
event_type = event_type_map.get(operation, AuditEventType.SYSTEM_ERROR)
|
|
||||||
await self.log_event(
|
|
||||||
event_type=event_type,
|
|
||||||
message=f"Queue {operation} by user {user_id}",
|
|
||||||
user_id=user_id,
|
|
||||||
ip_address=ip_address,
|
|
||||||
resource="queue",
|
|
||||||
action=operation,
|
|
||||||
details=details,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def log_system_error(
|
|
||||||
self, error: str, details: Optional[Dict[str, Any]] = None
|
|
||||||
) -> None:
|
|
||||||
"""Log system error."""
|
|
||||||
await self.log_event(
|
|
||||||
event_type=AuditEventType.SYSTEM_ERROR,
|
|
||||||
message=f"System error: {error}",
|
|
||||||
severity=AuditEventSeverity.ERROR,
|
|
||||||
status="error",
|
|
||||||
details=details,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_events(
|
|
||||||
self,
|
|
||||||
start_time: Optional[datetime] = None,
|
|
||||||
end_time: Optional[datetime] = None,
|
|
||||||
event_types: Optional[List[AuditEventType]] = None,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> List[AuditEvent]:
|
|
||||||
"""
|
|
||||||
Get audit events with filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_time: Start of time range
|
|
||||||
end_time: End of time range
|
|
||||||
event_types: Filter by event types
|
|
||||||
user_id: Filter by user ID
|
|
||||||
limit: Maximum number of events to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of audit events
|
|
||||||
"""
|
|
||||||
return await self.storage.read_events(
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
event_types=event_types,
|
|
||||||
user_id=user_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def cleanup_old_events(self, days: int = 90) -> int:
|
|
||||||
"""
|
|
||||||
Clean up old audit events.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to retain
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of events deleted
|
|
||||||
"""
|
|
||||||
return await self.storage.cleanup_old_events(days)
|
|
||||||
|
|
||||||
|
|
||||||
# Global audit service instance
|
|
||||||
_audit_service: Optional[AuditService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_audit_service() -> AuditService:
|
|
||||||
"""
|
|
||||||
Get the global audit service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AuditService instance
|
|
||||||
"""
|
|
||||||
global _audit_service
|
|
||||||
if _audit_service is None:
|
|
||||||
_audit_service = AuditService()
|
|
||||||
return _audit_service
|
|
||||||
|
|
||||||
|
|
||||||
def configure_audit_service(storage: Optional[AuditLogStorage] = None) -> AuditService:
|
|
||||||
"""
|
|
||||||
Configure the global audit service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
storage: Custom storage backend
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured AuditService instance
|
|
||||||
"""
|
|
||||||
global _audit_service
|
|
||||||
_audit_service = AuditService(storage=storage)
|
|
||||||
return _audit_service
|
|
||||||
@ -1,432 +0,0 @@
|
|||||||
"""Backup and restore service for configuration and data management."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BackupInfo:
|
|
||||||
"""Information about a backup."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
timestamp: datetime
|
|
||||||
size_bytes: int
|
|
||||||
backup_type: str # 'config', 'data', 'full'
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BackupService:
|
|
||||||
"""Service for managing backups and restores."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
backup_dir: str = "data/backups",
|
|
||||||
config_dir: str = "data",
|
|
||||||
database_path: str = "data/aniworld.db",
|
|
||||||
):
|
|
||||||
"""Initialize backup service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_dir: Directory to store backups.
|
|
||||||
config_dir: Directory containing configuration files.
|
|
||||||
database_path: Path to the database file.
|
|
||||||
"""
|
|
||||||
self.backup_dir = Path(backup_dir)
|
|
||||||
self.config_dir = Path(config_dir)
|
|
||||||
self.database_path = Path(database_path)
|
|
||||||
|
|
||||||
# Create backup directory if it doesn't exist
|
|
||||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def backup_configuration(
|
|
||||||
self, description: str = ""
|
|
||||||
) -> Optional[BackupInfo]:
|
|
||||||
"""Create a configuration backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Optional description for the backup.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupInfo: Information about the created backup.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
timestamp = datetime.now()
|
|
||||||
backup_name = (
|
|
||||||
f"config_{timestamp.strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
|
||||||
)
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
with tarfile.open(backup_path, "w:gz") as tar:
|
|
||||||
# Add configuration files
|
|
||||||
config_files = [
|
|
||||||
self.config_dir / "config.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
for config_file in config_files:
|
|
||||||
if config_file.exists():
|
|
||||||
tar.add(config_file, arcname=config_file.name)
|
|
||||||
|
|
||||||
size_bytes = backup_path.stat().st_size
|
|
||||||
|
|
||||||
info = BackupInfo(
|
|
||||||
name=backup_name,
|
|
||||||
timestamp=timestamp,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
backup_type="config",
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Configuration backup created: {backup_name}")
|
|
||||||
return info
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create configuration backup: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def backup_database(
|
|
||||||
self, description: str = ""
|
|
||||||
) -> Optional[BackupInfo]:
|
|
||||||
"""Create a database backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Optional description for the backup.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupInfo: Information about the created backup.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not self.database_path.exists():
|
|
||||||
logger.warning(
|
|
||||||
f"Database file not found: {self.database_path}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
timestamp = datetime.now()
|
|
||||||
backup_name = (
|
|
||||||
f"database_{timestamp.strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
|
||||||
)
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
with tarfile.open(backup_path, "w:gz") as tar:
|
|
||||||
tar.add(self.database_path, arcname=self.database_path.name)
|
|
||||||
|
|
||||||
size_bytes = backup_path.stat().st_size
|
|
||||||
|
|
||||||
info = BackupInfo(
|
|
||||||
name=backup_name,
|
|
||||||
timestamp=timestamp,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
backup_type="data",
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Database backup created: {backup_name}")
|
|
||||||
return info
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create database backup: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def backup_full(
|
|
||||||
self, description: str = ""
|
|
||||||
) -> Optional[BackupInfo]:
|
|
||||||
"""Create a full system backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Optional description for the backup.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupInfo: Information about the created backup.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
timestamp = datetime.now()
|
|
||||||
backup_name = f"full_{timestamp.strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
with tarfile.open(backup_path, "w:gz") as tar:
|
|
||||||
# Add configuration
|
|
||||||
config_file = self.config_dir / "config.json"
|
|
||||||
if config_file.exists():
|
|
||||||
tar.add(config_file, arcname=config_file.name)
|
|
||||||
|
|
||||||
# Add database
|
|
||||||
if self.database_path.exists():
|
|
||||||
tar.add(
|
|
||||||
self.database_path,
|
|
||||||
arcname=self.database_path.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add download queue
|
|
||||||
queue_file = self.config_dir / "download_queue.json"
|
|
||||||
if queue_file.exists():
|
|
||||||
tar.add(queue_file, arcname=queue_file.name)
|
|
||||||
|
|
||||||
size_bytes = backup_path.stat().st_size
|
|
||||||
|
|
||||||
info = BackupInfo(
|
|
||||||
name=backup_name,
|
|
||||||
timestamp=timestamp,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
backup_type="full",
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Full backup created: {backup_name}")
|
|
||||||
return info
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create full backup: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def restore_configuration(self, backup_name: str) -> bool:
|
|
||||||
"""Restore configuration from backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_name: Name of the backup to restore.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if restore was successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
if not backup_path.exists():
|
|
||||||
logger.error(f"Backup file not found: {backup_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Extract to temporary directory
|
|
||||||
temp_dir = self.backup_dir / "temp_restore"
|
|
||||||
temp_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
with tarfile.open(backup_path, "r:gz") as tar:
|
|
||||||
tar.extractall(temp_dir)
|
|
||||||
|
|
||||||
# Copy configuration file back
|
|
||||||
config_file = temp_dir / "config.json"
|
|
||||||
if config_file.exists():
|
|
||||||
shutil.copy(config_file, self.config_dir / "config.json")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
shutil.rmtree(temp_dir)
|
|
||||||
|
|
||||||
logger.info(f"Configuration restored from: {backup_name}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to restore configuration: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def restore_database(self, backup_name: str) -> bool:
|
|
||||||
"""Restore database from backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_name: Name of the backup to restore.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if restore was successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
if not backup_path.exists():
|
|
||||||
logger.error(f"Backup file not found: {backup_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Create backup of current database
|
|
||||||
if self.database_path.exists():
|
|
||||||
current_backup = (
|
|
||||||
self.database_path.parent
|
|
||||||
/ f"{self.database_path.name}.backup"
|
|
||||||
)
|
|
||||||
shutil.copy(self.database_path, current_backup)
|
|
||||||
logger.info(f"Current database backed up to: {current_backup}")
|
|
||||||
|
|
||||||
# Extract to temporary directory
|
|
||||||
temp_dir = self.backup_dir / "temp_restore"
|
|
||||||
temp_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
with tarfile.open(backup_path, "r:gz") as tar:
|
|
||||||
tar.extractall(temp_dir)
|
|
||||||
|
|
||||||
# Copy database file back
|
|
||||||
db_file = temp_dir / self.database_path.name
|
|
||||||
if db_file.exists():
|
|
||||||
shutil.copy(db_file, self.database_path)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
shutil.rmtree(temp_dir)
|
|
||||||
|
|
||||||
logger.info(f"Database restored from: {backup_name}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to restore database: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def list_backups(
|
|
||||||
self, backup_type: Optional[str] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""List available backups.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_type: Optional filter by backup type.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of backup information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backups = []
|
|
||||||
|
|
||||||
for backup_file in sorted(self.backup_dir.glob("*.tar.gz")):
|
|
||||||
# Extract type from filename
|
|
||||||
filename = backup_file.name
|
|
||||||
file_type = filename.split("_")[0]
|
|
||||||
|
|
||||||
if backup_type and file_type != backup_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract timestamp
|
|
||||||
timestamp_str = (
|
|
||||||
filename.split("_", 1)[1].replace(".tar.gz", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
backups.append(
|
|
||||||
{
|
|
||||||
"name": filename,
|
|
||||||
"type": file_type,
|
|
||||||
"size_bytes": backup_file.stat().st_size,
|
|
||||||
"created": timestamp_str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return sorted(backups, key=lambda x: x["created"], reverse=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list backups: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def delete_backup(self, backup_name: str) -> bool:
|
|
||||||
"""Delete a backup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backup_name: Name of the backup to delete.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if delete was successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backup_path = self.backup_dir / backup_name
|
|
||||||
|
|
||||||
if not backup_path.exists():
|
|
||||||
logger.warning(f"Backup not found: {backup_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
backup_path.unlink()
|
|
||||||
logger.info(f"Backup deleted: {backup_name}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete backup: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def cleanup_old_backups(
|
|
||||||
self, max_backups: int = 10, backup_type: Optional[str] = None
|
|
||||||
) -> int:
|
|
||||||
"""Remove old backups, keeping only the most recent ones.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
max_backups: Maximum number of backups to keep.
|
|
||||||
backup_type: Optional filter by backup type.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: Number of backups deleted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
backups = self.list_backups(backup_type)
|
|
||||||
|
|
||||||
if len(backups) <= max_backups:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
deleted_count = 0
|
|
||||||
for backup in backups[max_backups:]:
|
|
||||||
if self.delete_backup(backup["name"]):
|
|
||||||
deleted_count += 1
|
|
||||||
|
|
||||||
logger.info(f"Cleaned up {deleted_count} old backups")
|
|
||||||
return deleted_count
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to cleanup old backups: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def export_anime_data(
|
|
||||||
self, output_file: str
|
|
||||||
) -> bool:
|
|
||||||
"""Export anime library data to JSON.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output_file: Path to export file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if export was successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# This would integrate with the anime service
|
|
||||||
# to export anime library data
|
|
||||||
export_data = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"anime_count": 0,
|
|
||||||
"data": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(output_file, "w") as f:
|
|
||||||
json.dump(export_data, f, indent=2)
|
|
||||||
|
|
||||||
logger.info(f"Anime data exported to: {output_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to export anime data: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def import_anime_data(self, input_file: str) -> bool:
|
|
||||||
"""Import anime library data from JSON.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_file: Path to import file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if import was successful.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not os.path.exists(input_file):
|
|
||||||
logger.error(f"Import file not found: {input_file}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(input_file, "r") as f:
|
|
||||||
json.load(f) # Load and validate JSON
|
|
||||||
|
|
||||||
# This would integrate with the anime service
|
|
||||||
# to import anime library data
|
|
||||||
|
|
||||||
logger.info(f"Anime data imported from: {input_file}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to import anime data: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Global backup service instance
|
|
||||||
_backup_service: Optional[BackupService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_backup_service() -> BackupService:
|
|
||||||
"""Get or create the global backup service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BackupService: The backup service instance.
|
|
||||||
"""
|
|
||||||
global _backup_service
|
|
||||||
if _backup_service is None:
|
|
||||||
_backup_service = BackupService()
|
|
||||||
return _backup_service
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
"""Download queue service for managing anime episode downloads.
|
"""Download queue service for managing anime episode downloads.
|
||||||
|
|
||||||
This module provides a comprehensive queue management system for handling
|
This module provides a simplified queue management system for handling
|
||||||
concurrent anime episode downloads with priority-based scheduling, progress
|
anime episode downloads with manual start/stop controls, progress tracking,
|
||||||
tracking, persistence, and automatic retry functionality.
|
persistence, and retry functionality.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -13,25 +13,20 @@ from collections import deque
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.server.models.download import (
|
from src.server.models.download import (
|
||||||
DownloadItem,
|
DownloadItem,
|
||||||
DownloadPriority,
|
DownloadPriority,
|
||||||
DownloadProgress,
|
|
||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
EpisodeIdentifier,
|
EpisodeIdentifier,
|
||||||
QueueStats,
|
QueueStats,
|
||||||
QueueStatus,
|
QueueStatus,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.progress_service import (
|
from src.server.services.progress_service import ProgressService, get_progress_service
|
||||||
ProgressService,
|
|
||||||
ProgressType,
|
|
||||||
get_progress_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@ -41,11 +36,11 @@ class DownloadServiceError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class DownloadService:
|
class DownloadService:
|
||||||
"""Manages the download queue with concurrent processing and persistence.
|
"""Manages the download queue with manual start/stop controls.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Priority-based queue management
|
- Manual download start/stop
|
||||||
- Concurrent download processing
|
- FIFO queue processing
|
||||||
- Real-time progress tracking
|
- Real-time progress tracking
|
||||||
- Queue persistence and recovery
|
- Queue persistence and recovery
|
||||||
- Automatic retry logic
|
- Automatic retry logic
|
||||||
@ -55,7 +50,6 @@ class DownloadService:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
anime_service: AnimeService,
|
anime_service: AnimeService,
|
||||||
max_concurrent_downloads: int = 2,
|
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
persistence_path: str = "./data/download_queue.json",
|
persistence_path: str = "./data/download_queue.json",
|
||||||
progress_service: Optional[ProgressService] = None,
|
progress_service: Optional[ProgressService] = None,
|
||||||
@ -64,50 +58,56 @@ class DownloadService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
anime_service: Service for anime operations
|
anime_service: Service for anime operations
|
||||||
max_concurrent_downloads: Maximum simultaneous downloads
|
|
||||||
max_retries: Maximum retry attempts for failed downloads
|
max_retries: Maximum retry attempts for failed downloads
|
||||||
persistence_path: Path to persist queue state
|
persistence_path: Path to persist queue state
|
||||||
progress_service: Optional progress service for tracking
|
progress_service: Optional progress service for tracking
|
||||||
"""
|
"""
|
||||||
self._anime_service = anime_service
|
self._anime_service = anime_service
|
||||||
self._max_concurrent = max_concurrent_downloads
|
|
||||||
self._max_retries = max_retries
|
self._max_retries = max_retries
|
||||||
self._persistence_path = Path(persistence_path)
|
self._persistence_path = Path(persistence_path)
|
||||||
self._progress_service = progress_service or get_progress_service()
|
self._progress_service = progress_service or get_progress_service()
|
||||||
|
|
||||||
# Queue storage by status
|
# Queue storage by status
|
||||||
self._pending_queue: deque[DownloadItem] = deque()
|
self._pending_queue: deque[DownloadItem] = deque()
|
||||||
# Helper dict for O(1) lookup of pending items by ID
|
# Helper dict for O(1) lookup of pending items by ID
|
||||||
self._pending_items_by_id: Dict[str, DownloadItem] = {}
|
self._pending_items_by_id: Dict[str, DownloadItem] = {}
|
||||||
self._active_downloads: Dict[str, DownloadItem] = {}
|
self._active_download: Optional[DownloadItem] = None
|
||||||
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
||||||
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
||||||
|
|
||||||
# Control flags
|
# Control flags
|
||||||
self._is_running = False
|
self._is_stopped = True # Queue processing is stopped by default
|
||||||
self._is_paused = False
|
|
||||||
self._shutdown_event = asyncio.Event()
|
|
||||||
|
|
||||||
# Executor for blocking operations
|
# Executor for blocking operations
|
||||||
self._executor = ThreadPoolExecutor(
|
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||||
max_workers=max_concurrent_downloads
|
|
||||||
)
|
|
||||||
|
|
||||||
# WebSocket broadcast callback
|
|
||||||
self._broadcast_callback: Optional[Callable] = None
|
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
self._total_downloaded_mb: float = 0.0
|
self._total_downloaded_mb: float = 0.0
|
||||||
self._download_speeds: deque[float] = deque(maxlen=10)
|
self._download_speeds: deque[float] = deque(maxlen=10)
|
||||||
|
|
||||||
# Load persisted queue
|
# Load persisted queue
|
||||||
self._load_queue()
|
self._load_queue()
|
||||||
|
|
||||||
|
# Initialize queue progress tracking
|
||||||
|
asyncio.create_task(self._init_queue_progress())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"DownloadService initialized",
|
"DownloadService initialized",
|
||||||
max_concurrent=max_concurrent_downloads,
|
|
||||||
max_retries=max_retries,
|
max_retries=max_retries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _init_queue_progress(self) -> None:
|
||||||
|
"""Initialize the download queue progress tracking."""
|
||||||
|
try:
|
||||||
|
from src.server.services.progress_service import ProgressType
|
||||||
|
await self._progress_service.start_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
progress_type=ProgressType.QUEUE,
|
||||||
|
title="Download Queue",
|
||||||
|
message="Queue ready",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize queue progress", error=str(e))
|
||||||
|
|
||||||
def _add_to_pending_queue(
|
def _add_to_pending_queue(
|
||||||
self, item: DownloadItem, front: bool = False
|
self, item: DownloadItem, front: bool = False
|
||||||
@ -149,28 +149,6 @@ class DownloadService:
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_broadcast_callback(self, callback: Callable) -> None:
|
|
||||||
"""Set callback for broadcasting status updates via WebSocket."""
|
|
||||||
self._broadcast_callback = callback
|
|
||||||
logger.debug("Broadcast callback registered")
|
|
||||||
|
|
||||||
async def _broadcast_update(self, update_type: str, data: dict) -> None:
|
|
||||||
"""Broadcast update to connected WebSocket clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
update_type: Type of update (download_progress, queue_status, etc.)
|
|
||||||
data: Update data to broadcast
|
|
||||||
"""
|
|
||||||
if self._broadcast_callback:
|
|
||||||
try:
|
|
||||||
await self._broadcast_callback(update_type, data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast update",
|
|
||||||
update_type=update_type,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_item_id(self) -> str:
|
def _generate_item_id(self) -> str:
|
||||||
"""Generate unique identifier for download items."""
|
"""Generate unique identifier for download items."""
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
@ -212,14 +190,17 @@ class DownloadService:
|
|||||||
try:
|
try:
|
||||||
self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
|
self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
active_items = (
|
||||||
|
[self._active_download] if self._active_download else []
|
||||||
|
)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"pending": [
|
"pending": [
|
||||||
item.model_dump(mode="json")
|
item.model_dump(mode="json")
|
||||||
for item in self._pending_queue
|
for item in self._pending_queue
|
||||||
],
|
],
|
||||||
"active": [
|
"active": [
|
||||||
item.model_dump(mode="json")
|
item.model_dump(mode="json") for item in active_items
|
||||||
for item in self._active_downloads.values()
|
|
||||||
],
|
],
|
||||||
"failed": [
|
"failed": [
|
||||||
item.model_dump(mode="json")
|
item.model_dump(mode="json")
|
||||||
@ -238,17 +219,19 @@ class DownloadService:
|
|||||||
async def add_to_queue(
|
async def add_to_queue(
|
||||||
self,
|
self,
|
||||||
serie_id: str,
|
serie_id: str,
|
||||||
|
serie_folder: str,
|
||||||
serie_name: str,
|
serie_name: str,
|
||||||
episodes: List[EpisodeIdentifier],
|
episodes: List[EpisodeIdentifier],
|
||||||
priority: DownloadPriority = DownloadPriority.NORMAL,
|
priority: DownloadPriority = DownloadPriority.NORMAL,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Add episodes to the download queue.
|
"""Add episodes to the download queue (FIFO order).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serie_id: Series identifier
|
serie_id: Series identifier (provider key)
|
||||||
|
serie_folder: Series folder name on disk
|
||||||
serie_name: Series display name
|
serie_name: Series display name
|
||||||
episodes: List of episodes to download
|
episodes: List of episodes to download
|
||||||
priority: Queue priority level
|
priority: Queue priority level (ignored, kept for compatibility)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of created download item IDs
|
List of created download item IDs
|
||||||
@ -263,6 +246,7 @@ class DownloadService:
|
|||||||
item = DownloadItem(
|
item = DownloadItem(
|
||||||
id=self._generate_item_id(),
|
id=self._generate_item_id(),
|
||||||
serie_id=serie_id,
|
serie_id=serie_id,
|
||||||
|
serie_folder=serie_folder,
|
||||||
serie_name=serie_name,
|
serie_name=serie_name,
|
||||||
episode=episode,
|
episode=episode,
|
||||||
status=DownloadStatus.PENDING,
|
status=DownloadStatus.PENDING,
|
||||||
@ -270,12 +254,8 @@ class DownloadService:
|
|||||||
added_at=datetime.now(timezone.utc),
|
added_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert based on priority. High-priority downloads jump the
|
# Always append to end (FIFO order)
|
||||||
# line via appendleft so they execute before existing work;
|
self._add_to_pending_queue(item, front=False)
|
||||||
# everything else is appended to preserve FIFO order.
|
|
||||||
self._add_to_pending_queue(
|
|
||||||
item, front=(priority == DownloadPriority.HIGH)
|
|
||||||
)
|
|
||||||
|
|
||||||
created_ids.append(item.id)
|
created_ids.append(item.id)
|
||||||
|
|
||||||
@ -285,20 +265,21 @@ class DownloadService:
|
|||||||
serie=serie_name,
|
serie=serie_name,
|
||||||
season=episode.season,
|
season=episode.season,
|
||||||
episode=episode.episode,
|
episode=episode.episode,
|
||||||
priority=priority.value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
|
|
||||||
# Broadcast queue status update
|
# Notify via progress service
|
||||||
queue_status = await self.get_queue_status()
|
queue_status = await self.get_queue_status()
|
||||||
await self._broadcast_update(
|
await self._progress_service.update_progress(
|
||||||
"queue_status",
|
progress_id="download_queue",
|
||||||
{
|
message=f"Added {len(created_ids)} items to queue",
|
||||||
|
metadata={
|
||||||
"action": "items_added",
|
"action": "items_added",
|
||||||
"added_ids": created_ids,
|
"added_ids": created_ids,
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
},
|
},
|
||||||
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return created_ids
|
return created_ids
|
||||||
@ -324,12 +305,13 @@ class DownloadService:
|
|||||||
try:
|
try:
|
||||||
for item_id in item_ids:
|
for item_id in item_ids:
|
||||||
# Check if item is currently downloading
|
# Check if item is currently downloading
|
||||||
if item_id in self._active_downloads:
|
active = self._active_download
|
||||||
item = self._active_downloads[item_id]
|
if active and active.id == item_id:
|
||||||
|
item = active
|
||||||
item.status = DownloadStatus.CANCELLED
|
item.status = DownloadStatus.CANCELLED
|
||||||
item.completed_at = datetime.now(timezone.utc)
|
item.completed_at = datetime.now(timezone.utc)
|
||||||
self._failed_items.append(item)
|
self._failed_items.append(item)
|
||||||
del self._active_downloads[item_id]
|
self._active_download = None
|
||||||
removed_ids.append(item_id)
|
removed_ids.append(item_id)
|
||||||
logger.info("Cancelled active download", item_id=item_id)
|
logger.info("Cancelled active download", item_id=item_id)
|
||||||
continue
|
continue
|
||||||
@ -346,15 +328,17 @@ class DownloadService:
|
|||||||
|
|
||||||
if removed_ids:
|
if removed_ids:
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
# Broadcast queue status update
|
# Notify via progress service
|
||||||
queue_status = await self.get_queue_status()
|
queue_status = await self.get_queue_status()
|
||||||
await self._broadcast_update(
|
await self._progress_service.update_progress(
|
||||||
"queue_status",
|
progress_id="download_queue",
|
||||||
{
|
message=f"Removed {len(removed_ids)} items from queue",
|
||||||
|
metadata={
|
||||||
"action": "items_removed",
|
"action": "items_removed",
|
||||||
"removed_ids": removed_ids,
|
"removed_ids": removed_ids,
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
},
|
},
|
||||||
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return removed_ids
|
return removed_ids
|
||||||
@ -365,118 +349,151 @@ class DownloadService:
|
|||||||
f"Failed to remove items: {str(e)}"
|
f"Failed to remove items: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
async def reorder_queue(self, item_id: str, new_position: int) -> bool:
|
async def start_queue_processing(self) -> Optional[str]:
|
||||||
"""Reorder an item in the pending queue.
|
"""Start automatic queue processing of all pending downloads.
|
||||||
|
|
||||||
Args:
|
This will process all pending downloads one by one until the queue
|
||||||
item_id: Download item ID to reorder
|
is empty or stopped. The processing continues even if the browser
|
||||||
new_position: New position in queue (0-based)
|
is closed.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if reordering was successful
|
Item ID of first started download, or None if queue is empty
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DownloadServiceError: If reordering fails
|
DownloadServiceError: If queue processing is already active
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Find and remove item - O(1) lookup using helper dict
|
# Check if download already active
|
||||||
item_to_move = self._pending_items_by_id.get(item_id)
|
if self._active_download:
|
||||||
|
|
||||||
if not item_to_move:
|
|
||||||
raise DownloadServiceError(
|
raise DownloadServiceError(
|
||||||
f"Item {item_id} not found in pending queue"
|
"Queue processing is already active"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove from current position
|
# Check if queue is empty
|
||||||
self._pending_queue.remove(item_to_move)
|
if not self._pending_queue:
|
||||||
del self._pending_items_by_id[item_id]
|
logger.info("No pending downloads to start")
|
||||||
|
return None
|
||||||
|
|
||||||
# Insert at new position
|
# Mark queue as running
|
||||||
queue_list = list(self._pending_queue)
|
self._is_stopped = False
|
||||||
new_position = max(0, min(new_position, len(queue_list)))
|
|
||||||
queue_list.insert(new_position, item_to_move)
|
|
||||||
self._pending_queue = deque(queue_list)
|
|
||||||
# Re-add to helper dict
|
|
||||||
self._pending_items_by_id[item_id] = item_to_move
|
|
||||||
|
|
||||||
self._save_queue()
|
# Start queue processing in background
|
||||||
|
asyncio.create_task(self._process_queue())
|
||||||
|
|
||||||
# Broadcast queue status update
|
logger.info("Queue processing started")
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "queue_reordered",
|
|
||||||
"item_id": item_id,
|
|
||||||
"new_position": new_position,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
return "queue_started"
|
||||||
"Queue item reordered",
|
|
||||||
item_id=item_id,
|
|
||||||
new_position=new_position
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to reorder queue", error=str(e))
|
logger.error("Failed to start queue processing", error=str(e))
|
||||||
raise DownloadServiceError(
|
raise DownloadServiceError(
|
||||||
f"Failed to reorder: {str(e)}"
|
f"Failed to start queue processing: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
async def reorder_queue_bulk(self, item_order: List[str]) -> bool:
|
async def _process_queue(self) -> None:
|
||||||
"""Reorder pending queue to match provided item order for the specified
|
"""Process all items in the queue sequentially.
|
||||||
item IDs. Any pending items not mentioned will be appended after the
|
|
||||||
ordered items preserving their relative order.
|
This runs continuously until the queue is empty or stopped.
|
||||||
|
Each download is processed one at a time, and the next one starts
|
||||||
Args:
|
automatically after the previous one completes.
|
||||||
item_order: Desired ordering of item IDs for pending queue
|
"""
|
||||||
|
logger.info("Queue processor started")
|
||||||
|
|
||||||
|
while not self._is_stopped and len(self._pending_queue) > 0:
|
||||||
|
try:
|
||||||
|
# Get next item from queue
|
||||||
|
item = self._pending_queue.popleft()
|
||||||
|
del self._pending_items_by_id[item.id]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Processing next item from queue",
|
||||||
|
item_id=item.id,
|
||||||
|
serie=item.serie_name,
|
||||||
|
remaining=len(self._pending_queue)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify via progress service
|
||||||
|
queue_status = await self.get_queue_status()
|
||||||
|
msg = (
|
||||||
|
f"Started: {item.serie_name} "
|
||||||
|
f"S{item.episode.season:02d}E{item.episode.episode:02d}"
|
||||||
|
)
|
||||||
|
await self._progress_service.update_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message=msg,
|
||||||
|
metadata={
|
||||||
|
"action": "download_started",
|
||||||
|
"item_id": item.id,
|
||||||
|
"serie_name": item.serie_name,
|
||||||
|
"season": item.episode.season,
|
||||||
|
"episode": item.episode.episode,
|
||||||
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
|
},
|
||||||
|
force_broadcast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process the download (this will wait until complete)
|
||||||
|
await self._process_download(item)
|
||||||
|
|
||||||
|
# Small delay between downloads
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error in queue processing loop",
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
# Continue with next item even if one fails
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Queue processing completed
|
||||||
|
self._is_stopped = True
|
||||||
|
|
||||||
|
if len(self._pending_queue) == 0:
|
||||||
|
logger.info("Queue processing completed - all items processed")
|
||||||
|
queue_status = await self.get_queue_status()
|
||||||
|
await self._progress_service.complete_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message="All downloads completed",
|
||||||
|
metadata={
|
||||||
|
"queue_status": queue_status.model_dump(mode="json")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Queue processing stopped by user")
|
||||||
|
|
||||||
|
async def start_next_download(self) -> Optional[str]:
|
||||||
|
"""Legacy method - redirects to start_queue_processing.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if operation completed
|
Item ID of started download, or None if queue is empty
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DownloadServiceError: If a download is already active
|
||||||
"""
|
"""
|
||||||
try:
|
return await self.start_queue_processing()
|
||||||
# Map existing pending items by id
|
|
||||||
existing = {item.id: item for item in list(self._pending_queue)}
|
|
||||||
|
|
||||||
new_queue: List[DownloadItem] = []
|
async def stop_downloads(self) -> None:
|
||||||
|
"""Stop processing new downloads from queue.
|
||||||
# Add items in the requested order if present
|
|
||||||
for item_id in item_order:
|
Current download will continue, but no new downloads will start.
|
||||||
item = existing.pop(item_id, None)
|
"""
|
||||||
if item:
|
self._is_stopped = True
|
||||||
new_queue.append(item)
|
logger.info("Download processing stopped")
|
||||||
|
|
||||||
# Append any remaining items preserving original order
|
# Notify via progress service
|
||||||
for item in list(self._pending_queue):
|
queue_status = await self.get_queue_status()
|
||||||
if item.id in existing:
|
await self._progress_service.update_progress(
|
||||||
new_queue.append(item)
|
progress_id="download_queue",
|
||||||
existing.pop(item.id, None)
|
message="Queue processing stopped",
|
||||||
|
metadata={
|
||||||
# Replace pending queue
|
"action": "queue_stopped",
|
||||||
self._pending_queue = deque(new_queue)
|
"is_stopped": True,
|
||||||
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
self._save_queue()
|
},
|
||||||
|
force_broadcast=True,
|
||||||
# Broadcast queue status update
|
)
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "queue_bulk_reordered",
|
|
||||||
"item_order": item_order,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Bulk queue reorder applied", ordered_count=len(item_order))
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to apply bulk reorder", error=str(e))
|
|
||||||
raise DownloadServiceError(f"Failed to reorder: {str(e)}") from e
|
|
||||||
|
|
||||||
async def get_queue_status(self) -> QueueStatus:
|
async def get_queue_status(self) -> QueueStatus:
|
||||||
"""Get current status of all queues.
|
"""Get current status of all queues.
|
||||||
@ -484,10 +501,13 @@ class DownloadService:
|
|||||||
Returns:
|
Returns:
|
||||||
Complete queue status with all items
|
Complete queue status with all items
|
||||||
"""
|
"""
|
||||||
|
active_downloads = (
|
||||||
|
[self._active_download] if self._active_download else []
|
||||||
|
)
|
||||||
return QueueStatus(
|
return QueueStatus(
|
||||||
is_running=self._is_running,
|
is_running=not self._is_stopped,
|
||||||
is_paused=self._is_paused,
|
is_paused=False, # Kept for compatibility
|
||||||
active_downloads=list(self._active_downloads.values()),
|
active_downloads=active_downloads,
|
||||||
pending_queue=list(self._pending_queue),
|
pending_queue=list(self._pending_queue),
|
||||||
completed_downloads=list(self._completed_items),
|
completed_downloads=list(self._completed_items),
|
||||||
failed_downloads=list(self._failed_items),
|
failed_downloads=list(self._failed_items),
|
||||||
@ -499,7 +519,7 @@ class DownloadService:
|
|||||||
Returns:
|
Returns:
|
||||||
Statistics about the download queue
|
Statistics about the download queue
|
||||||
"""
|
"""
|
||||||
active_count = len(self._active_downloads)
|
active_count = 1 if self._active_download else 0
|
||||||
pending_count = len(self._pending_queue)
|
pending_count = len(self._pending_queue)
|
||||||
completed_count = len(self._completed_items)
|
completed_count = len(self._completed_items)
|
||||||
failed_count = len(self._failed_items)
|
failed_count = len(self._failed_items)
|
||||||
@ -532,36 +552,6 @@ class DownloadService:
|
|||||||
estimated_time_remaining=eta_seconds,
|
estimated_time_remaining=eta_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def pause_queue(self) -> None:
|
|
||||||
"""Pause download processing."""
|
|
||||||
self._is_paused = True
|
|
||||||
logger.info("Download queue paused")
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_paused",
|
|
||||||
{
|
|
||||||
"is_paused": True,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def resume_queue(self) -> None:
|
|
||||||
"""Resume download processing."""
|
|
||||||
self._is_paused = False
|
|
||||||
logger.info("Download queue resumed")
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_resumed",
|
|
||||||
{
|
|
||||||
"is_paused": False,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def clear_completed(self) -> int:
|
async def clear_completed(self) -> int:
|
||||||
"""Clear completed downloads from history.
|
"""Clear completed downloads from history.
|
||||||
|
|
||||||
@ -572,16 +562,74 @@ class DownloadService:
|
|||||||
self._completed_items.clear()
|
self._completed_items.clear()
|
||||||
logger.info("Cleared completed items", count=count)
|
logger.info("Cleared completed items", count=count)
|
||||||
|
|
||||||
# Broadcast queue status update
|
# Notify via progress service
|
||||||
if count > 0:
|
if count > 0:
|
||||||
queue_status = await self.get_queue_status()
|
queue_status = await self.get_queue_status()
|
||||||
await self._broadcast_update(
|
await self._progress_service.update_progress(
|
||||||
"queue_status",
|
progress_id="download_queue",
|
||||||
{
|
message=f"Cleared {count} completed items",
|
||||||
|
metadata={
|
||||||
"action": "completed_cleared",
|
"action": "completed_cleared",
|
||||||
"cleared_count": count,
|
"cleared_count": count,
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
},
|
},
|
||||||
|
force_broadcast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def clear_failed(self) -> int:
|
||||||
|
"""Clear failed downloads from history.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items cleared
|
||||||
|
"""
|
||||||
|
count = len(self._failed_items)
|
||||||
|
self._failed_items.clear()
|
||||||
|
logger.info("Cleared failed items", count=count)
|
||||||
|
|
||||||
|
# Notify via progress service
|
||||||
|
if count > 0:
|
||||||
|
queue_status = await self.get_queue_status()
|
||||||
|
await self._progress_service.update_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message=f"Cleared {count} failed items",
|
||||||
|
metadata={
|
||||||
|
"action": "failed_cleared",
|
||||||
|
"cleared_count": count,
|
||||||
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
|
},
|
||||||
|
force_broadcast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def clear_pending(self) -> int:
|
||||||
|
"""Clear all pending downloads from the queue.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items cleared
|
||||||
|
"""
|
||||||
|
count = len(self._pending_queue)
|
||||||
|
self._pending_queue.clear()
|
||||||
|
self._pending_items_by_id.clear()
|
||||||
|
logger.info("Cleared pending items", count=count)
|
||||||
|
|
||||||
|
# Save queue state
|
||||||
|
self._save_queue()
|
||||||
|
|
||||||
|
# Notify via progress service
|
||||||
|
if count > 0:
|
||||||
|
queue_status = await self.get_queue_status()
|
||||||
|
await self._progress_service.update_progress(
|
||||||
|
progress_id="download_queue",
|
||||||
|
message=f"Cleared {count} pending items",
|
||||||
|
metadata={
|
||||||
|
"action": "pending_cleared",
|
||||||
|
"cleared_count": count,
|
||||||
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
|
},
|
||||||
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return count
|
return count
|
||||||
@ -628,15 +676,17 @@ class DownloadService:
|
|||||||
|
|
||||||
if retried_ids:
|
if retried_ids:
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
# Broadcast queue status update
|
# Notify via progress service
|
||||||
queue_status = await self.get_queue_status()
|
queue_status = await self.get_queue_status()
|
||||||
await self._broadcast_update(
|
await self._progress_service.update_progress(
|
||||||
"queue_status",
|
progress_id="download_queue",
|
||||||
{
|
message=f"Retried {len(retried_ids)} failed items",
|
||||||
|
metadata={
|
||||||
"action": "items_retried",
|
"action": "items_retried",
|
||||||
"retried_ids": retried_ids,
|
"retried_ids": retried_ids,
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
},
|
},
|
||||||
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return retried_ids
|
return retried_ids
|
||||||
@ -647,67 +697,6 @@ class DownloadService:
|
|||||||
f"Failed to retry: {str(e)}"
|
f"Failed to retry: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def _create_progress_callback(self, item: DownloadItem) -> Callable:
|
|
||||||
"""Create a progress callback for a download item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item: Download item to track progress for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callback function for progress updates
|
|
||||||
"""
|
|
||||||
def progress_callback(progress_data: dict) -> None:
|
|
||||||
"""Update progress and broadcast to clients."""
|
|
||||||
try:
|
|
||||||
# Update item progress
|
|
||||||
item.progress = DownloadProgress(
|
|
||||||
percent=progress_data.get("percent", 0.0),
|
|
||||||
downloaded_mb=progress_data.get("downloaded_mb", 0.0),
|
|
||||||
total_mb=progress_data.get("total_mb"),
|
|
||||||
speed_mbps=progress_data.get("speed_mbps"),
|
|
||||||
eta_seconds=progress_data.get("eta_seconds"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track speed for statistics
|
|
||||||
if item.progress.speed_mbps:
|
|
||||||
self._download_speeds.append(item.progress.speed_mbps)
|
|
||||||
|
|
||||||
# Update progress service
|
|
||||||
if item.progress.total_mb and item.progress.total_mb > 0:
|
|
||||||
current_mb = int(item.progress.downloaded_mb)
|
|
||||||
total_mb = int(item.progress.total_mb)
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
self._progress_service.update_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
current=current_mb,
|
|
||||||
total=total_mb,
|
|
||||||
metadata={
|
|
||||||
"speed_mbps": item.progress.speed_mbps,
|
|
||||||
"eta_seconds": item.progress.eta_seconds,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast update (fire and forget)
|
|
||||||
asyncio.create_task(
|
|
||||||
self._broadcast_update(
|
|
||||||
"download_progress",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"progress": item.progress.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Progress callback error", error=str(e))
|
|
||||||
|
|
||||||
return progress_callback
|
|
||||||
|
|
||||||
async def _process_download(self, item: DownloadItem) -> None:
|
async def _process_download(self, item: DownloadItem) -> None:
|
||||||
"""Process a single download item.
|
"""Process a single download item.
|
||||||
|
|
||||||
@ -718,7 +707,7 @@ class DownloadService:
|
|||||||
# Update status
|
# Update status
|
||||||
item.status = DownloadStatus.DOWNLOADING
|
item.status = DownloadStatus.DOWNLOADING
|
||||||
item.started_at = datetime.now(timezone.utc)
|
item.started_at = datetime.now(timezone.utc)
|
||||||
self._active_downloads[item.id] = item
|
self._active_download = item
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting download",
|
"Starting download",
|
||||||
@ -727,33 +716,18 @@ class DownloadService:
|
|||||||
season=item.episode.season,
|
season=item.episode.season,
|
||||||
episode=item.episode.episode,
|
episode=item.episode.episode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start progress tracking
|
|
||||||
await self._progress_service.start_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title=f"Downloading {item.serie_name}",
|
|
||||||
message=(
|
|
||||||
f"S{item.episode.season:02d}E{item.episode.episode:02d}"
|
|
||||||
),
|
|
||||||
metadata={
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create progress callback
|
|
||||||
progress_callback = self._create_progress_callback(item)
|
|
||||||
|
|
||||||
# Execute download via anime service
|
# Execute download via anime service
|
||||||
|
# AnimeService handles ALL progress via SeriesApp events:
|
||||||
|
# - download started/progress/completed/failed events
|
||||||
|
# - All updates forwarded to ProgressService
|
||||||
|
# - ProgressService broadcasts to WebSocket clients
|
||||||
|
folder = item.serie_folder if item.serie_folder else item.serie_id
|
||||||
success = await self._anime_service.download(
|
success = await self._anime_service.download(
|
||||||
serie_folder=item.serie_id,
|
serie_folder=folder,
|
||||||
season=item.episode.season,
|
season=item.episode.season,
|
||||||
episode=item.episode.episode,
|
episode=item.episode.episode,
|
||||||
key=item.serie_id, # Assuming serie_id is the provider key
|
key=item.serie_id,
|
||||||
callback=progress_callback,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle result
|
# Handle result
|
||||||
@ -770,31 +744,6 @@ class DownloadService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Download completed successfully", item_id=item.id
|
"Download completed successfully", item_id=item.id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complete progress tracking
|
|
||||||
await self._progress_service.complete_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
message="Download completed successfully",
|
|
||||||
metadata={
|
|
||||||
"downloaded_mb": item.progress.downloaded_mb
|
|
||||||
if item.progress
|
|
||||||
else 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._broadcast_update(
|
|
||||||
"download_complete",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"downloaded_mb": item.progress.downloaded_mb
|
|
||||||
if item.progress
|
|
||||||
else 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise AnimeServiceError("Download returned False")
|
raise AnimeServiceError("Download returned False")
|
||||||
|
|
||||||
@ -811,106 +760,36 @@ class DownloadService:
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
retry_count=item.retry_count,
|
retry_count=item.retry_count,
|
||||||
)
|
)
|
||||||
|
# Note: Failure is already broadcast by AnimeService
|
||||||
# Fail progress tracking
|
# via ProgressService when SeriesApp fires failed event
|
||||||
await self._progress_service.fail_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
error_message=str(e),
|
|
||||||
metadata={"retry_count": item.retry_count},
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._broadcast_update(
|
|
||||||
"download_failed",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"error": item.error,
|
|
||||||
"retry_count": item.retry_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Remove from active downloads
|
# Remove from active downloads
|
||||||
if item.id in self._active_downloads:
|
if self._active_download and self._active_download.id == item.id:
|
||||||
del self._active_downloads[item.id]
|
self._active_download = None
|
||||||
|
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
|
|
||||||
async def _queue_processor(self) -> None:
|
|
||||||
"""Main queue processing loop."""
|
|
||||||
logger.info("Queue processor started")
|
|
||||||
|
|
||||||
while not self._shutdown_event.is_set():
|
|
||||||
try:
|
|
||||||
# Wait if paused
|
|
||||||
if self._is_paused:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if we can start more downloads
|
|
||||||
if len(self._active_downloads) >= self._max_concurrent:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get next item from queue
|
|
||||||
if not self._pending_queue:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
item = self._pending_queue.popleft()
|
|
||||||
|
|
||||||
# Process download in background
|
|
||||||
asyncio.create_task(self._process_download(item))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Queue processor error", error=str(e))
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
logger.info("Queue processor stopped")
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the download queue processor."""
|
"""Initialize the download queue service (compatibility method).
|
||||||
if self._is_running:
|
|
||||||
logger.warning("Queue processor already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._is_running = True
|
Note: Downloads are started manually via start_next_download().
|
||||||
self._shutdown_event.clear()
|
"""
|
||||||
|
logger.info("Download queue service initialized")
|
||||||
# Start processor task
|
|
||||||
asyncio.create_task(self._queue_processor())
|
|
||||||
|
|
||||||
logger.info("Download queue service started")
|
|
||||||
|
|
||||||
# Broadcast queue started event
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_started",
|
|
||||||
{
|
|
||||||
"is_running": True,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the download queue processor."""
|
"""Stop the download queue service and wait for active download.
|
||||||
if not self._is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
Note: This waits for the current download to complete.
|
||||||
|
"""
|
||||||
logger.info("Stopping download queue service...")
|
logger.info("Stopping download queue service...")
|
||||||
|
|
||||||
self._is_running = False
|
# Wait for active download to complete (with timeout)
|
||||||
self._shutdown_event.set()
|
|
||||||
|
|
||||||
# Wait for active downloads to complete (with timeout)
|
|
||||||
timeout = 30 # seconds
|
timeout = 30 # seconds
|
||||||
start_time = asyncio.get_event_loop().time()
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
while (
|
while (
|
||||||
self._active_downloads
|
self._active_download
|
||||||
and (asyncio.get_event_loop().time() - start_time) < timeout
|
and (asyncio.get_event_loop().time() - start_time) < timeout
|
||||||
):
|
):
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
@ -922,16 +801,6 @@ class DownloadService:
|
|||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
|
|
||||||
logger.info("Download queue service stopped")
|
logger.info("Download queue service stopped")
|
||||||
|
|
||||||
# Broadcast queue stopped event
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_stopped",
|
|
||||||
{
|
|
||||||
"is_running": False,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
|
|||||||
@ -1,324 +0,0 @@
|
|||||||
"""Monitoring service for system resource tracking and metrics collection."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.server.database.models import DownloadQueueItem
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QueueMetrics:
|
|
||||||
"""Download queue statistics and metrics."""
|
|
||||||
|
|
||||||
total_items: int = 0
|
|
||||||
pending_items: int = 0
|
|
||||||
downloading_items: int = 0
|
|
||||||
completed_items: int = 0
|
|
||||||
failed_items: int = 0
|
|
||||||
total_size_bytes: int = 0
|
|
||||||
downloaded_bytes: int = 0
|
|
||||||
average_speed_mbps: float = 0.0
|
|
||||||
estimated_time_remaining: Optional[timedelta] = None
|
|
||||||
success_rate: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SystemMetrics:
|
|
||||||
"""System resource metrics at a point in time."""
|
|
||||||
|
|
||||||
timestamp: datetime
|
|
||||||
cpu_percent: float
|
|
||||||
memory_percent: float
|
|
||||||
memory_available_mb: float
|
|
||||||
disk_percent: float
|
|
||||||
disk_free_mb: float
|
|
||||||
uptime_seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ErrorMetrics:
|
|
||||||
"""Error tracking and statistics."""
|
|
||||||
|
|
||||||
total_errors: int = 0
|
|
||||||
errors_24h: int = 0
|
|
||||||
most_common_errors: Dict[str, int] = field(default_factory=dict)
|
|
||||||
last_error_time: Optional[datetime] = None
|
|
||||||
error_rate_per_hour: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class MonitoringService:
|
|
||||||
"""Service for monitoring system resources and application metrics."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize monitoring service."""
|
|
||||||
self._error_log: List[tuple[datetime, str]] = []
|
|
||||||
self._performance_samples: List[SystemMetrics] = []
|
|
||||||
self._max_samples = 1440 # Keep 24 hours of minute samples
|
|
||||||
|
|
||||||
def get_system_metrics(self) -> SystemMetrics:
|
|
||||||
"""Get current system resource metrics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SystemMetrics: Current system metrics.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import time
|
|
||||||
|
|
||||||
cpu_percent = psutil.cpu_percent(interval=1)
|
|
||||||
memory_info = psutil.virtual_memory()
|
|
||||||
disk_info = psutil.disk_usage("/")
|
|
||||||
boot_time = psutil.boot_time()
|
|
||||||
uptime_seconds = time.time() - boot_time
|
|
||||||
|
|
||||||
metrics = SystemMetrics(
|
|
||||||
timestamp=datetime.now(),
|
|
||||||
cpu_percent=cpu_percent,
|
|
||||||
memory_percent=memory_info.percent,
|
|
||||||
memory_available_mb=memory_info.available / (1024 * 1024),
|
|
||||||
disk_percent=disk_info.percent,
|
|
||||||
disk_free_mb=disk_info.free / (1024 * 1024),
|
|
||||||
uptime_seconds=uptime_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store sample
|
|
||||||
self._performance_samples.append(metrics)
|
|
||||||
if len(self._performance_samples) > self._max_samples:
|
|
||||||
self._performance_samples.pop(0)
|
|
||||||
|
|
||||||
return metrics
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get system metrics: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_queue_metrics(self, db: AsyncSession) -> QueueMetrics:
|
|
||||||
"""Get download queue metrics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QueueMetrics: Queue statistics and progress.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get all queue items
|
|
||||||
result = await db.execute(select(DownloadQueueItem))
|
|
||||||
items = result.scalars().all()
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
return QueueMetrics()
|
|
||||||
|
|
||||||
# Calculate metrics
|
|
||||||
total_items = len(items)
|
|
||||||
pending_items = sum(1 for i in items if i.status == "PENDING")
|
|
||||||
downloading_items = sum(
|
|
||||||
1 for i in items if i.status == "DOWNLOADING"
|
|
||||||
)
|
|
||||||
completed_items = sum(1 for i in items if i.status == "COMPLETED")
|
|
||||||
failed_items = sum(1 for i in items if i.status == "FAILED")
|
|
||||||
|
|
||||||
total_size_bytes = sum(
|
|
||||||
(i.total_bytes or 0) for i in items
|
|
||||||
)
|
|
||||||
downloaded_bytes = sum(
|
|
||||||
(i.downloaded_bytes or 0) for i in items
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate average speed from active downloads
|
|
||||||
speeds = [
|
|
||||||
i.download_speed for i in items
|
|
||||||
if i.status == "DOWNLOADING" and i.download_speed
|
|
||||||
]
|
|
||||||
average_speed_mbps = (
|
|
||||||
sum(speeds) / len(speeds) / (1024 * 1024) if speeds else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate success rate
|
|
||||||
success_rate = (
|
|
||||||
(completed_items / total_items * 100) if total_items > 0 else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Estimate time remaining
|
|
||||||
estimated_time_remaining = None
|
|
||||||
if average_speed_mbps > 0 and total_size_bytes > downloaded_bytes:
|
|
||||||
remaining_bytes = total_size_bytes - downloaded_bytes
|
|
||||||
remaining_seconds = remaining_bytes / average_speed_mbps
|
|
||||||
estimated_time_remaining = timedelta(seconds=remaining_seconds)
|
|
||||||
|
|
||||||
return QueueMetrics(
|
|
||||||
total_items=total_items,
|
|
||||||
pending_items=pending_items,
|
|
||||||
downloading_items=downloading_items,
|
|
||||||
completed_items=completed_items,
|
|
||||||
failed_items=failed_items,
|
|
||||||
total_size_bytes=total_size_bytes,
|
|
||||||
downloaded_bytes=downloaded_bytes,
|
|
||||||
average_speed_mbps=average_speed_mbps,
|
|
||||||
estimated_time_remaining=estimated_time_remaining,
|
|
||||||
success_rate=success_rate,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get queue metrics: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def log_error(self, error_message: str) -> None:
|
|
||||||
"""Log an error for tracking purposes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_message: The error message to log.
|
|
||||||
"""
|
|
||||||
self._error_log.append((datetime.now(), error_message))
|
|
||||||
logger.debug(f"Error logged: {error_message}")
|
|
||||||
|
|
||||||
def get_error_metrics(self) -> ErrorMetrics:
|
|
||||||
"""Get error tracking metrics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ErrorMetrics: Error statistics and trends.
|
|
||||||
"""
|
|
||||||
total_errors = len(self._error_log)
|
|
||||||
|
|
||||||
# Get errors from last 24 hours
|
|
||||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
|
||||||
recent_errors = [
|
|
||||||
(time, msg) for time, msg in self._error_log
|
|
||||||
if time >= cutoff_time
|
|
||||||
]
|
|
||||||
errors_24h = len(recent_errors)
|
|
||||||
|
|
||||||
# Count error types
|
|
||||||
error_counts: Dict[str, int] = {}
|
|
||||||
for _, msg in recent_errors:
|
|
||||||
error_type = msg.split(":")[0]
|
|
||||||
error_counts[error_type] = error_counts.get(error_type, 0) + 1
|
|
||||||
|
|
||||||
# Sort by count
|
|
||||||
most_common_errors = dict(
|
|
||||||
sorted(error_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get last error time
|
|
||||||
last_error_time = (
|
|
||||||
recent_errors[-1][0] if recent_errors else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate error rate per hour
|
|
||||||
error_rate_per_hour = (
|
|
||||||
errors_24h / 24 if errors_24h > 0 else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return ErrorMetrics(
|
|
||||||
total_errors=total_errors,
|
|
||||||
errors_24h=errors_24h,
|
|
||||||
most_common_errors=most_common_errors,
|
|
||||||
last_error_time=last_error_time,
|
|
||||||
error_rate_per_hour=error_rate_per_hour,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_performance_summary(self) -> Dict[str, Any]:
|
|
||||||
"""Get performance summary from collected samples.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Performance statistics.
|
|
||||||
"""
|
|
||||||
if not self._performance_samples:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
cpu_values = [m.cpu_percent for m in self._performance_samples]
|
|
||||||
memory_values = [m.memory_percent for m in self._performance_samples]
|
|
||||||
disk_values = [m.disk_percent for m in self._performance_samples]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"cpu": {
|
|
||||||
"current": cpu_values[-1],
|
|
||||||
"average": sum(cpu_values) / len(cpu_values),
|
|
||||||
"max": max(cpu_values),
|
|
||||||
"min": min(cpu_values),
|
|
||||||
},
|
|
||||||
"memory": {
|
|
||||||
"current": memory_values[-1],
|
|
||||||
"average": sum(memory_values) / len(memory_values),
|
|
||||||
"max": max(memory_values),
|
|
||||||
"min": min(memory_values),
|
|
||||||
},
|
|
||||||
"disk": {
|
|
||||||
"current": disk_values[-1],
|
|
||||||
"average": sum(disk_values) / len(disk_values),
|
|
||||||
"max": max(disk_values),
|
|
||||||
"min": min(disk_values),
|
|
||||||
},
|
|
||||||
"sample_count": len(self._performance_samples),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_comprehensive_status(
|
|
||||||
self, db: AsyncSession
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Get comprehensive system status summary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Complete system status.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
system_metrics = self.get_system_metrics()
|
|
||||||
queue_metrics = await self.get_queue_metrics(db)
|
|
||||||
error_metrics = self.get_error_metrics()
|
|
||||||
performance = self.get_performance_summary()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"system": {
|
|
||||||
"cpu_percent": system_metrics.cpu_percent,
|
|
||||||
"memory_percent": system_metrics.memory_percent,
|
|
||||||
"disk_percent": system_metrics.disk_percent,
|
|
||||||
"uptime_seconds": system_metrics.uptime_seconds,
|
|
||||||
},
|
|
||||||
"queue": {
|
|
||||||
"total_items": queue_metrics.total_items,
|
|
||||||
"pending": queue_metrics.pending_items,
|
|
||||||
"downloading": queue_metrics.downloading_items,
|
|
||||||
"completed": queue_metrics.completed_items,
|
|
||||||
"failed": queue_metrics.failed_items,
|
|
||||||
"success_rate": round(queue_metrics.success_rate, 2),
|
|
||||||
"average_speed_mbps": round(
|
|
||||||
queue_metrics.average_speed_mbps, 2
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"total": error_metrics.total_errors,
|
|
||||||
"last_24h": error_metrics.errors_24h,
|
|
||||||
"rate_per_hour": round(
|
|
||||||
error_metrics.error_rate_per_hour, 2
|
|
||||||
),
|
|
||||||
"most_common": error_metrics.most_common_errors,
|
|
||||||
},
|
|
||||||
"performance": performance,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get comprehensive status: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# Global monitoring service instance
|
|
||||||
_monitoring_service: Optional[MonitoringService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_monitoring_service() -> MonitoringService:
|
|
||||||
"""Get or create the global monitoring service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
MonitoringService: The monitoring service instance.
|
|
||||||
"""
|
|
||||||
global _monitoring_service
|
|
||||||
if _monitoring_service is None:
|
|
||||||
_monitoring_service = MonitoringService()
|
|
||||||
return _monitoring_service
|
|
||||||
@ -11,7 +11,7 @@ import asyncio
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@ -85,6 +85,30 @@ class ProgressUpdate:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProgressEvent:
|
||||||
|
"""Represents a progress event for subscribers.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
event_type: Type of event (e.g., 'download_progress')
|
||||||
|
progress_id: Unique identifier for the progress operation
|
||||||
|
progress: The progress update data
|
||||||
|
room: WebSocket room to broadcast to (default: 'progress')
|
||||||
|
"""
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
progress_id: str
|
||||||
|
progress: ProgressUpdate
|
||||||
|
room: str = "progress"
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert event to dictionary for broadcasting."""
|
||||||
|
return {
|
||||||
|
"type": self.event_type,
|
||||||
|
"data": self.progress.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ProgressServiceError(Exception):
|
class ProgressServiceError(Exception):
|
||||||
"""Service-level exception for progress operations."""
|
"""Service-level exception for progress operations."""
|
||||||
|
|
||||||
@ -109,44 +133,82 @@ class ProgressService:
|
|||||||
self._history: Dict[str, ProgressUpdate] = {}
|
self._history: Dict[str, ProgressUpdate] = {}
|
||||||
self._max_history_size = 50
|
self._max_history_size = 50
|
||||||
|
|
||||||
# WebSocket broadcast callback
|
# Event subscribers: event_name -> list of handlers
|
||||||
self._broadcast_callback: Optional[Callable] = None
|
self._event_handlers: Dict[
|
||||||
|
str, List[Callable[[ProgressEvent], None]]
|
||||||
|
] = {}
|
||||||
|
|
||||||
# Lock for thread-safe operations
|
# Lock for thread-safe operations
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.info("ProgressService initialized")
|
logger.info("ProgressService initialized")
|
||||||
|
|
||||||
def set_broadcast_callback(self, callback: Callable) -> None:
|
def subscribe(
|
||||||
"""Set callback for broadcasting progress updates via WebSocket.
|
self, event_name: str, handler: Callable[[ProgressEvent], None]
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to progress events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback: Async function to call for broadcasting updates
|
event_name: Name of event to subscribe to
|
||||||
|
(e.g., 'progress_updated')
|
||||||
|
handler: Async function to call when event occurs
|
||||||
"""
|
"""
|
||||||
self._broadcast_callback = callback
|
if event_name not in self._event_handlers:
|
||||||
logger.debug("Progress broadcast callback registered")
|
self._event_handlers[event_name] = []
|
||||||
|
|
||||||
async def _broadcast(self, update: ProgressUpdate, room: str) -> None:
|
self._event_handlers[event_name].append(handler)
|
||||||
"""Broadcast progress update to WebSocket clients.
|
logger.debug("Event handler subscribed", event=event_name)
|
||||||
|
|
||||||
|
def unsubscribe(
|
||||||
|
self, event_name: str, handler: Callable[[ProgressEvent], None]
|
||||||
|
) -> None:
|
||||||
|
"""Unsubscribe from progress events.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
update: Progress update to broadcast
|
event_name: Name of event to unsubscribe from
|
||||||
room: WebSocket room to broadcast to
|
handler: Handler function to remove
|
||||||
"""
|
"""
|
||||||
if self._broadcast_callback:
|
if event_name in self._event_handlers:
|
||||||
try:
|
try:
|
||||||
await self._broadcast_callback(
|
self._event_handlers[event_name].remove(handler)
|
||||||
message_type=f"{update.type.value}_progress",
|
logger.debug("Event handler unsubscribed", event=event_name)
|
||||||
data=update.to_dict(),
|
except ValueError:
|
||||||
room=room,
|
logger.warning(
|
||||||
)
|
"Handler not found for unsubscribe", event=event_name
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast progress update",
|
|
||||||
error=str(e),
|
|
||||||
progress_id=update.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _emit_event(self, event: ProgressEvent) -> None:
|
||||||
|
"""Emit event to all subscribers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Progress event to emit
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Errors in individual handlers are logged but do not
|
||||||
|
prevent other handlers from executing.
|
||||||
|
"""
|
||||||
|
event_name = "progress_updated"
|
||||||
|
|
||||||
|
if event_name in self._event_handlers:
|
||||||
|
handlers = self._event_handlers[event_name]
|
||||||
|
if handlers:
|
||||||
|
# Execute all handlers, capturing exceptions
|
||||||
|
tasks = [handler(event) for handler in handlers]
|
||||||
|
# Ignore type error - tasks will be coroutines at runtime
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*tasks, return_exceptions=True
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Log any exceptions that occurred
|
||||||
|
for idx, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(
|
||||||
|
"Event handler raised exception",
|
||||||
|
event=event_name,
|
||||||
|
error=str(result),
|
||||||
|
handler_index=idx,
|
||||||
|
)
|
||||||
|
|
||||||
async def start_progress(
|
async def start_progress(
|
||||||
self,
|
self,
|
||||||
progress_id: str,
|
progress_id: str,
|
||||||
@ -197,9 +259,15 @@ class ProgressService:
|
|||||||
title=title,
|
title=title,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Broadcast to appropriate room
|
# Emit event to subscribers
|
||||||
room = f"{progress_type.value}_progress"
|
room = f"{progress_type.value}_progress"
|
||||||
await self._broadcast(update, room)
|
event = ProgressEvent(
|
||||||
|
event_type=f"{progress_type.value}_progress",
|
||||||
|
progress_id=progress_id,
|
||||||
|
progress=update,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await self._emit_event(event)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
@ -262,7 +330,13 @@ class ProgressService:
|
|||||||
|
|
||||||
if should_broadcast:
|
if should_broadcast:
|
||||||
room = f"{update.type.value}_progress"
|
room = f"{update.type.value}_progress"
|
||||||
await self._broadcast(update, room)
|
event = ProgressEvent(
|
||||||
|
event_type=f"{update.type.value}_progress",
|
||||||
|
progress_id=progress_id,
|
||||||
|
progress=update,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await self._emit_event(event)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
@ -311,9 +385,15 @@ class ProgressService:
|
|||||||
type=update.type.value,
|
type=update.type.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Broadcast completion
|
# Emit completion event
|
||||||
room = f"{update.type.value}_progress"
|
room = f"{update.type.value}_progress"
|
||||||
await self._broadcast(update, room)
|
event = ProgressEvent(
|
||||||
|
event_type=f"{update.type.value}_progress",
|
||||||
|
progress_id=progress_id,
|
||||||
|
progress=update,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await self._emit_event(event)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
@ -361,9 +441,15 @@ class ProgressService:
|
|||||||
error=error_message,
|
error=error_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Broadcast failure
|
# Emit failure event
|
||||||
room = f"{update.type.value}_progress"
|
room = f"{update.type.value}_progress"
|
||||||
await self._broadcast(update, room)
|
event = ProgressEvent(
|
||||||
|
event_type=f"{update.type.value}_progress",
|
||||||
|
progress_id=progress_id,
|
||||||
|
progress=update,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await self._emit_event(event)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
@ -405,9 +491,15 @@ class ProgressService:
|
|||||||
type=update.type.value,
|
type=update.type.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Broadcast cancellation
|
# Emit cancellation event
|
||||||
room = f"{update.type.value}_progress"
|
room = f"{update.type.value}_progress"
|
||||||
await self._broadcast(update, room)
|
event = ProgressEvent(
|
||||||
|
event_type=f"{update.type.value}_progress",
|
||||||
|
progress_id=progress_id,
|
||||||
|
progress=update,
|
||||||
|
room=room,
|
||||||
|
)
|
||||||
|
await self._emit_event(event)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
|
|||||||
@ -103,40 +103,6 @@ def reset_series_app() -> None:
|
|||||||
_series_app = None
|
_series_app = None
|
||||||
|
|
||||||
|
|
||||||
def get_optional_series_app() -> Optional[SeriesApp]:
|
|
||||||
"""
|
|
||||||
Dependency to optionally get SeriesApp instance.
|
|
||||||
|
|
||||||
Returns None if not configured instead of raising an exception.
|
|
||||||
Useful for endpoints that can validate input before needing the service.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[SeriesApp]: The main application instance or None
|
|
||||||
"""
|
|
||||||
global _series_app
|
|
||||||
|
|
||||||
# Try to load anime_directory from config.json if not in settings
|
|
||||||
if not settings.anime_directory:
|
|
||||||
try:
|
|
||||||
from src.server.services.config_service import get_config_service
|
|
||||||
config_service = get_config_service()
|
|
||||||
config = config_service.load_config()
|
|
||||||
if config.other and config.other.get("anime_directory"):
|
|
||||||
settings.anime_directory = str(config.other["anime_directory"])
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not settings.anime_directory:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if _series_app is None:
|
|
||||||
try:
|
|
||||||
_series_app = SeriesApp(settings.anime_directory)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return _series_app
|
|
||||||
|
|
||||||
|
|
||||||
async def get_database_session() -> AsyncGenerator:
|
async def get_database_session() -> AsyncGenerator:
|
||||||
"""
|
"""
|
||||||
@ -389,7 +355,9 @@ def get_anime_service() -> "AnimeService":
|
|||||||
try:
|
try:
|
||||||
from src.server.services.anime_service import AnimeService
|
from src.server.services.anime_service import AnimeService
|
||||||
|
|
||||||
_anime_service = AnimeService(settings.anime_directory)
|
# Get the singleton SeriesApp instance
|
||||||
|
series_app = get_series_app()
|
||||||
|
_anime_service = AnimeService(series_app)
|
||||||
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,
|
||||||
@ -416,39 +384,14 @@ def get_download_service() -> "DownloadService":
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
anime_service = get_anime_service()
|
anime_service = get_anime_service()
|
||||||
_download_service = DownloadService(anime_service)
|
_download_service = DownloadService(anime_service)
|
||||||
|
|
||||||
ws_service = websocket_service_module.get_websocket_service()
|
# Note: DownloadService no longer needs broadcast callbacks.
|
||||||
|
# Progress updates flow through:
|
||||||
async def broadcast_callback(update_type: str, data: dict) -> None:
|
# SeriesApp → AnimeService → ProgressService → WebSocketService
|
||||||
"""Broadcast download updates via WebSocket."""
|
|
||||||
if update_type == "download_progress":
|
|
||||||
await ws_service.broadcast_download_progress(
|
|
||||||
data.get("download_id", ""),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
elif update_type == "download_complete":
|
|
||||||
await ws_service.broadcast_download_complete(
|
|
||||||
data.get("download_id", ""),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
elif update_type == "download_failed":
|
|
||||||
await ws_service.broadcast_download_failed(
|
|
||||||
data.get("download_id", ""),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
elif update_type == "queue_status":
|
|
||||||
await ws_service.broadcast_queue_status(data)
|
|
||||||
else:
|
|
||||||
await ws_service.broadcast_queue_status(data)
|
|
||||||
|
|
||||||
_download_service.set_broadcast_callback(broadcast_callback)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -1218,6 +1218,52 @@ body {
|
|||||||
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%);
|
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drag and Drop Styles */
|
||||||
|
.draggable-item {
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.98);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-item.drag-over {
|
||||||
|
border-top: 3px solid var(--color-primary);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
transition: color var(--transition-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-list {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-queue-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.download-header {
|
.download-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -1261,11 +1307,11 @@ body {
|
|||||||
.queue-position {
|
.queue-position {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--spacing-sm);
|
top: var(--spacing-sm);
|
||||||
left: var(--spacing-sm);
|
left: 48px;
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
color: white;
|
color: white;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1275,7 +1321,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-card.pending .download-info {
|
.download-card.pending .download-info {
|
||||||
margin-left: 40px;
|
margin-left: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card.pending .download-header {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state small {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress Bars */
|
/* Progress Bars */
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Accessibility Features Module
|
|
||||||
* Enhances accessibility for all users
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize accessibility features
|
|
||||||
*/
|
|
||||||
function initAccessibilityFeatures() {
|
|
||||||
setupFocusManagement();
|
|
||||||
setupAriaLabels();
|
|
||||||
console.log('[Accessibility Features] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup focus management
|
|
||||||
*/
|
|
||||||
function setupFocusManagement() {
|
|
||||||
// Add focus visible class for keyboard navigation
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
document.body.classList.add('keyboard-navigation');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', () => {
|
|
||||||
document.body.classList.remove('keyboard-navigation');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup ARIA labels for dynamic content
|
|
||||||
*/
|
|
||||||
function setupAriaLabels() {
|
|
||||||
// Ensure all interactive elements have proper ARIA labels
|
|
||||||
const buttons = document.querySelectorAll('button:not([aria-label])');
|
|
||||||
buttons.forEach(button => {
|
|
||||||
if (!button.getAttribute('aria-label') && button.title) {
|
|
||||||
button.setAttribute('aria-label', button.title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Announce message to screen readers
|
|
||||||
*/
|
|
||||||
function announceToScreenReader(message, priority = 'polite') {
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('role', 'status');
|
|
||||||
announcement.setAttribute('aria-live', priority);
|
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
|
||||||
announcement.className = 'sr-only';
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.Accessibility = {
|
|
||||||
announce: announceToScreenReader
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initAccessibilityFeatures);
|
|
||||||
} else {
|
|
||||||
initAccessibilityFeatures();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Advanced Search Module
|
|
||||||
* Provides advanced search and filtering capabilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize advanced search
|
|
||||||
*/
|
|
||||||
function initAdvancedSearch() {
|
|
||||||
console.log('[Advanced Search] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement advanced search features
|
|
||||||
// - Filter by genre
|
|
||||||
// - Filter by year
|
|
||||||
// - Filter by status
|
|
||||||
// - Sort options
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initAdvancedSearch);
|
|
||||||
} else {
|
|
||||||
initAdvancedSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -26,10 +26,6 @@ class AniWorldApp {
|
|||||||
this.loadSeries();
|
this.loadSeries();
|
||||||
this.initTheme();
|
this.initTheme();
|
||||||
this.updateConnectionStatus();
|
this.updateConnectionStatus();
|
||||||
this.startProcessStatusMonitoring();
|
|
||||||
|
|
||||||
// Initialize Mobile & Accessibility features
|
|
||||||
this.initMobileAndAccessibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAuthentication() {
|
async checkAuthentication() {
|
||||||
@ -196,7 +192,6 @@ class AniWorldApp {
|
|||||||
|
|
||||||
this.showToast(this.localization.getText('connected-server'), 'success');
|
this.showToast(this.localization.getText('connected-server'), 'success');
|
||||||
this.updateConnectionStatus();
|
this.updateConnectionStatus();
|
||||||
this.checkProcessLocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('disconnect', () => {
|
this.socket.on('disconnect', () => {
|
||||||
@ -505,19 +500,6 @@ class AniWorldApp {
|
|||||||
this.hideStatus();
|
this.hideStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download controls
|
|
||||||
document.getElementById('pause-download').addEventListener('click', () => {
|
|
||||||
this.pauseDownload();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('resume-download').addEventListener('click', () => {
|
|
||||||
this.resumeDownload();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('cancel-download').addEventListener('click', () => {
|
|
||||||
this.cancelDownload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logout functionality
|
// Logout functionality
|
||||||
document.getElementById('logout-btn').addEventListener('click', () => {
|
document.getElementById('logout-btn').addEventListener('click', () => {
|
||||||
this.logout();
|
this.logout();
|
||||||
@ -834,10 +816,13 @@ class AniWorldApp {
|
|||||||
if (!response) return;
|
if (!response) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'success') {
|
// Check if response is a direct array (new format) or wrapped object (legacy)
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
this.displaySearchResults(data);
|
||||||
|
} else if (data.status === 'success') {
|
||||||
this.displaySearchResults(data.results);
|
this.displaySearchResults(data.results);
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Search error: ${data.message}`, 'error');
|
this.showToast(`Search error: ${data.message || 'Unknown error'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
@ -902,6 +887,7 @@ class AniWorldApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async downloadSelected() {
|
async downloadSelected() {
|
||||||
|
console.log('=== downloadSelected v1.1 - DEBUG VERSION ===');
|
||||||
if (this.selectedSeries.size === 0) {
|
if (this.selectedSeries.size === 0) {
|
||||||
this.showToast('No series selected', 'warning');
|
this.showToast('No series selected', 'warning');
|
||||||
return;
|
return;
|
||||||
@ -909,22 +895,104 @@ class AniWorldApp {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const folders = Array.from(this.selectedSeries);
|
const folders = Array.from(this.selectedSeries);
|
||||||
|
console.log('=== Starting download for selected series ===');
|
||||||
|
console.log('Selected folders:', folders);
|
||||||
|
console.log('seriesData:', this.seriesData);
|
||||||
|
let totalEpisodesAdded = 0;
|
||||||
|
let failedSeries = [];
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/anime/download', {
|
// For each selected series, get its missing episodes and add to queue
|
||||||
method: 'POST',
|
for (const folder of folders) {
|
||||||
headers: {
|
const serie = this.seriesData.find(s => s.folder === folder);
|
||||||
'Content-Type': 'application/json',
|
if (!serie || !serie.episodeDict) {
|
||||||
},
|
console.error('Serie not found or has no episodeDict:', folder, serie);
|
||||||
body: JSON.stringify({ folders })
|
failedSeries.push(folder);
|
||||||
});
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response) return;
|
// Validate required fields
|
||||||
const data = await response.json();
|
if (!serie.key) {
|
||||||
|
console.error('Serie missing key:', serie);
|
||||||
|
failedSeries.push(folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'success') {
|
// Convert episodeDict format {season: [episodes]} to episode identifiers
|
||||||
this.showToast('Download started', 'success');
|
const episodes = [];
|
||||||
|
for (const [season, episodeNumbers] of Object.entries(serie.episodeDict)) {
|
||||||
|
if (Array.isArray(episodeNumbers)) {
|
||||||
|
for (const episode of episodeNumbers) {
|
||||||
|
episodes.push({
|
||||||
|
season: parseInt(season),
|
||||||
|
episode: episode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
console.log('No episodes to add for serie:', serie.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use folder name as fallback if serie name is empty
|
||||||
|
const serieName = serie.name && serie.name.trim() ? serie.name : serie.folder;
|
||||||
|
|
||||||
|
// Add episodes to download queue
|
||||||
|
const requestBody = {
|
||||||
|
serie_id: serie.key,
|
||||||
|
serie_folder: serie.folder,
|
||||||
|
serie_name: serieName,
|
||||||
|
episodes: episodes,
|
||||||
|
priority: 'NORMAL'
|
||||||
|
};
|
||||||
|
console.log('Sending queue add request:', requestBody);
|
||||||
|
|
||||||
|
const response = await this.makeAuthenticatedRequest('/api/queue/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
failedSeries.push(folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Queue add response:', response.status, data);
|
||||||
|
|
||||||
|
// Log validation errors in detail
|
||||||
|
if (data.detail && Array.isArray(data.detail)) {
|
||||||
|
console.error('Validation errors:', JSON.stringify(data.detail, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok && data.status === 'success') {
|
||||||
|
totalEpisodesAdded += episodes.length;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to add to queue:', data);
|
||||||
|
failedSeries.push(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show result message
|
||||||
|
console.log('=== Download request complete ===');
|
||||||
|
console.log('Total episodes added:', totalEpisodesAdded);
|
||||||
|
console.log('Failed series:', failedSeries);
|
||||||
|
|
||||||
|
if (totalEpisodesAdded > 0) {
|
||||||
|
const message = failedSeries.length > 0
|
||||||
|
? `Added ${totalEpisodesAdded} episode(s) to queue (${failedSeries.length} series failed)`
|
||||||
|
: `Added ${totalEpisodesAdded} episode(s) to download queue`;
|
||||||
|
this.showToast(message, 'success');
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Download error: ${data.message}`, 'error');
|
const errorDetails = failedSeries.length > 0
|
||||||
|
? `Failed series: ${failedSeries.join(', ')}`
|
||||||
|
: 'No episodes were added. Check browser console for details.';
|
||||||
|
console.error('Failed to add episodes. Details:', errorDetails);
|
||||||
|
this.showToast('Failed to add episodes to queue. Check console for details.', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download error:', error);
|
console.error('Download error:', error);
|
||||||
@ -1099,74 +1167,6 @@ class AniWorldApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkProcessLocks() {
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/anime/process/locks');
|
|
||||||
if (!response) {
|
|
||||||
// If no response, set status as idle
|
|
||||||
this.updateProcessStatus('rescan', false);
|
|
||||||
this.updateProcessStatus('download', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is actually JSON and not HTML (login page)
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
if (!contentType || !contentType.includes('application/json')) {
|
|
||||||
console.warn('Process locks API returned non-JSON response, likely authentication issue');
|
|
||||||
// Set status as idle if we can't get proper response
|
|
||||||
this.updateProcessStatus('rescan', false);
|
|
||||||
this.updateProcessStatus('download', false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
const locks = data.locks;
|
|
||||||
this.updateProcessStatus('rescan', locks.rescan?.is_locked || false);
|
|
||||||
this.updateProcessStatus('download', locks.download?.is_locked || false);
|
|
||||||
|
|
||||||
// Update button states
|
|
||||||
const rescanBtn = document.getElementById('rescan-btn');
|
|
||||||
if (rescanBtn) {
|
|
||||||
if (locks.rescan?.is_locked) {
|
|
||||||
rescanBtn.disabled = true;
|
|
||||||
const span = rescanBtn.querySelector('span');
|
|
||||||
if (span) span.textContent = 'Scanning...';
|
|
||||||
} else {
|
|
||||||
rescanBtn.disabled = false;
|
|
||||||
const span = rescanBtn.querySelector('span');
|
|
||||||
if (span) span.textContent = 'Rescan';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If API returns error, set status as idle
|
|
||||||
console.warn('Process locks API returned error:', data.error);
|
|
||||||
this.updateProcessStatus('rescan', false);
|
|
||||||
this.updateProcessStatus('download', false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking process locks:', error);
|
|
||||||
// On error, set status as idle
|
|
||||||
this.updateProcessStatus('rescan', false);
|
|
||||||
this.updateProcessStatus('download', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startProcessStatusMonitoring() {
|
|
||||||
// Initial check on page load
|
|
||||||
this.checkProcessLocks();
|
|
||||||
|
|
||||||
// Check process status every 5 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
if (this.isConnected) {
|
|
||||||
this.checkProcessLocks();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
console.log('Process status monitoring started');
|
|
||||||
}
|
|
||||||
|
|
||||||
async showConfigModal() {
|
async showConfigModal() {
|
||||||
const modal = document.getElementById('config-modal');
|
const modal = document.getElementById('config-modal');
|
||||||
|
|
||||||
@ -1723,155 +1723,6 @@ class AniWorldApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showBackupsModal(backups) {
|
|
||||||
// Create modal to show backups
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'modal';
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
const modalContent = document.createElement('div');
|
|
||||||
modalContent.className = 'modal-content';
|
|
||||||
modalContent.style.maxWidth = '60%';
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.innerHTML = '<h3>Configuration Backups</h3>';
|
|
||||||
|
|
||||||
const backupList = document.createElement('div');
|
|
||||||
backupList.className = 'backup-list';
|
|
||||||
|
|
||||||
if (backups.length === 0) {
|
|
||||||
backupList.innerHTML = '<div class="backup-item"><span>No backups found</span></div>';
|
|
||||||
} else {
|
|
||||||
backups.forEach(backup => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'backup-item';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'backup-info';
|
|
||||||
|
|
||||||
const name = document.createElement('div');
|
|
||||||
name.className = 'backup-name';
|
|
||||||
name.textContent = backup.filename;
|
|
||||||
|
|
||||||
const details = document.createElement('div');
|
|
||||||
details.className = 'backup-details';
|
|
||||||
details.textContent = `Size: ${backup.size_kb} KB • Modified: ${backup.modified_display}`;
|
|
||||||
|
|
||||||
info.appendChild(name);
|
|
||||||
info.appendChild(details);
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'backup-actions';
|
|
||||||
|
|
||||||
const restoreBtn = document.createElement('button');
|
|
||||||
restoreBtn.className = 'btn btn-xs btn-primary';
|
|
||||||
restoreBtn.textContent = 'Restore';
|
|
||||||
restoreBtn.onclick = () => {
|
|
||||||
if (confirm('Are you sure you want to restore this backup? Current configuration will be overwritten.')) {
|
|
||||||
this.restoreBackup(backup.filename);
|
|
||||||
document.body.removeChild(modal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBtn = document.createElement('button');
|
|
||||||
downloadBtn.className = 'btn btn-xs btn-secondary';
|
|
||||||
downloadBtn.textContent = 'Download';
|
|
||||||
downloadBtn.onclick = () => this.downloadBackup(backup.filename);
|
|
||||||
|
|
||||||
actions.appendChild(restoreBtn);
|
|
||||||
actions.appendChild(downloadBtn);
|
|
||||||
|
|
||||||
item.appendChild(info);
|
|
||||||
item.appendChild(actions);
|
|
||||||
|
|
||||||
backupList.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeBtn = document.createElement('button');
|
|
||||||
closeBtn.textContent = 'Close';
|
|
||||||
closeBtn.className = 'btn btn-secondary';
|
|
||||||
closeBtn.onclick = () => document.body.removeChild(modal);
|
|
||||||
|
|
||||||
modalContent.appendChild(header);
|
|
||||||
modalContent.appendChild(backupList);
|
|
||||||
modalContent.appendChild(closeBtn);
|
|
||||||
modal.appendChild(modalContent);
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
// Close on background click
|
|
||||||
modal.onclick = (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
document.body.removeChild(modal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreBackup(filename) {
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest(`/api/config/backup/${encodeURIComponent(filename)}/restore`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response) return;
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
this.showToast('Configuration restored successfully', 'success');
|
|
||||||
// Reload the config modal
|
|
||||||
setTimeout(() => {
|
|
||||||
this.hideConfigModal();
|
|
||||||
this.showConfigModal();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
this.showToast(`Failed to restore backup: ${data.error}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error restoring backup:', error);
|
|
||||||
this.showToast('Failed to restore backup', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadBackup(filename) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = `/api/config/backup/${encodeURIComponent(filename)}/download`;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportConfig() {
|
|
||||||
try {
|
|
||||||
const includeSensitive = confirm('Include sensitive data (passwords, salts)? Click Cancel for safe export without sensitive data.');
|
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/config/export', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ include_sensitive: includeSensitive })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response && response.ok) {
|
|
||||||
// Handle file download
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `aniworld_config_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
this.showToast('Configuration exported successfully', 'success');
|
|
||||||
} else {
|
|
||||||
this.showToast('Failed to export configuration', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting config:', error);
|
|
||||||
this.showToast('Failed to export configuration', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateConfig() {
|
async validateConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/config/validate', {
|
const response = await this.makeAuthenticatedRequest('/api/config/validate', {
|
||||||
@ -1956,57 +1807,6 @@ class AniWorldApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pauseDownload() {
|
|
||||||
if (!this.isDownloading || this.isPaused) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/pause', { method: 'POST' });
|
|
||||||
if (!response) return;
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('pause-download').classList.add('hidden');
|
|
||||||
document.getElementById('resume-download').classList.remove('hidden');
|
|
||||||
this.showToast('Queue paused', 'warning');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Pause error:', error);
|
|
||||||
this.showToast('Failed to pause queue', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeDownload() {
|
|
||||||
if (!this.isDownloading || !this.isPaused) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/resume', { method: 'POST' });
|
|
||||||
if (!response) return;
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('pause-download').classList.remove('hidden');
|
|
||||||
document.getElementById('resume-download').classList.add('hidden');
|
|
||||||
this.showToast('Queue resumed', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Resume error:', error);
|
|
||||||
this.showToast('Failed to resume queue', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelDownload() {
|
|
||||||
if (!this.isDownloading) return;
|
|
||||||
|
|
||||||
if (confirm('Are you sure you want to stop the download queue?')) {
|
|
||||||
try {
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/stop', { method: 'POST' });
|
|
||||||
if (!response) return;
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
this.showToast('Queue stopped', 'warning');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Stop error:', error);
|
|
||||||
this.showToast('Failed to stop queue', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showDownloadQueue(data) {
|
showDownloadQueue(data) {
|
||||||
const queueSection = document.getElementById('download-queue-section');
|
const queueSection = document.getElementById('download-queue-section');
|
||||||
const queueProgress = document.getElementById('queue-progress');
|
const queueProgress = document.getElementById('queue-progress');
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bulk Operations Module
|
|
||||||
* Handles bulk selection and operations on multiple series
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize bulk operations
|
|
||||||
*/
|
|
||||||
function initBulkOperations() {
|
|
||||||
console.log('[Bulk Operations] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement bulk operations
|
|
||||||
// - Select multiple series
|
|
||||||
// - Bulk download
|
|
||||||
// - Bulk mark as watched
|
|
||||||
// - Bulk delete
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initBulkOperations);
|
|
||||||
} else {
|
|
||||||
initBulkOperations();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Color Contrast Compliance Module
|
|
||||||
* Ensures WCAG color contrast compliance
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize color contrast compliance
|
|
||||||
*/
|
|
||||||
function initColorContrastCompliance() {
|
|
||||||
checkContrastCompliance();
|
|
||||||
console.log('[Color Contrast Compliance] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if color contrast meets WCAG standards
|
|
||||||
*/
|
|
||||||
function checkContrastCompliance() {
|
|
||||||
// This would typically check computed styles
|
|
||||||
// For now, we rely on CSS variables defined in styles.css
|
|
||||||
console.log('[Color Contrast] Relying on predefined WCAG-compliant color scheme');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate contrast ratio between two colors
|
|
||||||
*/
|
|
||||||
function calculateContrastRatio(color1, color2) {
|
|
||||||
// Simplified contrast calculation
|
|
||||||
// Real implementation would use relative luminance
|
|
||||||
return 4.5; // Placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initColorContrastCompliance);
|
|
||||||
} else {
|
|
||||||
initColorContrastCompliance();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drag and Drop Module
|
|
||||||
* Handles drag-and-drop functionality for series cards
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize drag and drop
|
|
||||||
*/
|
|
||||||
function initDragDrop() {
|
|
||||||
console.log('[Drag & Drop] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement drag-and-drop for series cards
|
|
||||||
// This will allow users to reorder series or add to queue via drag-and-drop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initDragDrop);
|
|
||||||
} else {
|
|
||||||
initDragDrop();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* Keyboard Shortcuts Module
|
|
||||||
* Handles keyboard navigation and shortcuts for improved accessibility
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Keyboard shortcuts configuration
|
|
||||||
const shortcuts = {
|
|
||||||
'ctrl+k': 'focusSearch',
|
|
||||||
'ctrl+r': 'triggerRescan',
|
|
||||||
'ctrl+q': 'openQueue',
|
|
||||||
'escape': 'closeModals',
|
|
||||||
'tab': 'navigationMode',
|
|
||||||
'/': 'focusSearch'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize keyboard shortcuts
|
|
||||||
*/
|
|
||||||
function initKeyboardShortcuts() {
|
|
||||||
document.addEventListener('keydown', handleKeydown);
|
|
||||||
console.log('[Keyboard Shortcuts] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle keydown events
|
|
||||||
*/
|
|
||||||
function handleKeydown(event) {
|
|
||||||
const key = getKeyCombo(event);
|
|
||||||
|
|
||||||
if (shortcuts[key]) {
|
|
||||||
const action = shortcuts[key];
|
|
||||||
handleShortcut(action, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get key combination string
|
|
||||||
*/
|
|
||||||
function getKeyCombo(event) {
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
if (event.ctrlKey) parts.push('ctrl');
|
|
||||||
if (event.altKey) parts.push('alt');
|
|
||||||
if (event.shiftKey) parts.push('shift');
|
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
parts.push(key);
|
|
||||||
|
|
||||||
return parts.join('+');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle keyboard shortcut action
|
|
||||||
*/
|
|
||||||
function handleShortcut(action, event) {
|
|
||||||
switch(action) {
|
|
||||||
case 'focusSearch':
|
|
||||||
event.preventDefault();
|
|
||||||
focusSearchInput();
|
|
||||||
break;
|
|
||||||
case 'triggerRescan':
|
|
||||||
event.preventDefault();
|
|
||||||
triggerRescan();
|
|
||||||
break;
|
|
||||||
case 'openQueue':
|
|
||||||
event.preventDefault();
|
|
||||||
openQueue();
|
|
||||||
break;
|
|
||||||
case 'closeModals':
|
|
||||||
closeAllModals();
|
|
||||||
break;
|
|
||||||
case 'navigationMode':
|
|
||||||
handleTabNavigation(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Focus search input
|
|
||||||
*/
|
|
||||||
function focusSearchInput() {
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
searchInput.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger rescan
|
|
||||||
*/
|
|
||||||
function triggerRescan() {
|
|
||||||
const rescanBtn = document.getElementById('rescan-btn');
|
|
||||||
if (rescanBtn && !rescanBtn.disabled) {
|
|
||||||
rescanBtn.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open queue page
|
|
||||||
*/
|
|
||||||
function openQueue() {
|
|
||||||
window.location.href = '/queue';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all open modals
|
|
||||||
*/
|
|
||||||
function closeAllModals() {
|
|
||||||
const modals = document.querySelectorAll('.modal.active');
|
|
||||||
modals.forEach(modal => {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle tab navigation with visual indicators
|
|
||||||
*/
|
|
||||||
function handleTabNavigation(event) {
|
|
||||||
// Add keyboard-focus class to focused element
|
|
||||||
const previousFocus = document.querySelector('.keyboard-focus');
|
|
||||||
if (previousFocus) {
|
|
||||||
previousFocus.classList.remove('keyboard-focus');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will be applied after tab completes
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.activeElement) {
|
|
||||||
document.activeElement.classList.add('keyboard-focus');
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initKeyboardShortcuts);
|
|
||||||
} else {
|
|
||||||
initKeyboardShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Responsive Module
|
|
||||||
* Handles mobile-specific functionality and responsive behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let isMobile = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize mobile responsive features
|
|
||||||
*/
|
|
||||||
function initMobileResponsive() {
|
|
||||||
detectMobile();
|
|
||||||
setupResponsiveHandlers();
|
|
||||||
console.log('[Mobile Responsive] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if device is mobile
|
|
||||||
*/
|
|
||||||
function detectMobile() {
|
|
||||||
isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
document.body.classList.add('mobile-device');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup responsive event handlers
|
|
||||||
*/
|
|
||||||
function setupResponsiveHandlers() {
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
handleResize(); // Initial call
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle window resize
|
|
||||||
*/
|
|
||||||
function handleResize() {
|
|
||||||
const width = window.innerWidth;
|
|
||||||
|
|
||||||
if (width < 768) {
|
|
||||||
applyMobileLayout();
|
|
||||||
} else {
|
|
||||||
applyDesktopLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply mobile-specific layout
|
|
||||||
*/
|
|
||||||
function applyMobileLayout() {
|
|
||||||
document.body.classList.add('mobile-layout');
|
|
||||||
document.body.classList.remove('desktop-layout');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply desktop-specific layout
|
|
||||||
*/
|
|
||||||
function applyDesktopLayout() {
|
|
||||||
document.body.classList.add('desktop-layout');
|
|
||||||
document.body.classList.remove('mobile-layout');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.MobileResponsive = {
|
|
||||||
isMobile: () => isMobile
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initMobileResponsive);
|
|
||||||
} else {
|
|
||||||
initMobileResponsive();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Multi-Screen Support Module
|
|
||||||
* Handles multi-monitor and window management
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize multi-screen support
|
|
||||||
*/
|
|
||||||
function initMultiScreenSupport() {
|
|
||||||
if ('screen' in window) {
|
|
||||||
detectScreens();
|
|
||||||
console.log('[Multi-Screen Support] Initialized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect available screens
|
|
||||||
*/
|
|
||||||
function detectScreens() {
|
|
||||||
// Modern browsers support window.screen
|
|
||||||
const screenInfo = {
|
|
||||||
width: window.screen.width,
|
|
||||||
height: window.screen.height,
|
|
||||||
availWidth: window.screen.availWidth,
|
|
||||||
availHeight: window.screen.availHeight,
|
|
||||||
colorDepth: window.screen.colorDepth,
|
|
||||||
pixelDepth: window.screen.pixelDepth
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[Multi-Screen] Screen info:', screenInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request fullscreen
|
|
||||||
*/
|
|
||||||
function requestFullscreen() {
|
|
||||||
const elem = document.documentElement;
|
|
||||||
if (elem.requestFullscreen) {
|
|
||||||
elem.requestFullscreen();
|
|
||||||
} else if (elem.webkitRequestFullscreen) {
|
|
||||||
elem.webkitRequestFullscreen();
|
|
||||||
} else if (elem.msRequestFullscreen) {
|
|
||||||
elem.msRequestFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exit fullscreen
|
|
||||||
*/
|
|
||||||
function exitFullscreen() {
|
|
||||||
if (document.exitFullscreen) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else if (document.webkitExitFullscreen) {
|
|
||||||
document.webkitExitFullscreen();
|
|
||||||
} else if (document.msExitFullscreen) {
|
|
||||||
document.msExitFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.MultiScreen = {
|
|
||||||
requestFullscreen: requestFullscreen,
|
|
||||||
exitFullscreen: exitFullscreen
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initMultiScreenSupport);
|
|
||||||
} else {
|
|
||||||
initMultiScreenSupport();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -6,7 +6,7 @@ class QueueManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.refreshInterval = null;
|
this.refreshInterval = null;
|
||||||
this.isReordering = false;
|
this.pendingProgressUpdates = new Map(); // Store progress updates waiting for cards
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@ -15,8 +15,9 @@ class QueueManager {
|
|||||||
this.initSocket();
|
this.initSocket();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.initTheme();
|
this.initTheme();
|
||||||
this.startRefreshTimer();
|
// Remove polling - use WebSocket events for real-time updates
|
||||||
this.loadQueueData();
|
// this.startRefreshTimer(); // ← REMOVED
|
||||||
|
this.loadQueueData(); // Load initial data once
|
||||||
}
|
}
|
||||||
|
|
||||||
initSocket() {
|
initSocket() {
|
||||||
@ -55,21 +56,21 @@ class QueueManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('download_progress_update', (data) => {
|
|
||||||
this.updateDownloadProgress(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download queue events
|
// Download queue events
|
||||||
this.socket.on('download_started', () => {
|
this.socket.on('download_started', () => {
|
||||||
this.showToast('Download queue started', 'success');
|
this.showToast('Download queue started', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
// Full reload needed - queue structure changed
|
||||||
|
this.loadQueueData();
|
||||||
});
|
});
|
||||||
this.socket.on('queue_started', () => {
|
this.socket.on('queue_started', () => {
|
||||||
this.showToast('Download queue started', 'success');
|
this.showToast('Download queue started', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
// Full reload needed - queue structure changed
|
||||||
|
this.loadQueueData();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('download_progress', (data) => {
|
this.socket.on('download_progress', (data) => {
|
||||||
|
// Update progress in real-time without reloading all data
|
||||||
|
console.log('Received download progress:', data);
|
||||||
this.updateDownloadProgress(data);
|
this.updateDownloadProgress(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,7 +79,15 @@ class QueueManager {
|
|||||||
const serieName = data.serie_name || data.serie || 'Unknown';
|
const serieName = data.serie_name || data.serie || 'Unknown';
|
||||||
const episode = data.episode || '';
|
const episode = data.episode || '';
|
||||||
this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success');
|
this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
|
||||||
|
// Clear any pending progress updates for this download
|
||||||
|
const downloadId = data.item_id || data.download_id || data.id;
|
||||||
|
if (downloadId) {
|
||||||
|
this.pendingProgressUpdates.delete(downloadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full reload needed - item moved from active to completed
|
||||||
|
this.loadQueueData();
|
||||||
};
|
};
|
||||||
this.socket.on('download_completed', handleDownloadComplete);
|
this.socket.on('download_completed', handleDownloadComplete);
|
||||||
this.socket.on('download_complete', handleDownloadComplete);
|
this.socket.on('download_complete', handleDownloadComplete);
|
||||||
@ -87,14 +96,29 @@ class QueueManager {
|
|||||||
const handleDownloadError = (data) => {
|
const handleDownloadError = (data) => {
|
||||||
const message = data.error || data.message || 'Unknown error';
|
const message = data.error || data.message || 'Unknown error';
|
||||||
this.showToast(`Download failed: ${message}`, 'error');
|
this.showToast(`Download failed: ${message}`, 'error');
|
||||||
this.loadQueueData(); // Refresh data
|
|
||||||
|
// Clear any pending progress updates for this download
|
||||||
|
const downloadId = data.item_id || data.download_id || data.id;
|
||||||
|
if (downloadId) {
|
||||||
|
this.pendingProgressUpdates.delete(downloadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full reload needed - item moved from active to failed
|
||||||
|
this.loadQueueData();
|
||||||
};
|
};
|
||||||
this.socket.on('download_error', handleDownloadError);
|
this.socket.on('download_error', handleDownloadError);
|
||||||
this.socket.on('download_failed', handleDownloadError);
|
this.socket.on('download_failed', handleDownloadError);
|
||||||
|
|
||||||
this.socket.on('download_queue_completed', () => {
|
this.socket.on('download_queue_completed', () => {
|
||||||
this.showToast('All downloads completed!', 'success');
|
this.showToast('All downloads completed!', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
// Full reload needed - queue state changed
|
||||||
|
this.loadQueueData();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('queue_completed', () => {
|
||||||
|
this.showToast('All downloads completed!', 'success');
|
||||||
|
// Full reload needed - queue state changed
|
||||||
|
this.loadQueueData();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('download_stop_requested', () => {
|
this.socket.on('download_stop_requested', () => {
|
||||||
@ -104,7 +128,8 @@ class QueueManager {
|
|||||||
// Handle both old and new queue stopped events
|
// Handle both old and new queue stopped events
|
||||||
const handleQueueStopped = () => {
|
const handleQueueStopped = () => {
|
||||||
this.showToast('Download queue stopped', 'success');
|
this.showToast('Download queue stopped', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
// Full reload needed - queue state changed
|
||||||
|
this.loadQueueData();
|
||||||
};
|
};
|
||||||
this.socket.on('download_stopped', handleQueueStopped);
|
this.socket.on('download_stopped', handleQueueStopped);
|
||||||
this.socket.on('queue_stopped', handleQueueStopped);
|
this.socket.on('queue_stopped', handleQueueStopped);
|
||||||
@ -112,11 +137,13 @@ class QueueManager {
|
|||||||
// Handle queue paused/resumed
|
// Handle queue paused/resumed
|
||||||
this.socket.on('queue_paused', () => {
|
this.socket.on('queue_paused', () => {
|
||||||
this.showToast('Queue paused', 'info');
|
this.showToast('Queue paused', 'info');
|
||||||
|
// Full reload needed - queue state changed
|
||||||
this.loadQueueData();
|
this.loadQueueData();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('queue_resumed', () => {
|
this.socket.on('queue_resumed', () => {
|
||||||
this.showToast('Queue resumed', 'success');
|
this.showToast('Queue resumed', 'success');
|
||||||
|
// Full reload needed - queue state changed
|
||||||
this.loadQueueData();
|
this.loadQueueData();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -128,10 +155,6 @@ class QueueManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Queue management actions
|
// Queue management actions
|
||||||
document.getElementById('clear-queue-btn').addEventListener('click', () => {
|
|
||||||
this.clearQueue('pending');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('clear-completed-btn').addEventListener('click', () => {
|
document.getElementById('clear-completed-btn').addEventListener('click', () => {
|
||||||
this.clearQueue('completed');
|
this.clearQueue('completed');
|
||||||
});
|
});
|
||||||
@ -140,29 +163,21 @@ class QueueManager {
|
|||||||
this.clearQueue('failed');
|
this.clearQueue('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('clear-pending-btn').addEventListener('click', () => {
|
||||||
|
this.clearQueue('pending');
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('retry-all-btn').addEventListener('click', () => {
|
document.getElementById('retry-all-btn').addEventListener('click', () => {
|
||||||
this.retryAllFailed();
|
this.retryAllFailed();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('reorder-queue-btn').addEventListener('click', () => {
|
|
||||||
this.toggleReorderMode();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download controls
|
// Download controls
|
||||||
document.getElementById('start-queue-btn').addEventListener('click', () => {
|
document.getElementById('start-queue-btn').addEventListener('click', () => {
|
||||||
this.startDownloadQueue();
|
this.startDownload();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('stop-queue-btn').addEventListener('click', () => {
|
document.getElementById('stop-queue-btn').addEventListener('click', () => {
|
||||||
this.stopDownloadQueue();
|
this.stopDownloads();
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pause-all-btn').addEventListener('click', () => {
|
|
||||||
this.pauseAllDownloads();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('resume-all-btn').addEventListener('click', () => {
|
|
||||||
this.resumeAllDownloads();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal events
|
// Modal events
|
||||||
@ -217,6 +232,9 @@ class QueueManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.updateQueueDisplay(data);
|
this.updateQueueDisplay(data);
|
||||||
|
|
||||||
|
// Process any pending progress updates after queue is loaded
|
||||||
|
this.processPendingProgressUpdates();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading queue data:', error);
|
console.error('Error loading queue data:', error);
|
||||||
@ -244,18 +262,26 @@ class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateStatistics(stats, data) {
|
updateStatistics(stats, data) {
|
||||||
document.getElementById('total-items').textContent = stats.total_items || 0;
|
// Ensure stats object exists
|
||||||
|
const statistics = stats || {};
|
||||||
|
|
||||||
|
document.getElementById('total-items').textContent = statistics.total_items || 0;
|
||||||
document.getElementById('pending-items').textContent = (data.pending_queue || []).length;
|
document.getElementById('pending-items').textContent = (data.pending_queue || []).length;
|
||||||
document.getElementById('completed-items').textContent = stats.completed_items || 0;
|
document.getElementById('completed-items').textContent = statistics.completed_items || 0;
|
||||||
document.getElementById('failed-items').textContent = stats.failed_items || 0;
|
document.getElementById('failed-items').textContent = statistics.failed_items || 0;
|
||||||
|
|
||||||
document.getElementById('current-speed').textContent = stats.current_speed || '0 MB/s';
|
// Update section counts
|
||||||
document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s';
|
document.getElementById('queue-count').textContent = (data.pending_queue || []).length;
|
||||||
|
document.getElementById('completed-count').textContent = statistics.completed_items || 0;
|
||||||
|
document.getElementById('failed-count').textContent = statistics.failed_items || 0;
|
||||||
|
|
||||||
|
document.getElementById('current-speed').textContent = statistics.current_speed || '0 MB/s';
|
||||||
|
document.getElementById('average-speed').textContent = statistics.average_speed || '0 MB/s';
|
||||||
|
|
||||||
// Format ETA
|
// Format ETA
|
||||||
const etaElement = document.getElementById('eta-time');
|
const etaElement = document.getElementById('eta-time');
|
||||||
if (stats.eta) {
|
if (statistics.eta) {
|
||||||
const eta = new Date(stats.eta);
|
const eta = new Date(statistics.eta);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = eta - now;
|
const diffMs = eta - now;
|
||||||
|
|
||||||
@ -271,6 +297,159 @@ class QueueManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update download progress in real-time
|
||||||
|
* @param {Object} data - Progress data from WebSocket
|
||||||
|
*/
|
||||||
|
updateDownloadProgress(data) {
|
||||||
|
console.log('updateDownloadProgress called with:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// Extract download ID - prioritize metadata.item_id (actual item ID)
|
||||||
|
// Progress service sends id with "download_" prefix, but we need the actual item ID
|
||||||
|
let downloadId = null;
|
||||||
|
|
||||||
|
// First try metadata.item_id (this is the actual download item ID)
|
||||||
|
if (data.metadata && data.metadata.item_id) {
|
||||||
|
downloadId = data.metadata.item_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to other ID fields
|
||||||
|
if (!downloadId) {
|
||||||
|
downloadId = data.item_id || data.download_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ID starts with "download_", extract the actual ID
|
||||||
|
if (!downloadId && data.id) {
|
||||||
|
if (data.id.startsWith('download_')) {
|
||||||
|
downloadId = data.id.substring(9); // Remove "download_" prefix
|
||||||
|
} else {
|
||||||
|
downloadId = data.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data is wrapped in another 'data' property
|
||||||
|
if (!downloadId && data.data) {
|
||||||
|
if (data.data.metadata && data.data.metadata.item_id) {
|
||||||
|
downloadId = data.data.metadata.item_id;
|
||||||
|
} else if (data.data.item_id) {
|
||||||
|
downloadId = data.data.item_id;
|
||||||
|
} else if (data.data.id && data.data.id.startsWith('download_')) {
|
||||||
|
downloadId = data.data.id.substring(9);
|
||||||
|
} else {
|
||||||
|
downloadId = data.data.id || data.data.download_id;
|
||||||
|
}
|
||||||
|
data = data.data; // Use nested data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadId) {
|
||||||
|
console.warn('No download ID in progress data');
|
||||||
|
console.warn('Data structure:', data);
|
||||||
|
console.warn('Available keys:', Object.keys(data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Looking for download card with ID: ${downloadId}`);
|
||||||
|
|
||||||
|
// Find the download card in active downloads
|
||||||
|
const card = document.querySelector(`[data-download-id="${downloadId}"]`);
|
||||||
|
if (!card) {
|
||||||
|
// Card not found - store update and reload queue
|
||||||
|
console.warn(`Download card not found for ID: ${downloadId}`);
|
||||||
|
|
||||||
|
// Debug: Log all existing download cards
|
||||||
|
const allCards = document.querySelectorAll('[data-download-id]');
|
||||||
|
console.log(`Found ${allCards.length} download cards:`);
|
||||||
|
allCards.forEach(c => console.log(` - ${c.getAttribute('data-download-id')}`));
|
||||||
|
|
||||||
|
// Store this progress update to retry after queue loads
|
||||||
|
console.log(`Storing progress update for ${downloadId} to retry after reload`);
|
||||||
|
this.pendingProgressUpdates.set(downloadId, data);
|
||||||
|
|
||||||
|
// Reload queue to sync state
|
||||||
|
console.log('Reloading queue to sync state...');
|
||||||
|
this.loadQueueData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found download card for ID: ${downloadId}, updating progress`);
|
||||||
|
|
||||||
|
// Extract progress information - handle both ProgressService and yt-dlp formats
|
||||||
|
const progress = data.progress || data;
|
||||||
|
const percent = progress.percent || 0;
|
||||||
|
|
||||||
|
// Check if we have detailed yt-dlp progress (downloaded_mb, total_mb, speed_mbps)
|
||||||
|
// or basic ProgressService progress (current, total)
|
||||||
|
let downloaded, total, speed;
|
||||||
|
|
||||||
|
if (progress.downloaded_mb !== undefined && progress.total_mb !== undefined) {
|
||||||
|
// yt-dlp detailed format
|
||||||
|
downloaded = progress.downloaded_mb.toFixed(1);
|
||||||
|
total = progress.total_mb.toFixed(1);
|
||||||
|
speed = progress.speed_mbps ? progress.speed_mbps.toFixed(1) : '0.0';
|
||||||
|
} else if (progress.current !== undefined && progress.total !== undefined) {
|
||||||
|
// ProgressService basic format - convert bytes to MB
|
||||||
|
downloaded = (progress.current / (1024 * 1024)).toFixed(1);
|
||||||
|
total = progress.total > 0 ? (progress.total / (1024 * 1024)).toFixed(1) : 'Unknown';
|
||||||
|
speed = '0.0'; // Speed not available in basic format
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
downloaded = '0.0';
|
||||||
|
total = 'Unknown';
|
||||||
|
speed = '0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const progressFill = card.querySelector('.progress-fill');
|
||||||
|
if (progressFill) {
|
||||||
|
progressFill.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress text
|
||||||
|
const progressInfo = card.querySelector('.progress-info');
|
||||||
|
if (progressInfo) {
|
||||||
|
const percentSpan = progressInfo.querySelector('span:first-child');
|
||||||
|
const speedSpan = progressInfo.querySelector('.download-speed');
|
||||||
|
|
||||||
|
if (percentSpan) {
|
||||||
|
percentSpan.textContent = `${percent.toFixed(1)}% (${downloaded} MB / ${total} MB)`;
|
||||||
|
}
|
||||||
|
if (speedSpan) {
|
||||||
|
speedSpan.textContent = `${speed} MB/s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Updated progress for ${downloadId}: ${percent.toFixed(1)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
processPendingProgressUpdates() {
|
||||||
|
if (this.pendingProgressUpdates.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Processing ${this.pendingProgressUpdates.size} pending progress updates...`);
|
||||||
|
|
||||||
|
// Process each pending update
|
||||||
|
const processed = [];
|
||||||
|
for (const [downloadId, data] of this.pendingProgressUpdates.entries()) {
|
||||||
|
// Check if card now exists
|
||||||
|
const card = document.querySelector(`[data-download-id="${downloadId}"]`);
|
||||||
|
if (card) {
|
||||||
|
console.log(`Retrying progress update for ${downloadId}`);
|
||||||
|
this.updateDownloadProgress(data);
|
||||||
|
processed.push(downloadId);
|
||||||
|
} else {
|
||||||
|
console.log(`Card still not found for ${downloadId}, will retry on next reload`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove processed updates
|
||||||
|
processed.forEach(id => this.pendingProgressUpdates.delete(id));
|
||||||
|
|
||||||
|
if (processed.length > 0) {
|
||||||
|
console.log(`Successfully processed ${processed.length} pending updates`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderActiveDownloads(downloads) {
|
renderActiveDownloads(downloads) {
|
||||||
const container = document.getElementById('active-downloads');
|
const container = document.getElementById('active-downloads');
|
||||||
|
|
||||||
@ -295,20 +474,12 @@ class QueueManager {
|
|||||||
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
|
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-card active">
|
<div class="download-card active" data-download-id="${download.id}">
|
||||||
<div class="download-header">
|
<div class="download-header">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||||
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
<p>${this.escapeHtml(download.episode.season)}x${String(download.episode.episode).padStart(2, '0')} - ${this.escapeHtml(download.episode.title || 'Episode ' + download.episode.episode)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-actions">
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="queueManager.pauseDownload('${download.id}')">
|
|
||||||
<i class="fas fa-pause"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-small btn-error" onclick="queueManager.cancelDownload('${download.id}')">
|
|
||||||
<i class="fas fa-stop"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="download-progress">
|
<div class="download-progress">
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
@ -331,6 +502,7 @@ class QueueManager {
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
<p>No items in queue</p>
|
<p>No items in queue</p>
|
||||||
|
<small>Add episodes from the main page to start downloading</small>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@ -341,10 +513,11 @@ class QueueManager {
|
|||||||
|
|
||||||
createPendingQueueCard(download, index) {
|
createPendingQueueCard(download, index) {
|
||||||
const addedAt = new Date(download.added_at).toLocaleString();
|
const addedAt = new Date(download.added_at).toLocaleString();
|
||||||
const priorityClass = download.priority === 'high' ? 'high-priority' : '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-card pending ${priorityClass}" data-id="${download.id}">
|
<div class="download-card pending"
|
||||||
|
data-id="${download.id}"
|
||||||
|
data-index="${index}">
|
||||||
<div class="queue-position">${index + 1}</div>
|
<div class="queue-position">${index + 1}</div>
|
||||||
<div class="download-header">
|
<div class="download-header">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
@ -353,7 +526,6 @@ class QueueManager {
|
|||||||
<small>Added: ${addedAt}</small>
|
<small>Added: ${addedAt}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-actions">
|
<div class="download-actions">
|
||||||
${download.priority === 'high' ? '<i class="fas fa-arrow-up priority-indicator" title="High Priority"></i>' : ''}
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="queueManager.removeFromQueue('${download.id}')">
|
<button class="btn btn-small btn-secondary" onclick="queueManager.removeFromQueue('${download.id}')">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -420,7 +592,7 @@ class QueueManager {
|
|||||||
const retryCount = download.retry_count || 0;
|
const retryCount = download.retry_count || 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-card failed">
|
<div class="download-card failed" data-id="${download.id}">
|
||||||
<div class="download-header">
|
<div class="download-header">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
<h4>${this.escapeHtml(download.serie_name)}</h4>
|
||||||
@ -441,10 +613,23 @@ class QueueManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeFailedDownload(downloadId) {
|
||||||
|
await this.removeFromQueue(downloadId);
|
||||||
|
}
|
||||||
|
|
||||||
updateButtonStates(data) {
|
updateButtonStates(data) {
|
||||||
const hasActive = (data.active_downloads || []).length > 0;
|
const hasActive = (data.active_downloads || []).length > 0;
|
||||||
const hasPending = (data.pending_queue || []).length > 0;
|
const hasPending = (data.pending_queue || []).length > 0;
|
||||||
const hasFailed = (data.failed_downloads || []).length > 0;
|
const hasFailed = (data.failed_downloads || []).length > 0;
|
||||||
|
const hasCompleted = (data.completed_downloads || []).length > 0;
|
||||||
|
|
||||||
|
console.log('Button states update:', {
|
||||||
|
hasPending,
|
||||||
|
pendingCount: (data.pending_queue || []).length,
|
||||||
|
hasActive,
|
||||||
|
hasFailed,
|
||||||
|
hasCompleted
|
||||||
|
});
|
||||||
|
|
||||||
// Enable start button only if there are pending items and no active downloads
|
// Enable start button only if there are pending items and no active downloads
|
||||||
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
|
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
|
||||||
@ -459,23 +644,31 @@ class QueueManager {
|
|||||||
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('pause-all-btn').disabled = !hasActive;
|
|
||||||
document.getElementById('clear-queue-btn').disabled = !hasPending;
|
|
||||||
document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2;
|
|
||||||
document.getElementById('retry-all-btn').disabled = !hasFailed;
|
document.getElementById('retry-all-btn').disabled = !hasFailed;
|
||||||
|
document.getElementById('clear-completed-btn').disabled = !hasCompleted;
|
||||||
|
document.getElementById('clear-failed-btn').disabled = !hasFailed;
|
||||||
|
|
||||||
|
// Update clear pending button if it exists
|
||||||
|
const clearPendingBtn = document.getElementById('clear-pending-btn');
|
||||||
|
if (clearPendingBtn) {
|
||||||
|
clearPendingBtn.disabled = !hasPending;
|
||||||
|
console.log('Clear pending button updated:', { disabled: !hasPending, hasPending });
|
||||||
|
} else {
|
||||||
|
console.error('Clear pending button not found in DOM');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearQueue(type) {
|
async clearQueue(type) {
|
||||||
const titles = {
|
const titles = {
|
||||||
pending: 'Clear Queue',
|
|
||||||
completed: 'Clear Completed Downloads',
|
completed: 'Clear Completed Downloads',
|
||||||
failed: 'Clear Failed Downloads'
|
failed: 'Clear Failed Downloads',
|
||||||
|
pending: 'Remove All Pending Downloads'
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
pending: 'Are you sure you want to clear all pending downloads from the queue?',
|
|
||||||
completed: 'Are you sure you want to clear all completed downloads?',
|
completed: 'Are you sure you want to clear all completed downloads?',
|
||||||
failed: 'Are you sure you want to clear all failed downloads?'
|
failed: 'Are you sure you want to clear all failed downloads?',
|
||||||
|
pending: 'Are you sure you want to remove all pending downloads from the queue?'
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmed = await this.showConfirmModal(titles[type], messages[type]);
|
const confirmed = await this.showConfirmModal(titles[type], messages[type]);
|
||||||
@ -483,7 +676,6 @@ class QueueManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === 'completed') {
|
if (type === 'completed') {
|
||||||
// Use the new DELETE /api/queue/completed endpoint
|
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/completed', {
|
const response = await this.makeAuthenticatedRequest('/api/queue/completed', {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
@ -491,11 +683,28 @@ class QueueManager {
|
|||||||
if (!response) return;
|
if (!response) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
this.showToast(`Cleared ${data.cleared_count} completed downloads`, 'success');
|
this.showToast(`Cleared ${data.count} completed downloads`, 'success');
|
||||||
|
this.loadQueueData();
|
||||||
|
} else if (type === 'failed') {
|
||||||
|
const response = await this.makeAuthenticatedRequest('/api/queue/failed', {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.showToast(`Cleared ${data.count} failed downloads`, 'success');
|
||||||
|
this.loadQueueData();
|
||||||
|
} else if (type === 'pending') {
|
||||||
|
const response = await this.makeAuthenticatedRequest('/api/queue/pending', {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.showToast(`Removed ${data.count} pending downloads`, 'success');
|
||||||
this.loadQueueData();
|
this.loadQueueData();
|
||||||
} else {
|
|
||||||
// For pending and failed, use the old logic (TODO: implement backend endpoints)
|
|
||||||
this.showToast(`Clear ${type} not yet implemented`, 'warning');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -528,14 +737,31 @@ class QueueManager {
|
|||||||
const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?');
|
const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
// Get all failed downloads and retry them individually
|
try {
|
||||||
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
|
// Get all failed download IDs
|
||||||
|
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
|
||||||
|
const itemIds = Array.from(failedCards).map(card => card.dataset.id).filter(id => id);
|
||||||
|
|
||||||
for (const card of failedCards) {
|
if (itemIds.length === 0) {
|
||||||
const downloadId = card.dataset.id;
|
this.showToast('No failed downloads to retry', 'info');
|
||||||
if (downloadId) {
|
return;
|
||||||
await this.retryDownload(downloadId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await this.makeAuthenticatedRequest('/api/queue/retry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ item_ids: itemIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.showToast(`Retried ${data.retried_count || itemIds.length} download(s)`, 'success');
|
||||||
|
this.loadQueueData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrying failed downloads:', error);
|
||||||
|
this.showToast('Failed to retry downloads', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -571,7 +797,7 @@ class QueueManager {
|
|||||||
return `${minutes}m ${seconds}s`;
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startDownloadQueue() {
|
async startDownload() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/start', {
|
const response = await this.makeAuthenticatedRequest('/api/queue/start', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
@ -581,22 +807,24 @@ class QueueManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
this.showToast('Download queue started', 'success');
|
this.showToast('Queue processing started - all items will download automatically', 'success');
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
document.getElementById('start-queue-btn').style.display = 'none';
|
document.getElementById('start-queue-btn').style.display = 'none';
|
||||||
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
|
document.getElementById('stop-queue-btn').style.display = 'inline-flex';
|
||||||
document.getElementById('stop-queue-btn').disabled = false;
|
document.getElementById('stop-queue-btn').disabled = false;
|
||||||
|
|
||||||
|
this.loadQueueData(); // Refresh display
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Failed to start queue: ${data.message}`, 'error');
|
this.showToast(`Failed to start queue: ${data.message || 'Unknown error'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting download queue:', error);
|
console.error('Error starting queue:', error);
|
||||||
this.showToast('Failed to start download queue', 'error');
|
this.showToast('Failed to start queue processing', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopDownloadQueue() {
|
async stopDownloads() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/queue/stop', {
|
const response = await this.makeAuthenticatedRequest('/api/queue/stop', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
@ -606,36 +834,23 @@ class QueueManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
this.showToast('Download queue stopped', 'success');
|
this.showToast('Queue processing stopped', 'success');
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
document.getElementById('stop-queue-btn').style.display = 'none';
|
document.getElementById('stop-queue-btn').style.display = 'none';
|
||||||
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
document.getElementById('start-queue-btn').style.display = 'inline-flex';
|
||||||
document.getElementById('start-queue-btn').disabled = false;
|
document.getElementById('start-queue-btn').disabled = false;
|
||||||
|
|
||||||
|
this.loadQueueData(); // Refresh display
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Failed to stop queue: ${data.message}`, 'error');
|
this.showToast(`Failed to stop queue: ${data.message}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping download queue:', error);
|
console.error('Error stopping queue:', error);
|
||||||
this.showToast('Failed to stop download queue', 'error');
|
this.showToast('Failed to stop queue', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseAllDownloads() {
|
|
||||||
// TODO: Implement pause functionality
|
|
||||||
this.showToast('Pause functionality not yet implemented', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
resumeAllDownloads() {
|
|
||||||
// TODO: Implement resume functionality
|
|
||||||
this.showToast('Resume functionality not yet implemented', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleReorderMode() {
|
|
||||||
// TODO: Implement reorder functionality
|
|
||||||
this.showToast('Reorder functionality not yet implemented', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeAuthenticatedRequest(url, options = {}) {
|
async makeAuthenticatedRequest(url, options = {}) {
|
||||||
// Get JWT token from localStorage
|
// Get JWT token from localStorage
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Touch Gestures Module
|
|
||||||
* Handles touch gestures for mobile devices
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize touch gestures
|
|
||||||
*/
|
|
||||||
function initTouchGestures() {
|
|
||||||
if ('ontouchstart' in window) {
|
|
||||||
setupSwipeGestures();
|
|
||||||
console.log('[Touch Gestures] Initialized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup swipe gesture handlers
|
|
||||||
*/
|
|
||||||
function setupSwipeGestures() {
|
|
||||||
let touchStartX = 0;
|
|
||||||
let touchStartY = 0;
|
|
||||||
let touchEndX = 0;
|
|
||||||
let touchEndY = 0;
|
|
||||||
|
|
||||||
document.addEventListener('touchstart', (e) => {
|
|
||||||
touchStartX = e.changedTouches[0].screenX;
|
|
||||||
touchStartY = e.changedTouches[0].screenY;
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
document.addEventListener('touchend', (e) => {
|
|
||||||
touchEndX = e.changedTouches[0].screenX;
|
|
||||||
touchEndY = e.changedTouches[0].screenY;
|
|
||||||
handleSwipe();
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
function handleSwipe() {
|
|
||||||
const deltaX = touchEndX - touchStartX;
|
|
||||||
const deltaY = touchEndY - touchStartY;
|
|
||||||
const minSwipeDistance = 50;
|
|
||||||
|
|
||||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
||||||
// Horizontal swipe
|
|
||||||
if (Math.abs(deltaX) > minSwipeDistance) {
|
|
||||||
if (deltaX > 0) {
|
|
||||||
// Swipe right
|
|
||||||
console.log('[Touch Gestures] Swipe right detected');
|
|
||||||
} else {
|
|
||||||
// Swipe left
|
|
||||||
console.log('[Touch Gestures] Swipe left detected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initTouchGestures);
|
|
||||||
} else {
|
|
||||||
initTouchGestures();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Undo/Redo Module
|
|
||||||
* Provides undo/redo functionality for user actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const actionHistory = [];
|
|
||||||
let currentIndex = -1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize undo/redo system
|
|
||||||
*/
|
|
||||||
function initUndoRedo() {
|
|
||||||
setupKeyboardShortcuts();
|
|
||||||
console.log('[Undo/Redo] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup keyboard shortcuts for undo/redo
|
|
||||||
*/
|
|
||||||
function setupKeyboardShortcuts() {
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
if (event.key === 'z' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
undo();
|
|
||||||
} else if (event.key === 'z' && event.shiftKey || event.key === 'y') {
|
|
||||||
event.preventDefault();
|
|
||||||
redo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add action to history
|
|
||||||
*/
|
|
||||||
function addAction(action) {
|
|
||||||
// Remove any actions after current index
|
|
||||||
actionHistory.splice(currentIndex + 1);
|
|
||||||
|
|
||||||
// Add new action
|
|
||||||
actionHistory.push(action);
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
// Limit history size
|
|
||||||
if (actionHistory.length > 50) {
|
|
||||||
actionHistory.shift();
|
|
||||||
currentIndex--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo last action
|
|
||||||
*/
|
|
||||||
function undo() {
|
|
||||||
if (currentIndex >= 0) {
|
|
||||||
const action = actionHistory[currentIndex];
|
|
||||||
if (action && action.undo) {
|
|
||||||
action.undo();
|
|
||||||
currentIndex--;
|
|
||||||
showNotification('Action undone');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redo last undone action
|
|
||||||
*/
|
|
||||||
function redo() {
|
|
||||||
if (currentIndex < actionHistory.length - 1) {
|
|
||||||
currentIndex++;
|
|
||||||
const action = actionHistory[currentIndex];
|
|
||||||
if (action && action.redo) {
|
|
||||||
action.redo();
|
|
||||||
showNotification('Action redone');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show undo/redo notification
|
|
||||||
*/
|
|
||||||
function showNotification(message) {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'undo-notification';
|
|
||||||
notification.textContent = message;
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.UndoRedo = {
|
|
||||||
add: addAction,
|
|
||||||
undo: undo,
|
|
||||||
redo: redo
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initUndoRedo);
|
|
||||||
} else {
|
|
||||||
initUndoRedo();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -102,6 +102,8 @@ class WebSocketClient {
|
|||||||
const message = JSON.parse(data);
|
const message = JSON.parse(data);
|
||||||
const { type, data: payload, timestamp } = message;
|
const { type, data: payload, timestamp } = message;
|
||||||
|
|
||||||
|
console.log(`WebSocket message: type=${type}`, payload);
|
||||||
|
|
||||||
// Emit event with payload
|
// Emit event with payload
|
||||||
if (type) {
|
if (type) {
|
||||||
this.emit(type, payload || {});
|
this.emit(type, payload || {});
|
||||||
|
|||||||
@ -171,21 +171,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="progress-text" class="progress-text">0%</div>
|
<div id="progress-text" class="progress-text">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="download-controls" class="download-controls hidden">
|
<!-- Download controls removed - use dedicated queue page -->
|
||||||
<button id="pause-download" class="btn btn-secondary btn-small">
|
|
||||||
<i class="fas fa-pause"></i>
|
|
||||||
<span data-text="pause">Pause</span>
|
|
||||||
</button>
|
|
||||||
<button id="resume-download" class="btn btn-primary btn-small hidden">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
<span data-text="resume">Resume</span>
|
|
||||||
</button>
|
|
||||||
<button id="cancel-download" class="btn btn-small"
|
|
||||||
style="background-color: var(--color-error); color: white;">
|
|
||||||
<i class="fas fa-stop"></i>
|
|
||||||
<span data-text="cancel">Cancel</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -106,16 +106,6 @@
|
|||||||
<i class="fas fa-play-circle"></i>
|
<i class="fas fa-play-circle"></i>
|
||||||
Active Downloads
|
Active Downloads
|
||||||
</h2>
|
</h2>
|
||||||
<div class="section-actions">
|
|
||||||
<button id="pause-all-btn" class="btn btn-secondary" disabled>
|
|
||||||
<i class="fas fa-pause"></i>
|
|
||||||
Pause All
|
|
||||||
</button>
|
|
||||||
<button id="resume-all-btn" class="btn btn-primary" disabled style="display: none;">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
Resume All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="active-downloads-list" id="active-downloads">
|
<div class="active-downloads-list" id="active-downloads">
|
||||||
@ -131,24 +121,20 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
Download Queue
|
Download Queue (<span id="queue-count">0</span>)
|
||||||
</h2>
|
</h2>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
|
<button id="clear-pending-btn" class="btn btn-secondary" disabled>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
Remove All
|
||||||
|
</button>
|
||||||
<button id="start-queue-btn" class="btn btn-primary" disabled>
|
<button id="start-queue-btn" class="btn btn-primary" disabled>
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
Start Downloads
|
Start
|
||||||
</button>
|
</button>
|
||||||
<button id="stop-queue-btn" class="btn btn-secondary" disabled style="display: none;">
|
<button id="stop-queue-btn" class="btn btn-secondary" disabled style="display: none;">
|
||||||
<i class="fas fa-stop"></i>
|
<i class="fas fa-stop"></i>
|
||||||
Stop Downloads
|
Stop
|
||||||
</button>
|
|
||||||
<button id="clear-queue-btn" class="btn btn-secondary" disabled>
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
Clear Queue
|
|
||||||
</button>
|
|
||||||
<button id="reorder-queue-btn" class="btn btn-secondary" disabled>
|
|
||||||
<i class="fas fa-sort"></i>
|
|
||||||
Reorder
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -157,6 +143,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
<p>No items in queue</p>
|
<p>No items in queue</p>
|
||||||
|
<small>Add episodes from the main page to start downloading</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -166,10 +153,10 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="fas fa-check-circle"></i>
|
<i class="fas fa-check-circle"></i>
|
||||||
Recent Completed
|
Completed (<span id="completed-count">0</span>)
|
||||||
</h2>
|
</h2>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
<button id="clear-completed-btn" class="btn btn-secondary">
|
<button id="clear-completed-btn" class="btn btn-secondary" disabled>
|
||||||
<i class="fas fa-broom"></i>
|
<i class="fas fa-broom"></i>
|
||||||
Clear Completed
|
Clear Completed
|
||||||
</button>
|
</button>
|
||||||
@ -178,8 +165,9 @@
|
|||||||
|
|
||||||
<div class="completed-downloads-list" id="completed-downloads">
|
<div class="completed-downloads-list" id="completed-downloads">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-check-circle"></i>
|
<i class="fas fa-check-circle text-success"></i>
|
||||||
<p>No completed downloads</p>
|
<p>No completed downloads</p>
|
||||||
|
<small>Completed episodes will appear here</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -189,14 +177,14 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
Failed Downloads
|
Failed (<span id="failed-count">0</span>)
|
||||||
</h2>
|
</h2>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
<button id="retry-all-btn" class="btn btn-warning" disabled>
|
<button id="retry-all-btn" class="btn btn-warning" disabled>
|
||||||
<i class="fas fa-redo"></i>
|
<i class="fas fa-redo"></i>
|
||||||
Retry All
|
Retry All
|
||||||
</button>
|
</button>
|
||||||
<button id="clear-failed-btn" class="btn btn-secondary">
|
<button id="clear-failed-btn" class="btn btn-secondary" disabled>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
Clear Failed
|
Clear Failed
|
||||||
</button>
|
</button>
|
||||||
@ -207,6 +195,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-check-circle text-success"></i>
|
<i class="fas fa-check-circle text-success"></i>
|
||||||
<p>No failed downloads</p>
|
<p>No failed downloads</p>
|
||||||
|
<small>Failed episodes can be retried or removed</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
23
stop_server.sh
Normal file
23
stop_server.sh
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stop Aniworld FastAPI Server
|
||||||
|
|
||||||
|
echo "Stopping Aniworld server..."
|
||||||
|
|
||||||
|
# Method 1: Kill uvicorn processes
|
||||||
|
pkill -f "uvicorn.*fastapi_app:app" && echo "✓ Stopped uvicorn processes"
|
||||||
|
|
||||||
|
# Method 2: Kill any process using port 8000
|
||||||
|
PORT_PID=$(lsof -ti:8000)
|
||||||
|
if [ -n "$PORT_PID" ]; then
|
||||||
|
kill -9 $PORT_PID
|
||||||
|
echo "✓ Killed process on port 8000 (PID: $PORT_PID)"
|
||||||
|
else
|
||||||
|
echo "✓ Port 8000 is already free"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 3: Kill any python processes running the server
|
||||||
|
pkill -f "run_server.py" && echo "✓ Stopped run_server.py processes"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Server stopped successfully!"
|
||||||
|
echo "You can restart it with: ./start_server.sh"
|
||||||
@ -1,154 +0,0 @@
|
|||||||
"""Integration tests for analytics API endpoints.
|
|
||||||
|
|
||||||
Tests analytics API endpoints including download statistics,
|
|
||||||
series popularity, storage analysis, and performance reports.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_downloads_endpoint():
|
|
||||||
"""Test GET /api/analytics/downloads endpoint."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
response = await client.get("/api/analytics/downloads?days=30")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_series_popularity_endpoint():
|
|
||||||
"""Test GET /api/analytics/series-popularity endpoint."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
"/api/analytics/series-popularity?limit=10"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_storage_endpoint():
|
|
||||||
"""Test GET /api/analytics/storage endpoint."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("psutil.disk_usage") as mock_disk:
|
|
||||||
mock_disk.return_value = {
|
|
||||||
"total": 1024 * 1024 * 1024,
|
|
||||||
"used": 512 * 1024 * 1024,
|
|
||||||
"free": 512 * 1024 * 1024,
|
|
||||||
"percent": 50.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await client.get("/api/analytics/storage")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 401, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_performance_endpoint():
|
|
||||||
"""Test GET /api/analytics/performance endpoint."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
"/api/analytics/performance?hours=24"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_summary_endpoint():
|
|
||||||
"""Test GET /api/analytics/summary endpoint."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
response = await client.get("/api/analytics/summary")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_downloads_with_query_params():
|
|
||||||
"""Test /api/analytics/downloads with different query params."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
response = await client.get("/api/analytics/downloads?days=7")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_series_with_different_limits():
|
|
||||||
"""Test /api/analytics/series-popularity with different limits."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
for limit in [5, 10, 20]:
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/analytics/series-popularity?limit={limit}"
|
|
||||||
)
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_performance_with_different_hours():
|
|
||||||
"""Test /api/analytics/performance with different hour ranges."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
with patch("src.server.api.analytics.get_db_session") as mock_get_db:
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_get_db.return_value = mock_db
|
|
||||||
|
|
||||||
for hours in [1, 12, 24, 72]:
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/analytics/performance?hours={hours}"
|
|
||||||
)
|
|
||||||
assert response.status_code in [200, 422, 500]
|
|
||||||
|
|
||||||
|
|
||||||
@ -43,6 +43,16 @@ class FakeSeriesApp:
|
|||||||
"""Trigger rescan with callback."""
|
"""Trigger rescan with callback."""
|
||||||
callback()
|
callback()
|
||||||
|
|
||||||
|
def add(self, serie):
|
||||||
|
"""Add a serie to the list."""
|
||||||
|
# Check if already exists
|
||||||
|
if not any(s.key == serie.key for s in self._items):
|
||||||
|
self._items.append(serie)
|
||||||
|
|
||||||
|
def refresh_series_list(self):
|
||||||
|
"""Refresh series list."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_auth_state():
|
def reset_auth_state():
|
||||||
@ -144,3 +154,61 @@ async def test_get_anime_detail_endpoint_unauthorized():
|
|||||||
response = await client.get("/api/v1/anime/1")
|
response = await client.get("/api/v1/anime/1")
|
||||||
# Should work or require auth
|
# Should work or require auth
|
||||||
assert response.status_code in (200, 401, 404, 503)
|
assert response.status_code in (200, 401, 404, 503)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_series_endpoint_unauthorized():
|
||||||
|
"""Test POST /api/anime/add without authentication."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/anime/add",
|
||||||
|
json={"link": "test-link", "name": "Test Anime"}
|
||||||
|
)
|
||||||
|
# Should require auth
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_series_endpoint_authenticated(authenticated_client):
|
||||||
|
"""Test POST /api/anime/add with authentication."""
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/anime/add",
|
||||||
|
json={"link": "test-anime-link", "name": "Test New Anime"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# The endpoint should succeed (returns 200 or may fail if series exists)
|
||||||
|
assert response.status_code in (200, 400)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert "Test New Anime" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_series_endpoint_empty_name(authenticated_client):
|
||||||
|
"""Test POST /api/anime/add with empty name."""
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/anime/add",
|
||||||
|
json={"link": "test-link", "name": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 400 for empty name
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "name" in data["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_series_endpoint_empty_link(authenticated_client):
|
||||||
|
"""Test POST /api/anime/add with empty link."""
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/anime/add",
|
||||||
|
json={"link": "", "name": "Test Anime"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 400 for empty link
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "link" in data["detail"].lower()
|
||||||
|
|||||||
@ -92,14 +92,9 @@ def mock_download_service():
|
|||||||
# Mock remove_from_queue
|
# Mock remove_from_queue
|
||||||
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
|
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
|
||||||
|
|
||||||
# Mock reorder_queue
|
# Mock start/stop
|
||||||
service.reorder_queue = AsyncMock(return_value=True)
|
service.start_next_download = AsyncMock(return_value="item-id-1")
|
||||||
|
service.stop_downloads = AsyncMock()
|
||||||
# Mock start/stop/pause/resume
|
|
||||||
service.start = AsyncMock()
|
|
||||||
service.stop = AsyncMock()
|
|
||||||
service.pause_queue = AsyncMock()
|
|
||||||
service.resume_queue = AsyncMock()
|
|
||||||
|
|
||||||
# Mock clear_completed and retry_failed
|
# Mock clear_completed and retry_failed
|
||||||
service.clear_completed = AsyncMock(return_value=5)
|
service.clear_completed = AsyncMock(return_value=5)
|
||||||
@ -116,10 +111,16 @@ async def test_get_queue_status(authenticated_client, mock_download_service):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
assert "status" in data
|
# Updated to match new response structure
|
||||||
|
assert "is_running" in data
|
||||||
|
assert "is_paused" in data
|
||||||
|
assert "active_downloads" in data
|
||||||
|
assert "pending_queue" in data
|
||||||
|
assert "completed_downloads" in data
|
||||||
|
assert "failed_downloads" in data
|
||||||
assert "statistics" in data
|
assert "statistics" in data
|
||||||
assert data["status"]["is_running"] is True
|
assert data["is_running"] is True
|
||||||
assert data["status"]["is_paused"] is False
|
assert data["is_paused"] is False
|
||||||
|
|
||||||
mock_download_service.get_queue_status.assert_called_once()
|
mock_download_service.get_queue_status.assert_called_once()
|
||||||
mock_download_service.get_queue_stats.assert_called_once()
|
mock_download_service.get_queue_stats.assert_called_once()
|
||||||
@ -259,54 +260,56 @@ async def test_remove_from_queue_not_found(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remove_multiple_from_queue(
|
async def test_start_download_success(
|
||||||
authenticated_client, mock_download_service
|
authenticated_client, mock_download_service
|
||||||
):
|
):
|
||||||
"""Test DELETE /api/queue/ with multiple items."""
|
"""Test POST /api/queue/start starts first pending download."""
|
||||||
request_data = {"item_ids": ["item-id-1", "item-id-2"]}
|
|
||||||
|
|
||||||
response = await authenticated_client.request(
|
|
||||||
"DELETE", "/api/queue/", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
|
||||||
["item-id-1", "item-id-2"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remove_multiple_empty_list(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test removing with empty item list returns 400."""
|
|
||||||
request_data = {"item_ids": []}
|
|
||||||
|
|
||||||
response = await authenticated_client.request(
|
|
||||||
"DELETE", "/api/queue/", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/start endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/start")
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
assert data["status"] == "success"
|
assert data["status"] == "success"
|
||||||
assert "started" in data["message"].lower()
|
assert "item_id" in data
|
||||||
|
assert data["item_id"] == "item-id-1"
|
||||||
|
|
||||||
mock_download_service.start.assert_called_once()
|
mock_download_service.start_next_download.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_queue(authenticated_client, mock_download_service):
|
async def test_start_download_empty_queue(
|
||||||
"""Test POST /api/queue/stop endpoint."""
|
authenticated_client, mock_download_service
|
||||||
|
):
|
||||||
|
"""Test starting download with empty queue returns 400."""
|
||||||
|
mock_download_service.start_next_download.return_value = None
|
||||||
|
|
||||||
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
detail = data["detail"].lower()
|
||||||
|
assert "empty" in detail or "no pending" in detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_download_already_active(
|
||||||
|
authenticated_client, mock_download_service
|
||||||
|
):
|
||||||
|
"""Test starting download while one is active returns 400."""
|
||||||
|
mock_download_service.start_next_download.side_effect = (
|
||||||
|
DownloadServiceError("A download is already in progress")
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "already" in data["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_downloads(authenticated_client, mock_download_service):
|
||||||
|
"""Test POST /api/queue/stop stops queue processing."""
|
||||||
response = await authenticated_client.post("/api/queue/stop")
|
response = await authenticated_client.post("/api/queue/stop")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -315,70 +318,7 @@ async def test_stop_queue(authenticated_client, mock_download_service):
|
|||||||
assert data["status"] == "success"
|
assert data["status"] == "success"
|
||||||
assert "stopped" in data["message"].lower()
|
assert "stopped" in data["message"].lower()
|
||||||
|
|
||||||
mock_download_service.stop.assert_called_once()
|
mock_download_service.stop_downloads.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pause_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/pause endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/pause")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "paused" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.pause_queue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resume_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/resume endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/resume")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "resumed" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.resume_queue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reorder_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/reorder endpoint."""
|
|
||||||
request_data = {"item_id": "item-id-1", "new_position": 0}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/reorder", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
|
|
||||||
mock_download_service.reorder_queue.assert_called_once_with(
|
|
||||||
item_id="item-id-1", new_position=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reorder_queue_not_found(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test reordering non-existent item returns 404."""
|
|
||||||
mock_download_service.reorder_queue.return_value = False
|
|
||||||
|
|
||||||
request_data = {"item_id": "non-existent", "new_position": 0}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/reorder", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -395,6 +335,22 @@ async def test_clear_completed(authenticated_client, mock_download_service):
|
|||||||
mock_download_service.clear_completed.assert_called_once()
|
mock_download_service.clear_completed.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_pending(authenticated_client, mock_download_service):
|
||||||
|
"""Test DELETE /api/queue/pending endpoint."""
|
||||||
|
mock_download_service.clear_pending = AsyncMock(return_value=3)
|
||||||
|
|
||||||
|
response = await authenticated_client.delete("/api/queue/pending")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["count"] == 3
|
||||||
|
|
||||||
|
mock_download_service.clear_pending.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_retry_failed(authenticated_client, mock_download_service):
|
async def test_retry_failed(authenticated_client, mock_download_service):
|
||||||
"""Test POST /api/queue/retry endpoint."""
|
"""Test POST /api/queue/retry endpoint."""
|
||||||
@ -444,8 +400,6 @@ async def test_queue_endpoints_require_auth(mock_download_service):
|
|||||||
("DELETE", "/api/queue/item-1"),
|
("DELETE", "/api/queue/item-1"),
|
||||||
("POST", "/api/queue/start"),
|
("POST", "/api/queue/start"),
|
||||||
("POST", "/api/queue/stop"),
|
("POST", "/api/queue/stop"),
|
||||||
("POST", "/api/queue/pause"),
|
|
||||||
("POST", "/api/queue/resume"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for method, url in endpoints:
|
for method, url in endpoints:
|
||||||
@ -456,7 +410,8 @@ async def test_queue_endpoints_require_auth(mock_download_service):
|
|||||||
elif method == "DELETE":
|
elif method == "DELETE":
|
||||||
response = await client.delete(url)
|
response = await client.delete(url)
|
||||||
|
|
||||||
# Should return 401 or 503 (503 if service not available)
|
# Should return 401 or 503 (503 if service unavailable)
|
||||||
assert response.status_code in (401, 503), (
|
assert response.status_code in (401, 503), (
|
||||||
f"{method} {url} should require auth, got {response.status_code}"
|
f"{method} {url} should require auth, "
|
||||||
|
f"got {response.status_code}"
|
||||||
)
|
)
|
||||||
|
|||||||
466
tests/api/test_queue_features.py
Normal file
466
tests/api/test_queue_features.py
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
"""Tests for queue management features.
|
||||||
|
|
||||||
|
This module tests the queue page functionality including:
|
||||||
|
- Display of queued items in organized lists
|
||||||
|
- Drag-and-drop reordering
|
||||||
|
- Starting and stopping queue processing
|
||||||
|
- Filtering completed and failed downloads
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
"""Create an async test client."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_headers(client: AsyncClient):
|
||||||
|
"""Get authentication headers with valid JWT token."""
|
||||||
|
# Setup auth
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPass123!"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPass123!"}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_download_request():
|
||||||
|
"""Sample download request for testing."""
|
||||||
|
return {
|
||||||
|
"serie_id": "test-series",
|
||||||
|
"serie_name": "Test Series",
|
||||||
|
"episodes": [
|
||||||
|
{"season": 1, "episode": 1},
|
||||||
|
{"season": 1, "episode": 2}
|
||||||
|
],
|
||||||
|
"priority": "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueDisplay:
|
||||||
|
"""Test queue display and organization."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queue_status_includes_all_sections(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test queue status includes all sections."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify structure
|
||||||
|
assert "status" in data
|
||||||
|
assert "statistics" in data
|
||||||
|
|
||||||
|
status = data["status"]
|
||||||
|
assert "active" in status
|
||||||
|
assert "pending" in status
|
||||||
|
assert "completed" in status
|
||||||
|
assert "failed" in status
|
||||||
|
assert "is_running" in status
|
||||||
|
assert "is_paused" in status
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queue_items_have_required_fields(
|
||||||
|
self, client: AsyncClient, auth_headers: dict,
|
||||||
|
sample_download_request: dict
|
||||||
|
):
|
||||||
|
"""Test queue items have required display fields."""
|
||||||
|
# Add an item to the queue
|
||||||
|
add_response = await client.post(
|
||||||
|
"/api/queue/add",
|
||||||
|
json=sample_download_request,
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert add_response.status_code == 201
|
||||||
|
|
||||||
|
# Get queue status
|
||||||
|
response = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
pending = data["status"]["pending"]
|
||||||
|
|
||||||
|
assert len(pending) > 0
|
||||||
|
item = pending[0]
|
||||||
|
|
||||||
|
# Verify required fields for display
|
||||||
|
assert "id" in item
|
||||||
|
assert "serie_name" in item
|
||||||
|
assert "episode" in item
|
||||||
|
assert "priority" in item
|
||||||
|
assert "added_at" in item
|
||||||
|
|
||||||
|
# Verify episode structure
|
||||||
|
episode = item["episode"]
|
||||||
|
assert "season" in episode
|
||||||
|
assert "episode" in episode
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueReordering:
|
||||||
|
"""Test queue reordering functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reorder_queue_with_item_ids(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test reordering queue using item_ids array."""
|
||||||
|
# Clear existing queue first
|
||||||
|
status_response = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
existing_items = [
|
||||||
|
item["id"]
|
||||||
|
for item in status_response.json()["status"]["pending"]
|
||||||
|
]
|
||||||
|
if existing_items:
|
||||||
|
await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/queue/",
|
||||||
|
json={"item_ids": existing_items},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add exactly 3 items
|
||||||
|
added_ids = []
|
||||||
|
for i in range(3):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/add",
|
||||||
|
json={
|
||||||
|
"serie_id": f"test-{i}",
|
||||||
|
"serie_name": f"Test Series {i}",
|
||||||
|
"episodes": [{"season": 1, "episode": i+1}],
|
||||||
|
"priority": "normal"
|
||||||
|
},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
data = response.json()
|
||||||
|
if "added_items" in data and data["added_items"]:
|
||||||
|
added_ids.extend(data["added_items"])
|
||||||
|
|
||||||
|
assert len(added_ids) == 3, f"Expected 3 items, got {len(added_ids)}"
|
||||||
|
|
||||||
|
# Reverse the order
|
||||||
|
new_order = list(reversed(added_ids))
|
||||||
|
|
||||||
|
# Reorder
|
||||||
|
reorder_response = await client.post(
|
||||||
|
"/api/queue/reorder",
|
||||||
|
json={"item_ids": new_order},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reorder_response.status_code == 200
|
||||||
|
assert reorder_response.json()["status"] == "success"
|
||||||
|
|
||||||
|
# Verify new order
|
||||||
|
status_response = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
current_order = [
|
||||||
|
item["id"]
|
||||||
|
for item in status_response.json()["status"]["pending"]
|
||||||
|
]
|
||||||
|
|
||||||
|
assert current_order == new_order
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reorder_with_invalid_ids(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test reordering with non-existent IDs succeeds (idempotent)."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/reorder",
|
||||||
|
json={"item_ids": ["invalid-id-1", "invalid-id-2"]},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk reorder is idempotent and succeeds even with invalid IDs
|
||||||
|
# It just ignores items that don't exist
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reorder_empty_list(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test reordering with empty list."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/reorder",
|
||||||
|
json={"item_ids": []},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should succeed but do nothing
|
||||||
|
assert response.status_code in [200, 404]
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueControl:
|
||||||
|
"""Test queue start/stop functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_queue(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test starting the download queue."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/start",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_queue(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test stopping the download queue."""
|
||||||
|
# Start first
|
||||||
|
await client.post("/api/queue/start", headers=auth_headers)
|
||||||
|
|
||||||
|
# Then stop
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/stop",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queue_status_reflects_running_state(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test queue status reflects running state."""
|
||||||
|
# Initially not running
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert status.json()["status"]["is_running"] is False
|
||||||
|
|
||||||
|
# Start queue
|
||||||
|
await client.post("/api/queue/start", headers=auth_headers)
|
||||||
|
|
||||||
|
# Should be running
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert status.json()["status"]["is_running"] is True
|
||||||
|
|
||||||
|
# Stop queue
|
||||||
|
await client.post("/api/queue/stop", headers=auth_headers)
|
||||||
|
|
||||||
|
# Should not be running
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert status.json()["status"]["is_running"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompletedDownloads:
|
||||||
|
"""Test completed downloads management."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_completed_downloads(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test clearing completed downloads."""
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/queue/completed",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "count" in data
|
||||||
|
assert data["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_completed_section_count(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test that completed count is accurate."""
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
data = status.json()
|
||||||
|
|
||||||
|
completed_count = data["statistics"]["completed_count"]
|
||||||
|
completed_list = len(data["status"]["completed"])
|
||||||
|
|
||||||
|
# Count should match list length
|
||||||
|
assert completed_count == completed_list
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailedDownloads:
|
||||||
|
"""Test failed downloads management."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_failed_downloads(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test clearing failed downloads."""
|
||||||
|
response = await client.delete(
|
||||||
|
"/api/queue/failed",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "count" in data
|
||||||
|
assert data["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_failed_downloads(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test retrying failed downloads."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/retry",
|
||||||
|
json={"item_ids": []},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "retried_count" in data
|
||||||
|
assert data["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_specific_failed_download(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test retrying a specific failed download."""
|
||||||
|
# Test the endpoint accepts the format
|
||||||
|
response = await client.post(
|
||||||
|
"/api/queue/retry",
|
||||||
|
json={"item_ids": ["some-id"]},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should succeed even if ID doesn't exist (idempotent)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_section_count(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test that failed count is accurate."""
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
data = status.json()
|
||||||
|
|
||||||
|
failed_count = data["statistics"]["failed_count"]
|
||||||
|
failed_list = len(data["status"]["failed"])
|
||||||
|
|
||||||
|
# Count should match list length
|
||||||
|
assert failed_count == failed_list
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkOperations:
|
||||||
|
"""Test bulk queue operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_multiple_items(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test removing multiple items from queue."""
|
||||||
|
# Add multiple items
|
||||||
|
item_ids = []
|
||||||
|
for i in range(3):
|
||||||
|
add_response = await client.post(
|
||||||
|
"/api/queue/add",
|
||||||
|
json={
|
||||||
|
"serie_id": f"bulk-test-{i}",
|
||||||
|
"serie_name": f"Bulk Test {i}",
|
||||||
|
"episodes": [{"season": 1, "episode": i+1}],
|
||||||
|
"priority": "normal"
|
||||||
|
},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
if add_response.status_code == 201:
|
||||||
|
data = add_response.json()
|
||||||
|
if "added_items" in data and len(data["added_items"]) > 0:
|
||||||
|
item_ids.append(data["added_items"][0])
|
||||||
|
|
||||||
|
# Remove all at once
|
||||||
|
if item_ids:
|
||||||
|
response = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/queue/",
|
||||||
|
json={"item_ids": item_ids},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_entire_pending_queue(
|
||||||
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
):
|
||||||
|
"""Test clearing entire pending queue."""
|
||||||
|
# Get all pending items
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
pending = status.json()["status"]["pending"]
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
item_ids = [item["id"] for item in pending]
|
||||||
|
|
||||||
|
# Remove all
|
||||||
|
response = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/queue/",
|
||||||
|
json={"item_ids": item_ids},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify queue is empty
|
||||||
|
status = await client.get(
|
||||||
|
"/api/queue/status",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert len(status.json()["status"]["pending"]) == 0
|
||||||
@ -197,7 +197,7 @@ class TestFrontendAnimeAPI:
|
|||||||
assert isinstance(data, list)
|
assert isinstance(data, list)
|
||||||
# Search should return results (actual API call)
|
# Search should return results (actual API call)
|
||||||
if len(data) > 0:
|
if len(data) > 0:
|
||||||
assert "title" in data[0]
|
assert "name" in data[0]
|
||||||
|
|
||||||
async def test_rescan_anime(self, authenticated_client):
|
async def test_rescan_anime(self, authenticated_client):
|
||||||
"""Test POST /api/anime/rescan triggers rescan."""
|
"""Test POST /api/anime/rescan triggers rescan."""
|
||||||
@ -247,23 +247,17 @@ class TestFrontendDownloadAPI:
|
|||||||
assert "status" in data or "statistics" in data
|
assert "status" in data or "statistics" in data
|
||||||
|
|
||||||
async def test_start_download_queue(self, authenticated_client):
|
async def test_start_download_queue(self, authenticated_client):
|
||||||
"""Test POST /api/queue/start starts queue."""
|
"""Test POST /api/queue/start starts next download."""
|
||||||
response = await authenticated_client.post("/api/queue/start")
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
|
|
||||||
assert response.status_code == 200
|
# Should return 200 with item_id, or 400 if queue is empty
|
||||||
|
assert response.status_code in [200, 400]
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "message" in data or "status" in data
|
if response.status_code == 200:
|
||||||
|
assert "item_id" in data
|
||||||
async def test_pause_download_queue(self, authenticated_client):
|
|
||||||
"""Test POST /api/queue/pause pauses queue."""
|
|
||||||
response = await authenticated_client.post("/api/queue/pause")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "message" in data or "status" in data
|
|
||||||
|
|
||||||
async def test_stop_download_queue(self, authenticated_client):
|
async def test_stop_download_queue(self, authenticated_client):
|
||||||
"""Test POST /api/queue/stop stops queue."""
|
"""Test POST /api/queue/stop stops processing new downloads."""
|
||||||
response = await authenticated_client.post("/api/queue/stop")
|
response = await authenticated_client.post("/api/queue/stop")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@ -323,8 +323,8 @@ class TestProtectedEndpoints:
|
|||||||
endpoints = [
|
endpoints = [
|
||||||
("/api/queue/status", "GET"),
|
("/api/queue/status", "GET"),
|
||||||
("/api/queue/add", "POST"),
|
("/api/queue/add", "POST"),
|
||||||
("/api/queue/control/start", "POST"),
|
("/api/queue/start", "POST"),
|
||||||
("/api/queue/control/pause", "POST"),
|
("/api/queue/pause", "POST"),
|
||||||
]
|
]
|
||||||
|
|
||||||
token = await self.get_valid_token(client)
|
token = await self.get_valid_token(client)
|
||||||
|
|||||||
@ -153,15 +153,14 @@ class TestDownloadFlowEndToEnd:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Verify status structure
|
# Verify status structure (updated for new response format)
|
||||||
assert "status" in data
|
assert "is_running" in data
|
||||||
|
assert "is_paused" in data
|
||||||
|
assert "pending_queue" in data
|
||||||
|
assert "active_downloads" in data
|
||||||
|
assert "completed_downloads" in data
|
||||||
|
assert "failed_downloads" in data
|
||||||
assert "statistics" in data
|
assert "statistics" in data
|
||||||
|
|
||||||
status = data["status"]
|
|
||||||
assert "pending" in status
|
|
||||||
assert "active" in status
|
|
||||||
assert "completed" in status
|
|
||||||
assert "failed" in status
|
|
||||||
|
|
||||||
async def test_add_with_different_priorities(self, authenticated_client):
|
async def test_add_with_different_priorities(self, authenticated_client):
|
||||||
"""Test adding episodes with different priority levels."""
|
"""Test adding episodes with different priority levels."""
|
||||||
@ -216,36 +215,7 @@ class TestQueueControlOperations:
|
|||||||
|
|
||||||
async def test_start_queue_processing(self, authenticated_client):
|
async def test_start_queue_processing(self, authenticated_client):
|
||||||
"""Test starting the queue processor."""
|
"""Test starting the queue processor."""
|
||||||
response = await authenticated_client.post("/api/queue/control/start")
|
response = await authenticated_client.post("/api/queue/start")
|
||||||
|
|
||||||
assert response.status_code in [200, 503]
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
assert data["status"] == "success"
|
|
||||||
|
|
||||||
async def test_pause_queue_processing(self, authenticated_client):
|
|
||||||
"""Test pausing the queue processor."""
|
|
||||||
# Start first
|
|
||||||
await authenticated_client.post("/api/queue/control/start")
|
|
||||||
|
|
||||||
# Then pause
|
|
||||||
response = await authenticated_client.post("/api/queue/control/pause")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 503]
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
assert data["status"] == "success"
|
|
||||||
|
|
||||||
async def test_resume_queue_processing(self, authenticated_client):
|
|
||||||
"""Test resuming the queue processor."""
|
|
||||||
# Start and pause first
|
|
||||||
await authenticated_client.post("/api/queue/control/start")
|
|
||||||
await authenticated_client.post("/api/queue/control/pause")
|
|
||||||
|
|
||||||
# Then resume
|
|
||||||
response = await authenticated_client.post("/api/queue/control/resume")
|
|
||||||
|
|
||||||
assert response.status_code in [200, 503]
|
assert response.status_code in [200, 503]
|
||||||
|
|
||||||
@ -255,7 +225,7 @@ class TestQueueControlOperations:
|
|||||||
|
|
||||||
async def test_clear_completed_downloads(self, authenticated_client):
|
async def test_clear_completed_downloads(self, authenticated_client):
|
||||||
"""Test clearing completed downloads from the queue."""
|
"""Test clearing completed downloads from the queue."""
|
||||||
response = await authenticated_client.post("/api/queue/control/clear_completed")
|
response = await authenticated_client.delete("/api/queue/completed")
|
||||||
|
|
||||||
assert response.status_code in [200, 503]
|
assert response.status_code in [200, 503]
|
||||||
|
|
||||||
@ -294,36 +264,9 @@ class TestQueueItemOperations:
|
|||||||
# For now, test the endpoint with a dummy ID
|
# For now, test the endpoint with a dummy ID
|
||||||
response = await authenticated_client.post("/api/queue/items/dummy-id/retry")
|
response = await authenticated_client.post("/api/queue/items/dummy-id/retry")
|
||||||
|
|
||||||
# Should return 404 if item doesn't exist, or 503 if service unavailable
|
# Should return 404 if item doesn't exist, or 503 if unavailable
|
||||||
assert response.status_code in [200, 404, 503]
|
assert response.status_code in [200, 404, 503]
|
||||||
|
|
||||||
async def test_reorder_queue_items(self, authenticated_client):
|
|
||||||
"""Test reordering queue items."""
|
|
||||||
# Add multiple items
|
|
||||||
item_ids = []
|
|
||||||
for i in range(3):
|
|
||||||
add_response = await authenticated_client.post(
|
|
||||||
"/api/queue/add",
|
|
||||||
json={
|
|
||||||
"serie_id": f"series-{i}",
|
|
||||||
"serie_name": f"Series {i}",
|
|
||||||
"episodes": [{"season": 1, "episode": 1}],
|
|
||||||
"priority": "normal"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if add_response.status_code == 201:
|
|
||||||
item_ids.extend(add_response.json()["item_ids"])
|
|
||||||
|
|
||||||
if len(item_ids) >= 2:
|
|
||||||
# Reorder items
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/reorder",
|
|
||||||
json={"item_order": list(reversed(item_ids))}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code in [200, 503]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadProgressTracking:
|
class TestDownloadProgressTracking:
|
||||||
"""Test progress tracking during downloads."""
|
"""Test progress tracking during downloads."""
|
||||||
@ -348,11 +291,11 @@ class TestDownloadProgressTracking:
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "status" in data
|
# Updated for new response format
|
||||||
|
assert "active_downloads" in data
|
||||||
|
|
||||||
# Check that items can have progress
|
# Check that items can have progress
|
||||||
status = data["status"]
|
for item in data.get("active_downloads", []):
|
||||||
for item in status.get("active", []):
|
|
||||||
if "progress" in item and item["progress"]:
|
if "progress" in item and item["progress"]:
|
||||||
assert "percentage" in item["progress"]
|
assert "percentage" in item["progress"]
|
||||||
assert "current_mb" in item["progress"]
|
assert "current_mb" in item["progress"]
|
||||||
@ -414,13 +357,18 @@ class TestErrorHandlingAndRetries:
|
|||||||
|
|
||||||
if add_response.status_code == 201:
|
if add_response.status_code == 201:
|
||||||
# Get queue status to check retry count
|
# Get queue status to check retry count
|
||||||
status_response = await authenticated_client.get("/api/queue/status")
|
status_response = await authenticated_client.get(
|
||||||
|
"/api/queue/status"
|
||||||
|
)
|
||||||
|
|
||||||
if status_response.status_code == 200:
|
if status_response.status_code == 200:
|
||||||
data = status_response.json()
|
data = status_response.json()
|
||||||
# Verify structure includes retry_count field
|
# Verify structure includes retry_count field
|
||||||
for item_list in [data["status"].get("pending", []),
|
# Updated to match new response structure
|
||||||
data["status"].get("failed", [])]:
|
for item_list in [
|
||||||
|
data.get("pending_queue", []),
|
||||||
|
data.get("failed_downloads", [])
|
||||||
|
]:
|
||||||
for item in item_list:
|
for item in item_list:
|
||||||
assert "retry_count" in item
|
assert "retry_count" in item
|
||||||
|
|
||||||
@ -448,7 +396,7 @@ class TestAuthenticationRequirements:
|
|||||||
|
|
||||||
async def test_queue_control_requires_auth(self, client):
|
async def test_queue_control_requires_auth(self, client):
|
||||||
"""Test that queue control endpoints require authentication."""
|
"""Test that queue control endpoints require authentication."""
|
||||||
response = await client.post("/api/queue/control/start")
|
response = await client.post("/api/queue/start")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
async def test_item_operations_require_auth(self, client):
|
async def test_item_operations_require_auth(self, client):
|
||||||
@ -598,33 +546,7 @@ class TestCompleteDownloadWorkflow:
|
|||||||
assert progress_response.status_code in [200, 503]
|
assert progress_response.status_code in [200, 503]
|
||||||
|
|
||||||
# 5. Verify final state (completed or still processing)
|
# 5. Verify final state (completed or still processing)
|
||||||
final_response = await authenticated_client.get("/api/queue/status")
|
final_response = await authenticated_client.get(
|
||||||
|
"/api/queue/status"
|
||||||
|
)
|
||||||
assert final_response.status_code in [200, 503]
|
assert final_response.status_code in [200, 503]
|
||||||
|
|
||||||
async def test_workflow_with_pause_and_resume(self, authenticated_client):
|
|
||||||
"""Test download workflow with pause and resume."""
|
|
||||||
# Add items
|
|
||||||
await authenticated_client.post(
|
|
||||||
"/api/queue/add",
|
|
||||||
json={
|
|
||||||
"serie_id": "pause-test",
|
|
||||||
"serie_name": "Pause Test Series",
|
|
||||||
"episodes": [{"season": 1, "episode": 1}],
|
|
||||||
"priority": "normal"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start processing
|
|
||||||
await authenticated_client.post("/api/queue/control/start")
|
|
||||||
|
|
||||||
# Pause
|
|
||||||
pause_response = await authenticated_client.post("/api/queue/control/pause")
|
|
||||||
assert pause_response.status_code in [200, 503]
|
|
||||||
|
|
||||||
# Resume
|
|
||||||
resume_response = await authenticated_client.post("/api/queue/control/resume")
|
|
||||||
assert resume_response.status_code in [200, 503]
|
|
||||||
|
|
||||||
# Verify queue status
|
|
||||||
status_response = await authenticated_client.get("/api/queue/status")
|
|
||||||
assert status_response.status_code in [200, 503]
|
|
||||||
|
|||||||
398
tests/integration/test_download_progress_integration.py
Normal file
398
tests/integration/test_download_progress_integration.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
"""Integration tests for download progress WebSocket real-time updates.
|
||||||
|
|
||||||
|
This module tests the end-to-end flow of download progress from the
|
||||||
|
download service through the WebSocket service to connected clients.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.models.download import EpisodeIdentifier
|
||||||
|
from src.server.services.anime_service import AnimeService
|
||||||
|
from src.server.services.download_service import DownloadService
|
||||||
|
from src.server.services.progress_service import ProgressService
|
||||||
|
from src.server.services.websocket_service import WebSocketService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_app():
|
||||||
|
"""Mock SeriesApp for testing."""
|
||||||
|
app = Mock()
|
||||||
|
app.series_list = []
|
||||||
|
app.search = Mock(return_value=[])
|
||||||
|
app.ReScan = Mock()
|
||||||
|
|
||||||
|
def mock_download(
|
||||||
|
serie_folder, season, episode, key, callback=None, **kwargs
|
||||||
|
):
|
||||||
|
"""Simulate download with realistic progress updates."""
|
||||||
|
if callback:
|
||||||
|
# Simulate yt-dlp progress updates
|
||||||
|
for percent in [10, 25, 50, 75, 90, 100]:
|
||||||
|
callback({
|
||||||
|
'percent': float(percent),
|
||||||
|
'downloaded_mb': percent,
|
||||||
|
'total_mb': 100.0,
|
||||||
|
'speed_mbps': 2.5,
|
||||||
|
'eta_seconds': int((100 - percent) / 2.5),
|
||||||
|
})
|
||||||
|
|
||||||
|
result = Mock()
|
||||||
|
result.success = True
|
||||||
|
result.message = "Download completed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
app.download = Mock(side_effect=mock_download)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def progress_service():
|
||||||
|
"""Create a ProgressService instance."""
|
||||||
|
return ProgressService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def websocket_service():
|
||||||
|
"""Create a WebSocketService instance."""
|
||||||
|
return WebSocketService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def anime_service(mock_series_app, progress_service):
|
||||||
|
"""Create an AnimeService."""
|
||||||
|
with patch(
|
||||||
|
"src.server.services.anime_service.SeriesApp",
|
||||||
|
return_value=mock_series_app
|
||||||
|
):
|
||||||
|
service = AnimeService(
|
||||||
|
directory="/test/anime",
|
||||||
|
progress_service=progress_service,
|
||||||
|
)
|
||||||
|
yield service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def download_service(anime_service, progress_service):
|
||||||
|
"""Create a DownloadService."""
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=anime_service,
|
||||||
|
progress_service=progress_service,
|
||||||
|
persistence_path="/tmp/test_integration_progress_queue.json",
|
||||||
|
)
|
||||||
|
yield service
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadProgressIntegration:
|
||||||
|
"""Integration tests for download progress WebSocket flow."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_progress_flow_with_websocket(
|
||||||
|
self, download_service, websocket_service
|
||||||
|
):
|
||||||
|
"""Test complete flow from download to WebSocket broadcast."""
|
||||||
|
# Track all messages sent via WebSocket
|
||||||
|
sent_messages: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Mock WebSocket broadcast methods
|
||||||
|
original_broadcast_progress = (
|
||||||
|
websocket_service.broadcast_download_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_broadcast_progress(download_id: str, data: dict):
|
||||||
|
"""Capture broadcast calls."""
|
||||||
|
sent_messages.append({
|
||||||
|
'type': 'download_progress',
|
||||||
|
'download_id': download_id,
|
||||||
|
'data': data,
|
||||||
|
})
|
||||||
|
# Call original to maintain functionality
|
||||||
|
await original_broadcast_progress(download_id, data)
|
||||||
|
|
||||||
|
websocket_service.broadcast_download_progress = (
|
||||||
|
mock_broadcast_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect download service to WebSocket service
|
||||||
|
async def broadcast_callback(update_type: str, data: dict):
|
||||||
|
"""Bridge download service to WebSocket service."""
|
||||||
|
if update_type == "download_progress":
|
||||||
|
await websocket_service.broadcast_download_progress(
|
||||||
|
data.get("download_id", ""),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(broadcast_callback)
|
||||||
|
|
||||||
|
# Add download to queue
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="integration_test",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Integration Test Anime",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start processing
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
|
||||||
|
# Wait for download to complete
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Verify progress messages were sent
|
||||||
|
progress_messages = [
|
||||||
|
m for m in sent_messages if m['type'] == 'download_progress'
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_messages) >= 3 # Multiple progress updates
|
||||||
|
|
||||||
|
# Verify progress increases
|
||||||
|
percentages = [
|
||||||
|
m['data'].get('progress', {}).get('percent', 0)
|
||||||
|
for m in progress_messages
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should have increasing percentages
|
||||||
|
for i in range(1, len(percentages)):
|
||||||
|
assert percentages[i] >= percentages[i - 1]
|
||||||
|
|
||||||
|
# Last update should be close to 100%
|
||||||
|
assert percentages[-1] >= 90
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_client_receives_progress(
|
||||||
|
self, download_service, websocket_service
|
||||||
|
):
|
||||||
|
"""Test that WebSocket clients receive progress messages."""
|
||||||
|
# Track messages received by clients
|
||||||
|
client_messages: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Mock WebSocket client
|
||||||
|
class MockWebSocket:
|
||||||
|
"""Mock WebSocket for testing."""
|
||||||
|
|
||||||
|
async def accept(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_json(self, data):
|
||||||
|
"""Capture sent messages."""
|
||||||
|
client_messages.append(data)
|
||||||
|
|
||||||
|
async def receive_json(self):
|
||||||
|
# Keep connection open
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
mock_ws = MockWebSocket()
|
||||||
|
|
||||||
|
# Connect mock client
|
||||||
|
connection_id = "test_client_1"
|
||||||
|
await websocket_service.connect(mock_ws, connection_id)
|
||||||
|
|
||||||
|
# Connect download service to WebSocket service
|
||||||
|
async def broadcast_callback(update_type: str, data: dict):
|
||||||
|
if update_type == "download_progress":
|
||||||
|
await websocket_service.broadcast_download_progress(
|
||||||
|
data.get("download_id", ""),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(broadcast_callback)
|
||||||
|
|
||||||
|
# Add and start download
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="client_test",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Client Test Anime",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Verify client received messages
|
||||||
|
progress_messages = [
|
||||||
|
m for m in client_messages
|
||||||
|
if m.get('type') == 'download_progress'
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_messages) >= 2
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await websocket_service.disconnect(connection_id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_clients_receive_same_progress(
|
||||||
|
self, download_service, websocket_service
|
||||||
|
):
|
||||||
|
"""Test that all connected clients receive progress updates."""
|
||||||
|
# Track messages for each client
|
||||||
|
client1_messages: List[Dict] = []
|
||||||
|
client2_messages: List[Dict] = []
|
||||||
|
|
||||||
|
class MockWebSocket:
|
||||||
|
"""Mock WebSocket for testing."""
|
||||||
|
|
||||||
|
def __init__(self, message_list):
|
||||||
|
self.messages = message_list
|
||||||
|
|
||||||
|
async def accept(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_json(self, data):
|
||||||
|
self.messages.append(data)
|
||||||
|
|
||||||
|
async def receive_json(self):
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
# Connect two clients
|
||||||
|
client1 = MockWebSocket(client1_messages)
|
||||||
|
client2 = MockWebSocket(client2_messages)
|
||||||
|
|
||||||
|
await websocket_service.connect(client1, "client1")
|
||||||
|
await websocket_service.connect(client2, "client2")
|
||||||
|
|
||||||
|
# Connect download service
|
||||||
|
async def broadcast_callback(update_type: str, data: dict):
|
||||||
|
if update_type == "download_progress":
|
||||||
|
await websocket_service.broadcast_download_progress(
|
||||||
|
data.get("download_id", ""),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(broadcast_callback)
|
||||||
|
|
||||||
|
# Start download
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="multi_client_test",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Multi Client Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Both clients should receive progress
|
||||||
|
client1_progress = [
|
||||||
|
m for m in client1_messages
|
||||||
|
if m.get('type') == 'download_progress'
|
||||||
|
]
|
||||||
|
client2_progress = [
|
||||||
|
m for m in client2_messages
|
||||||
|
if m.get('type') == 'download_progress'
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(client1_progress) >= 2
|
||||||
|
assert len(client2_progress) >= 2
|
||||||
|
|
||||||
|
# Both should have similar number of updates
|
||||||
|
assert abs(len(client1_progress) - len(client2_progress)) <= 2
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await websocket_service.disconnect("client1")
|
||||||
|
await websocket_service.disconnect("client2")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_data_structure_matches_frontend_expectations(
|
||||||
|
self, download_service, websocket_service
|
||||||
|
):
|
||||||
|
"""Test that progress data structure matches frontend requirements."""
|
||||||
|
captured_data: List[Dict] = []
|
||||||
|
|
||||||
|
async def capture_broadcast(update_type: str, data: dict):
|
||||||
|
if update_type == "download_progress":
|
||||||
|
captured_data.append(data)
|
||||||
|
await websocket_service.broadcast_download_progress(
|
||||||
|
data.get("download_id", ""),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(capture_broadcast)
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="structure_test",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Structure Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=2, episode=3)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
assert len(captured_data) > 0
|
||||||
|
|
||||||
|
# Verify data structure matches frontend expectations
|
||||||
|
for data in captured_data:
|
||||||
|
# Required fields for frontend (queue.js)
|
||||||
|
assert 'download_id' in data or 'item_id' in data
|
||||||
|
assert 'serie_name' in data
|
||||||
|
assert 'season' in data
|
||||||
|
assert 'episode' in data
|
||||||
|
assert 'progress' in data
|
||||||
|
|
||||||
|
# Progress object structure
|
||||||
|
progress = data['progress']
|
||||||
|
assert 'percent' in progress
|
||||||
|
assert 'downloaded_mb' in progress
|
||||||
|
assert 'total_mb' in progress
|
||||||
|
|
||||||
|
# Verify episode info
|
||||||
|
assert data['season'] == 2
|
||||||
|
assert data['episode'] == 3
|
||||||
|
assert data['serie_name'] == "Structure Test"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disconnected_client_doesnt_receive_progress(
|
||||||
|
self, download_service, websocket_service
|
||||||
|
):
|
||||||
|
"""Test that disconnected clients don't receive updates."""
|
||||||
|
client_messages: List[Dict] = []
|
||||||
|
|
||||||
|
class MockWebSocket:
|
||||||
|
async def accept(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_json(self, data):
|
||||||
|
client_messages.append(data)
|
||||||
|
|
||||||
|
async def receive_json(self):
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
mock_ws = MockWebSocket()
|
||||||
|
|
||||||
|
# Connect and then disconnect
|
||||||
|
connection_id = "temp_client"
|
||||||
|
await websocket_service.connect(mock_ws, connection_id)
|
||||||
|
await websocket_service.disconnect(connection_id)
|
||||||
|
|
||||||
|
# Connect download service
|
||||||
|
async def broadcast_callback(update_type: str, data: dict):
|
||||||
|
if update_type == "download_progress":
|
||||||
|
await websocket_service.broadcast_download_progress(
|
||||||
|
data.get("download_id", ""),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(broadcast_callback)
|
||||||
|
|
||||||
|
# Start download after disconnect
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="disconnect_test",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Disconnect Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
initial_message_count = len(client_messages)
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
# Should not receive progress updates after disconnect
|
||||||
|
progress_messages = [
|
||||||
|
m for m in client_messages[initial_message_count:]
|
||||||
|
if m.get('type') == 'download_progress'
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_messages) == 0
|
||||||
@ -60,7 +60,6 @@ async def download_service(anime_service, progress_service):
|
|||||||
"""Create a DownloadService with dependencies."""
|
"""Create a DownloadService with dependencies."""
|
||||||
service = DownloadService(
|
service = DownloadService(
|
||||||
anime_service=anime_service,
|
anime_service=anime_service,
|
||||||
max_concurrent_downloads=2,
|
|
||||||
progress_service=progress_service,
|
progress_service=progress_service,
|
||||||
persistence_path="/tmp/test_queue.json",
|
persistence_path="/tmp/test_queue.json",
|
||||||
)
|
)
|
||||||
@ -173,40 +172,6 @@ class TestWebSocketDownloadIntegration:
|
|||||||
assert stop_broadcast is not None
|
assert stop_broadcast is not None
|
||||||
assert stop_broadcast["data"]["is_running"] is False
|
assert stop_broadcast["data"]["is_running"] is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_pause_resume_broadcast(
|
|
||||||
self, download_service
|
|
||||||
):
|
|
||||||
"""Test that pause/resume operations broadcast updates."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Pause queue
|
|
||||||
await download_service.pause_queue()
|
|
||||||
|
|
||||||
# Resume queue
|
|
||||||
await download_service.resume_queue()
|
|
||||||
|
|
||||||
# Find pause/resume broadcasts
|
|
||||||
pause_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_paused"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
resume_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_resumed"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pause_broadcast is not None
|
|
||||||
assert pause_broadcast["data"]["is_paused"] is True
|
|
||||||
|
|
||||||
assert resume_broadcast is not None
|
|
||||||
assert resume_broadcast["data"]["is_paused"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clear_completed_broadcast(
|
async def test_clear_completed_broadcast(
|
||||||
self, download_service
|
self, download_service
|
||||||
|
|||||||
@ -322,74 +322,3 @@ class TestAPIParameterValidation:
|
|||||||
# Should not grant admin from parameter
|
# Should not grant admin from parameter
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert not data.get("data", {}).get("is_admin", False)
|
assert not data.get("data", {}).get("is_admin", False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.security
|
|
||||||
class TestFileUploadSecurity:
|
|
||||||
"""Security tests for file upload handling."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def client(self):
|
|
||||||
"""Create async HTTP client for testing."""
|
|
||||||
from httpx import ASGITransport
|
|
||||||
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=ASGITransport(app=app), base_url="http://test"
|
|
||||||
) as ac:
|
|
||||||
yield ac
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_malicious_file_extension(self, client):
|
|
||||||
"""Test handling of dangerous file extensions."""
|
|
||||||
dangerous_extensions = [
|
|
||||||
".exe",
|
|
||||||
".sh",
|
|
||||||
".bat",
|
|
||||||
".cmd",
|
|
||||||
".php",
|
|
||||||
".jsp",
|
|
||||||
]
|
|
||||||
|
|
||||||
for ext in dangerous_extensions:
|
|
||||||
files = {"file": (f"test{ext}", b"malicious content")}
|
|
||||||
response = await client.post("/api/upload", files=files)
|
|
||||||
|
|
||||||
# Should reject dangerous files
|
|
||||||
assert response.status_code in [400, 403, 415]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_file_size_limit(self, client):
|
|
||||||
"""Test enforcement of file size limits."""
|
|
||||||
# Try to upload very large file
|
|
||||||
large_content = b"A" * (100 * 1024 * 1024) # 100MB
|
|
||||||
|
|
||||||
files = {"file": ("large.txt", large_content)}
|
|
||||||
response = await client.post("/api/upload", files=files)
|
|
||||||
|
|
||||||
# Should reject oversized files
|
|
||||||
assert response.status_code in [413, 422]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_double_extension_bypass(self, client):
|
|
||||||
"""Test protection against double extension bypass."""
|
|
||||||
files = {"file": ("image.jpg.php", b"<?php phpinfo(); ?>")}
|
|
||||||
response = await client.post("/api/upload", files=files)
|
|
||||||
|
|
||||||
# Should detect and reject
|
|
||||||
assert response.status_code in [400, 403, 415]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mime_type_validation(self, client):
|
|
||||||
"""Test MIME type validation."""
|
|
||||||
# PHP file with image MIME type
|
|
||||||
files = {
|
|
||||||
"file": (
|
|
||||||
"image.jpg",
|
|
||||||
b"<?php phpinfo(); ?>",
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
response = await client.post("/api/upload", files=files)
|
|
||||||
|
|
||||||
# Should validate actual content, not just MIME type
|
|
||||||
assert response.status_code in [400, 403, 415]
|
|
||||||
|
|||||||
@ -1,315 +0,0 @@
|
|||||||
"""Unit tests for analytics service.
|
|
||||||
|
|
||||||
Tests analytics service functionality including download statistics,
|
|
||||||
series popularity tracking, storage analysis, and performance reporting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.server.services.analytics_service import (
|
|
||||||
AnalyticsService,
|
|
||||||
DownloadStats,
|
|
||||||
PerformanceReport,
|
|
||||||
StorageAnalysis,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def analytics_service(tmp_path):
|
|
||||||
"""Create analytics service with temp directory."""
|
|
||||||
with patch("src.server.services.analytics_service.ANALYTICS_FILE",
|
|
||||||
tmp_path / "analytics.json"):
|
|
||||||
service = AnalyticsService()
|
|
||||||
yield service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def mock_db():
|
|
||||||
"""Create mock database session."""
|
|
||||||
db = AsyncMock(spec=AsyncSession)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_service_initialization(analytics_service):
|
|
||||||
"""Test analytics service initializes with default data."""
|
|
||||||
assert analytics_service.analytics_file.exists()
|
|
||||||
|
|
||||||
data = json.loads(analytics_service.analytics_file.read_text())
|
|
||||||
assert "created_at" in data
|
|
||||||
assert "download_stats" in data
|
|
||||||
assert "series_popularity" in data
|
|
||||||
assert data["download_stats"]["total_downloads"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_download_stats_no_data(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test download statistics with no download data."""
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(
|
|
||||||
return_value=[]
|
|
||||||
)))
|
|
||||||
))
|
|
||||||
|
|
||||||
stats = await analytics_service.get_download_stats(mock_db)
|
|
||||||
|
|
||||||
assert isinstance(stats, DownloadStats)
|
|
||||||
assert stats.total_downloads == 0
|
|
||||||
assert stats.successful_downloads == 0
|
|
||||||
assert stats.success_rate == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_download_stats_with_data(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test download statistics with download data."""
|
|
||||||
# Mock downloads - updated to use actual model fields
|
|
||||||
download1 = MagicMock()
|
|
||||||
download1.status = "completed"
|
|
||||||
download1.total_bytes = 1024 * 1024 * 100 # 100 MB
|
|
||||||
download1.download_speed = 1024 * 1024 * 10 # 10 MB/s
|
|
||||||
|
|
||||||
download2 = MagicMock()
|
|
||||||
download2.status = "failed"
|
|
||||||
download2.total_bytes = 0
|
|
||||||
download2.download_speed = None
|
|
||||||
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(
|
|
||||||
return_value=[download1, download2]
|
|
||||||
)))
|
|
||||||
))
|
|
||||||
|
|
||||||
stats = await analytics_service.get_download_stats(mock_db)
|
|
||||||
|
|
||||||
assert stats.total_downloads == 2
|
|
||||||
assert stats.successful_downloads == 1
|
|
||||||
assert stats.failed_downloads == 1
|
|
||||||
assert stats.success_rate == 50.0
|
|
||||||
assert stats.total_bytes_downloaded == 1024 * 1024 * 100
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_series_popularity_empty(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test series popularity with no data."""
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
all=MagicMock(return_value=[])
|
|
||||||
))
|
|
||||||
|
|
||||||
popularity = await analytics_service.get_series_popularity(
|
|
||||||
mock_db, limit=10
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(popularity, list)
|
|
||||||
assert len(popularity) == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_series_popularity_with_data(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test series popularity with data."""
|
|
||||||
# Mock returns tuples:
|
|
||||||
# (series_name, download_count, total_size, last_download, successful)
|
|
||||||
row = (
|
|
||||||
"Test Anime",
|
|
||||||
5,
|
|
||||||
1024 * 1024 * 500,
|
|
||||||
datetime.now(),
|
|
||||||
4
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
all=MagicMock(return_value=[row])
|
|
||||||
))
|
|
||||||
|
|
||||||
popularity = await analytics_service.get_series_popularity(
|
|
||||||
mock_db, limit=10
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(popularity) == 1
|
|
||||||
assert popularity[0].series_name == "Test Anime"
|
|
||||||
assert popularity[0].download_count == 5
|
|
||||||
assert popularity[0].success_rate == 80.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_storage_analysis(analytics_service):
|
|
||||||
"""Test storage analysis retrieval."""
|
|
||||||
with patch("psutil.disk_usage") as mock_disk:
|
|
||||||
mock_disk.return_value = MagicMock(
|
|
||||||
total=1024 * 1024 * 1024 * 1024,
|
|
||||||
used=512 * 1024 * 1024 * 1024,
|
|
||||||
free=512 * 1024 * 1024 * 1024,
|
|
||||||
percent=50.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
analysis = analytics_service.get_storage_analysis()
|
|
||||||
|
|
||||||
assert isinstance(analysis, StorageAnalysis)
|
|
||||||
assert analysis.total_storage_bytes > 0
|
|
||||||
assert analysis.storage_percent_used == 50.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_performance_report_no_data(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test performance report with no data."""
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(
|
|
||||||
return_value=[]
|
|
||||||
)))
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("psutil.Process") as mock_process:
|
|
||||||
mock_process.return_value = MagicMock(
|
|
||||||
memory_info=MagicMock(
|
|
||||||
return_value=MagicMock(rss=100 * 1024 * 1024)
|
|
||||||
),
|
|
||||||
cpu_percent=MagicMock(return_value=10.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
report = await analytics_service.get_performance_report(
|
|
||||||
mock_db, hours=24
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(report, PerformanceReport)
|
|
||||||
assert report.downloads_per_hour == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_record_performance_sample(analytics_service):
|
|
||||||
"""Test recording performance samples."""
|
|
||||||
analytics_service.record_performance_sample(
|
|
||||||
queue_size=5,
|
|
||||||
active_downloads=2,
|
|
||||||
cpu_percent=25.0,
|
|
||||||
memory_mb=512.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = json.loads(
|
|
||||||
analytics_service.analytics_file.read_text()
|
|
||||||
)
|
|
||||||
assert len(data["performance_samples"]) == 1
|
|
||||||
sample = data["performance_samples"][0]
|
|
||||||
assert sample["queue_size"] == 5
|
|
||||||
assert sample["active_downloads"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_record_multiple_performance_samples(
|
|
||||||
analytics_service
|
|
||||||
):
|
|
||||||
"""Test recording multiple performance samples."""
|
|
||||||
for i in range(5):
|
|
||||||
analytics_service.record_performance_sample(
|
|
||||||
queue_size=i,
|
|
||||||
active_downloads=i % 2,
|
|
||||||
cpu_percent=10.0 + i,
|
|
||||||
memory_mb=256.0 + i * 50,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = json.loads(
|
|
||||||
analytics_service.analytics_file.read_text()
|
|
||||||
)
|
|
||||||
assert len(data["performance_samples"]) == 5
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_summary_report(
|
|
||||||
analytics_service, mock_db
|
|
||||||
):
|
|
||||||
"""Test generating comprehensive summary report."""
|
|
||||||
mock_db.execute = AsyncMock(return_value=MagicMock(
|
|
||||||
scalars=MagicMock(return_value=MagicMock(all=MagicMock(
|
|
||||||
return_value=[]
|
|
||||||
))),
|
|
||||||
all=MagicMock(return_value=[]),
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch("psutil.disk_usage") as mock_disk:
|
|
||||||
mock_disk.return_value = MagicMock(
|
|
||||||
total=1024 * 1024 * 1024,
|
|
||||||
used=512 * 1024 * 1024,
|
|
||||||
free=512 * 1024 * 1024,
|
|
||||||
percent=50.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("psutil.Process"):
|
|
||||||
report = await analytics_service.generate_summary_report(
|
|
||||||
mock_db
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "timestamp" in report
|
|
||||||
assert "download_stats" in report
|
|
||||||
assert "series_popularity" in report
|
|
||||||
assert "storage_analysis" in report
|
|
||||||
assert "performance_report" in report
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_dir_size(analytics_service, tmp_path):
|
|
||||||
"""Test directory size calculation."""
|
|
||||||
# Create test files
|
|
||||||
(tmp_path / "file1.txt").write_text("test content")
|
|
||||||
(tmp_path / "file2.txt").write_text("more test content")
|
|
||||||
subdir = tmp_path / "subdir"
|
|
||||||
subdir.mkdir()
|
|
||||||
(subdir / "file3.txt").write_text("nested content")
|
|
||||||
|
|
||||||
size = analytics_service._get_dir_size(tmp_path)
|
|
||||||
|
|
||||||
assert size > 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_dir_size_nonexistent(analytics_service):
|
|
||||||
"""Test directory size for nonexistent directory."""
|
|
||||||
size = analytics_service._get_dir_size(
|
|
||||||
Path("/nonexistent/directory")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert size == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_persistence(analytics_service):
|
|
||||||
"""Test analytics data persistence."""
|
|
||||||
analytics_service.record_performance_sample(
|
|
||||||
queue_size=10,
|
|
||||||
active_downloads=3,
|
|
||||||
cpu_percent=50.0,
|
|
||||||
memory_mb=1024.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new service instance
|
|
||||||
analytics_service2 = AnalyticsService()
|
|
||||||
analytics_service2.analytics_file = analytics_service.analytics_file
|
|
||||||
|
|
||||||
data = json.loads(
|
|
||||||
analytics_service2.analytics_file.read_text()
|
|
||||||
)
|
|
||||||
assert len(data["performance_samples"]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analytics_service_singleton(analytics_service):
|
|
||||||
"""Test analytics service singleton pattern."""
|
|
||||||
from src.server.services.analytics_service import get_analytics_service
|
|
||||||
|
|
||||||
service1 = get_analytics_service()
|
|
||||||
service2 = get_analytics_service()
|
|
||||||
|
|
||||||
assert service1 is service2
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
"""Unit tests for backup service."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.backup_service import BackupService, get_backup_service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_backup_env():
|
|
||||||
"""Create temporary directories for testing."""
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
backup_dir = Path(tmpdir) / "backups"
|
|
||||||
config_dir = Path(tmpdir) / "config"
|
|
||||||
config_dir.mkdir()
|
|
||||||
|
|
||||||
# Create mock config files
|
|
||||||
(config_dir / "config.json").write_text('{"test": "config"}')
|
|
||||||
(config_dir / "download_queue.json").write_text('{"queue": []}')
|
|
||||||
|
|
||||||
yield {
|
|
||||||
"backup_dir": str(backup_dir),
|
|
||||||
"config_dir": str(config_dir),
|
|
||||||
"tmpdir": tmpdir,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_service_initialization(temp_backup_env):
|
|
||||||
"""Test backup service initialization."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert service is not None
|
|
||||||
assert service.backup_dir.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_configuration(temp_backup_env):
|
|
||||||
"""Test configuration backup creation."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
backup_info = service.backup_configuration("Test backup")
|
|
||||||
|
|
||||||
assert backup_info is not None
|
|
||||||
assert backup_info.backup_type == "config"
|
|
||||||
assert backup_info.size_bytes > 0
|
|
||||||
assert "config_" in backup_info.name
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_configuration_no_config(temp_backup_env):
|
|
||||||
"""Test configuration backup with missing config file."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove config file
|
|
||||||
(Path(temp_backup_env["config_dir"]) / "config.json").unlink()
|
|
||||||
|
|
||||||
# Should still create backup (empty tar)
|
|
||||||
backup_info = service.backup_configuration()
|
|
||||||
|
|
||||||
assert backup_info is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_database(temp_backup_env):
|
|
||||||
"""Test database backup creation."""
|
|
||||||
# Create mock database file
|
|
||||||
db_path = Path(temp_backup_env["tmpdir"]) / "aniworld.db"
|
|
||||||
db_path.write_bytes(b"mock database content")
|
|
||||||
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
database_path=str(db_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
backup_info = service.backup_database("DB backup")
|
|
||||||
|
|
||||||
assert backup_info is not None
|
|
||||||
assert backup_info.backup_type == "data"
|
|
||||||
assert backup_info.size_bytes > 0
|
|
||||||
assert "database_" in backup_info.name
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_database_not_found(temp_backup_env):
|
|
||||||
"""Test database backup with missing database."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
database_path="/nonexistent/database.db",
|
|
||||||
)
|
|
||||||
|
|
||||||
backup_info = service.backup_database()
|
|
||||||
|
|
||||||
assert backup_info is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_full(temp_backup_env):
|
|
||||||
"""Test full system backup."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
backup_info = service.backup_full("Full backup")
|
|
||||||
|
|
||||||
assert backup_info is not None
|
|
||||||
assert backup_info.backup_type == "full"
|
|
||||||
assert backup_info.size_bytes > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_backups(temp_backup_env):
|
|
||||||
"""Test listing backups."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create several backups
|
|
||||||
service.backup_configuration()
|
|
||||||
service.backup_full()
|
|
||||||
|
|
||||||
backups = service.list_backups()
|
|
||||||
|
|
||||||
assert len(backups) >= 2
|
|
||||||
assert all("name" in b for b in backups)
|
|
||||||
assert all("type" in b for b in backups)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_backups_by_type(temp_backup_env):
|
|
||||||
"""Test listing backups filtered by type."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create different types of backups
|
|
||||||
service.backup_configuration()
|
|
||||||
service.backup_full()
|
|
||||||
|
|
||||||
config_backups = service.list_backups("config")
|
|
||||||
|
|
||||||
assert all(b["type"] == "config" for b in config_backups)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_backup(temp_backup_env):
|
|
||||||
"""Test backup deletion."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
backup_info = service.backup_configuration()
|
|
||||||
assert backup_info is not None
|
|
||||||
|
|
||||||
backups_before = service.list_backups()
|
|
||||||
assert len(backups_before) > 0
|
|
||||||
|
|
||||||
result = service.delete_backup(backup_info.name)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
backups_after = service.list_backups()
|
|
||||||
assert len(backups_after) < len(backups_before)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_backup_not_found(temp_backup_env):
|
|
||||||
"""Test deleting non-existent backup."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
result = service.delete_backup("nonexistent_backup.tar.gz")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_old_backups(temp_backup_env):
|
|
||||||
"""Test cleanup of old backups."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create multiple backups with small delays to ensure unique timestamps
|
|
||||||
for i in range(5):
|
|
||||||
service.backup_configuration()
|
|
||||||
time.sleep(1) # Ensure different timestamps
|
|
||||||
|
|
||||||
backups_before = service.list_backups()
|
|
||||||
assert len(backups_before) == 5
|
|
||||||
|
|
||||||
# Keep only 2 backups
|
|
||||||
deleted = service.cleanup_old_backups(max_backups=2)
|
|
||||||
|
|
||||||
backups_after = service.list_backups()
|
|
||||||
assert len(backups_after) <= 2
|
|
||||||
assert deleted == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_anime_data(temp_backup_env):
|
|
||||||
"""Test anime data export."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
export_file = Path(temp_backup_env["tmpdir"]) / "anime_export.json"
|
|
||||||
result = service.export_anime_data(str(export_file))
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert export_file.exists()
|
|
||||||
assert "timestamp" in export_file.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_anime_data(temp_backup_env):
|
|
||||||
"""Test anime data import."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create import file
|
|
||||||
import_file = Path(temp_backup_env["tmpdir"]) / "anime_import.json"
|
|
||||||
import_file.write_text('{"timestamp": "2025-01-01T00:00:00", "data": []}')
|
|
||||||
|
|
||||||
result = service.import_anime_data(str(import_file))
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_anime_data_not_found(temp_backup_env):
|
|
||||||
"""Test anime data import with missing file."""
|
|
||||||
service = BackupService(
|
|
||||||
backup_dir=temp_backup_env["backup_dir"],
|
|
||||||
config_dir=temp_backup_env["config_dir"],
|
|
||||||
)
|
|
||||||
|
|
||||||
result = service.import_anime_data("/nonexistent/file.json")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_backup_service():
|
|
||||||
"""Test singleton backup service."""
|
|
||||||
service1 = get_backup_service()
|
|
||||||
service2 = get_backup_service()
|
|
||||||
|
|
||||||
assert service1 is service2
|
|
||||||
assert isinstance(service1, BackupService)
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
"""Unit tests for diagnostics endpoints."""
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.api.diagnostics import (
|
|
||||||
NetworkTestResult,
|
|
||||||
check_dns,
|
|
||||||
check_host_connectivity,
|
|
||||||
network_diagnostics,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiagnosticsEndpoint:
|
|
||||||
"""Test diagnostics API endpoints."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_network_diagnostics_returns_standard_format(self):
|
|
||||||
"""Test that network diagnostics returns the expected format."""
|
|
||||||
# Mock authentication
|
|
||||||
mock_auth = {"user_id": "test_user"}
|
|
||||||
|
|
||||||
# Mock the helper functions
|
|
||||||
with patch(
|
|
||||||
"src.server.api.diagnostics.check_dns",
|
|
||||||
return_value=True
|
|
||||||
), patch(
|
|
||||||
"src.server.api.diagnostics.check_host_connectivity",
|
|
||||||
side_effect=[
|
|
||||||
NetworkTestResult(
|
|
||||||
host="google.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=50.5
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="cloudflare.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=30.2
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="github.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=100.0
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="aniworld.to",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=75.3
|
|
||||||
),
|
|
||||||
]
|
|
||||||
):
|
|
||||||
# Call the endpoint
|
|
||||||
result = await network_diagnostics(auth=mock_auth)
|
|
||||||
|
|
||||||
# Verify response structure
|
|
||||||
assert isinstance(result, dict)
|
|
||||||
assert "status" in result
|
|
||||||
assert "data" in result
|
|
||||||
assert result["status"] == "success"
|
|
||||||
|
|
||||||
# Verify data structure
|
|
||||||
data = result["data"]
|
|
||||||
assert "internet_connected" in data
|
|
||||||
assert "dns_working" in data
|
|
||||||
assert "aniworld_reachable" in data
|
|
||||||
assert "tests" in data
|
|
||||||
|
|
||||||
# Verify values
|
|
||||||
assert data["internet_connected"] is True
|
|
||||||
assert data["dns_working"] is True
|
|
||||||
assert data["aniworld_reachable"] is True
|
|
||||||
assert len(data["tests"]) == 4
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_network_diagnostics_aniworld_unreachable(self):
|
|
||||||
"""Test diagnostics when aniworld.to is unreachable."""
|
|
||||||
mock_auth = {"user_id": "test_user"}
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.diagnostics.check_dns",
|
|
||||||
return_value=True
|
|
||||||
), patch(
|
|
||||||
"src.server.api.diagnostics.check_host_connectivity",
|
|
||||||
side_effect=[
|
|
||||||
NetworkTestResult(
|
|
||||||
host="google.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=50.5
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="cloudflare.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=30.2
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="github.com",
|
|
||||||
reachable=True,
|
|
||||||
response_time_ms=100.0
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="aniworld.to",
|
|
||||||
reachable=False,
|
|
||||||
error="Connection timeout"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
):
|
|
||||||
result = await network_diagnostics(auth=mock_auth)
|
|
||||||
|
|
||||||
# Verify aniworld is marked as unreachable
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert result["data"]["aniworld_reachable"] is False
|
|
||||||
assert result["data"]["internet_connected"] is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_network_diagnostics_all_unreachable(self):
|
|
||||||
"""Test diagnostics when all hosts are unreachable."""
|
|
||||||
mock_auth = {"user_id": "test_user"}
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.api.diagnostics.check_dns",
|
|
||||||
return_value=False
|
|
||||||
), patch(
|
|
||||||
"src.server.api.diagnostics.check_host_connectivity",
|
|
||||||
side_effect=[
|
|
||||||
NetworkTestResult(
|
|
||||||
host="google.com",
|
|
||||||
reachable=False,
|
|
||||||
error="Connection timeout"
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="cloudflare.com",
|
|
||||||
reachable=False,
|
|
||||||
error="Connection timeout"
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="github.com",
|
|
||||||
reachable=False,
|
|
||||||
error="Connection timeout"
|
|
||||||
),
|
|
||||||
NetworkTestResult(
|
|
||||||
host="aniworld.to",
|
|
||||||
reachable=False,
|
|
||||||
error="Connection timeout"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
):
|
|
||||||
result = await network_diagnostics(auth=mock_auth)
|
|
||||||
|
|
||||||
# Verify all are unreachable
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert result["data"]["internet_connected"] is False
|
|
||||||
assert result["data"]["dns_working"] is False
|
|
||||||
assert result["data"]["aniworld_reachable"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestNetworkHelpers:
|
|
||||||
"""Test network helper functions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_dns_success(self):
|
|
||||||
"""Test DNS check when DNS is working."""
|
|
||||||
with patch("socket.gethostbyname", return_value="142.250.185.78"):
|
|
||||||
result = await check_dns()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_dns_failure(self):
|
|
||||||
"""Test DNS check when DNS fails."""
|
|
||||||
import socket
|
|
||||||
with patch(
|
|
||||||
"socket.gethostbyname",
|
|
||||||
side_effect=socket.gaierror("DNS lookup failed")
|
|
||||||
):
|
|
||||||
result = await check_dns()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_host_connectivity_success(self):
|
|
||||||
"""Test host connectivity check when host is reachable."""
|
|
||||||
with patch(
|
|
||||||
"socket.create_connection",
|
|
||||||
return_value=MagicMock()
|
|
||||||
):
|
|
||||||
result = await check_host_connectivity("google.com", 80)
|
|
||||||
assert result.host == "google.com"
|
|
||||||
assert result.reachable is True
|
|
||||||
assert result.response_time_ms is not None
|
|
||||||
assert result.response_time_ms >= 0
|
|
||||||
assert result.error is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_host_connectivity_timeout(self):
|
|
||||||
"""Test host connectivity when connection times out."""
|
|
||||||
import asyncio
|
|
||||||
with patch(
|
|
||||||
"socket.create_connection",
|
|
||||||
side_effect=asyncio.TimeoutError()
|
|
||||||
):
|
|
||||||
result = await check_host_connectivity("example.com", 80, 1.0)
|
|
||||||
assert result.host == "example.com"
|
|
||||||
assert result.reachable is False
|
|
||||||
assert result.error == "Connection timeout"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_host_connectivity_dns_failure(self):
|
|
||||||
"""Test host connectivity when DNS resolution fails."""
|
|
||||||
import socket
|
|
||||||
with patch(
|
|
||||||
"socket.create_connection",
|
|
||||||
side_effect=socket.gaierror("Name resolution failed")
|
|
||||||
):
|
|
||||||
result = await check_host_connectivity("invalid.host", 80)
|
|
||||||
assert result.host == "invalid.host"
|
|
||||||
assert result.reachable is False
|
|
||||||
assert "DNS resolution failed" in result.error
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_host_connectivity_connection_refused(self):
|
|
||||||
"""Test host connectivity when connection is refused."""
|
|
||||||
with patch(
|
|
||||||
"socket.create_connection",
|
|
||||||
side_effect=ConnectionRefusedError()
|
|
||||||
):
|
|
||||||
result = await check_host_connectivity("localhost", 12345)
|
|
||||||
assert result.host == "localhost"
|
|
||||||
assert result.reachable is False
|
|
||||||
assert result.error == "Connection refused"
|
|
||||||
403
tests/unit/test_download_progress_websocket.py
Normal file
403
tests/unit/test_download_progress_websocket.py
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
"""Unit tests for download progress WebSocket updates.
|
||||||
|
|
||||||
|
This module tests the integration between download service progress tracking
|
||||||
|
and WebSocket broadcasting to ensure real-time updates are properly sent
|
||||||
|
to connected clients.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.models.download import (
|
||||||
|
DownloadPriority,
|
||||||
|
DownloadProgress,
|
||||||
|
EpisodeIdentifier,
|
||||||
|
)
|
||||||
|
from src.server.services.anime_service import AnimeService
|
||||||
|
from src.server.services.download_service import DownloadService
|
||||||
|
from src.server.services.progress_service import ProgressService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_app():
|
||||||
|
"""Mock SeriesApp for testing."""
|
||||||
|
app = Mock()
|
||||||
|
app.series_list = []
|
||||||
|
app.search = Mock(return_value=[])
|
||||||
|
app.ReScan = Mock()
|
||||||
|
|
||||||
|
# Mock download with progress callback
|
||||||
|
def mock_download(
|
||||||
|
serie_folder, season, episode, key, callback=None, **kwargs
|
||||||
|
):
|
||||||
|
"""Simulate download with progress updates."""
|
||||||
|
if callback:
|
||||||
|
# Simulate progress updates
|
||||||
|
callback({
|
||||||
|
'percent': 25.0,
|
||||||
|
'downloaded_mb': 25.0,
|
||||||
|
'total_mb': 100.0,
|
||||||
|
'speed_mbps': 2.5,
|
||||||
|
'eta_seconds': 30,
|
||||||
|
})
|
||||||
|
callback({
|
||||||
|
'percent': 50.0,
|
||||||
|
'downloaded_mb': 50.0,
|
||||||
|
'total_mb': 100.0,
|
||||||
|
'speed_mbps': 2.5,
|
||||||
|
'eta_seconds': 20,
|
||||||
|
})
|
||||||
|
callback({
|
||||||
|
'percent': 100.0,
|
||||||
|
'downloaded_mb': 100.0,
|
||||||
|
'total_mb': 100.0,
|
||||||
|
'speed_mbps': 2.5,
|
||||||
|
'eta_seconds': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return success result
|
||||||
|
result = Mock()
|
||||||
|
result.success = True
|
||||||
|
result.message = "Download completed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
app.download = Mock(side_effect=mock_download)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def progress_service():
|
||||||
|
"""Create a ProgressService instance for testing."""
|
||||||
|
return ProgressService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def anime_service(mock_series_app, progress_service):
|
||||||
|
"""Create an AnimeService with mocked dependencies."""
|
||||||
|
with patch(
|
||||||
|
"src.server.services.anime_service.SeriesApp",
|
||||||
|
return_value=mock_series_app
|
||||||
|
):
|
||||||
|
service = AnimeService(
|
||||||
|
directory="/test/anime",
|
||||||
|
progress_service=progress_service,
|
||||||
|
)
|
||||||
|
yield service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def download_service(anime_service, progress_service):
|
||||||
|
"""Create a DownloadService with dependencies."""
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=anime_service,
|
||||||
|
progress_service=progress_service,
|
||||||
|
persistence_path="/tmp/test_download_progress_queue.json",
|
||||||
|
)
|
||||||
|
yield service
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadProgressWebSocket:
|
||||||
|
"""Test download progress WebSocket broadcasting."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_callback_broadcasts_updates(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that progress callback broadcasts updates via WebSocket."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
"""Capture broadcast calls."""
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
# Add item to queue
|
||||||
|
item_ids = await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_1",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Test Anime",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
priority=DownloadPriority.NORMAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(item_ids) == 1
|
||||||
|
|
||||||
|
# Start processing - this should trigger download with progress
|
||||||
|
result = await download_service.start_queue_processing()
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
# Wait for download to process
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Filter progress broadcasts
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should have received multiple progress updates
|
||||||
|
assert len(progress_broadcasts) >= 2
|
||||||
|
|
||||||
|
# Verify progress data structure
|
||||||
|
for broadcast in progress_broadcasts:
|
||||||
|
data = broadcast["data"]
|
||||||
|
assert "download_id" in data or "item_id" in data
|
||||||
|
assert "progress" in data
|
||||||
|
|
||||||
|
progress = data["progress"]
|
||||||
|
assert "percent" in progress
|
||||||
|
assert "downloaded_mb" in progress
|
||||||
|
assert "total_mb" in progress
|
||||||
|
assert 0 <= progress["percent"] <= 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_updates_include_episode_info(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that progress updates include episode information."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
# Add item with specific episode info
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_2",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="My Test Anime",
|
||||||
|
episodes=[EpisodeIdentifier(season=2, episode=5)],
|
||||||
|
priority=DownloadPriority.HIGH,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start processing
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Find progress broadcasts
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_broadcasts) > 0
|
||||||
|
|
||||||
|
# Verify episode info is included
|
||||||
|
data = progress_broadcasts[0]["data"]
|
||||||
|
assert data["serie_name"] == "My Test Anime"
|
||||||
|
assert data["season"] == 2
|
||||||
|
assert data["episode"] == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_percent_increases(self, download_service):
|
||||||
|
"""Test that progress percentage increases over time."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_3",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Progress Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Get progress broadcasts in order
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Verify we have multiple updates
|
||||||
|
assert len(progress_broadcasts) >= 2
|
||||||
|
|
||||||
|
# Verify progress increases
|
||||||
|
percentages = [
|
||||||
|
b["data"]["progress"]["percent"] for b in progress_broadcasts
|
||||||
|
]
|
||||||
|
|
||||||
|
# Each percentage should be >= the previous one
|
||||||
|
for i in range(1, len(percentages)):
|
||||||
|
assert percentages[i] >= percentages[i - 1]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_includes_speed_and_eta(self, download_service):
|
||||||
|
"""Test that progress updates include speed and ETA."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_4",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Speed Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_broadcasts) > 0
|
||||||
|
|
||||||
|
# Check that speed and ETA are present
|
||||||
|
progress = progress_broadcasts[0]["data"]["progress"]
|
||||||
|
assert "speed_mbps" in progress
|
||||||
|
assert "eta_seconds" in progress
|
||||||
|
|
||||||
|
# Speed and ETA should be numeric (or None)
|
||||||
|
if progress["speed_mbps"] is not None:
|
||||||
|
assert isinstance(progress["speed_mbps"], (int, float))
|
||||||
|
if progress["eta_seconds"] is not None:
|
||||||
|
assert isinstance(progress["eta_seconds"], (int, float))
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_broadcast_without_callback(self, download_service):
|
||||||
|
"""Test that no errors occur when broadcast callback is not set."""
|
||||||
|
# Don't set broadcast callback
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_5",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="No Broadcast Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should complete without errors
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Verify download completed successfully
|
||||||
|
status = await download_service.get_queue_status()
|
||||||
|
assert len(status.completed_downloads) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_error_handling(self, download_service):
|
||||||
|
"""Test that broadcast errors don't break download process."""
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
async def failing_broadcast(update_type: str, data: dict):
|
||||||
|
"""Broadcast that always fails."""
|
||||||
|
nonlocal error_count
|
||||||
|
error_count += 1
|
||||||
|
raise RuntimeError("Broadcast failed")
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(failing_broadcast)
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_6",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Error Handling Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should complete despite broadcast errors
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Verify download still completed
|
||||||
|
status = await download_service.get_queue_status()
|
||||||
|
assert len(status.completed_downloads) == 1
|
||||||
|
|
||||||
|
# Verify broadcast was attempted
|
||||||
|
assert error_count > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_downloads_broadcast_separately(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that multiple downloads broadcast their progress separately."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
# Add multiple episodes
|
||||||
|
item_ids = await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_7",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Multi Episode Test",
|
||||||
|
episodes=[
|
||||||
|
EpisodeIdentifier(season=1, episode=1),
|
||||||
|
EpisodeIdentifier(season=1, episode=2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(item_ids) == 2
|
||||||
|
|
||||||
|
# Start processing
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(1.0) # Give time for both downloads
|
||||||
|
|
||||||
|
# Get progress broadcasts
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should have progress for both episodes
|
||||||
|
assert len(progress_broadcasts) >= 4 # At least 2 updates per episode
|
||||||
|
|
||||||
|
# Verify different download IDs
|
||||||
|
download_ids = set()
|
||||||
|
for broadcast in progress_broadcasts:
|
||||||
|
download_id = (
|
||||||
|
broadcast["data"].get("download_id")
|
||||||
|
or broadcast["data"].get("item_id")
|
||||||
|
)
|
||||||
|
if download_id:
|
||||||
|
download_ids.add(download_id)
|
||||||
|
|
||||||
|
# Should have at least 2 unique download IDs
|
||||||
|
assert len(download_ids) >= 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_data_format_matches_model(self, download_service):
|
||||||
|
"""Test that broadcast data matches DownloadProgress model."""
|
||||||
|
broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def mock_broadcast(update_type: str, data: dict):
|
||||||
|
broadcasts.append({"type": update_type, "data": data})
|
||||||
|
|
||||||
|
download_service.set_broadcast_callback(mock_broadcast)
|
||||||
|
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="test_serie_8",
|
||||||
|
serie_folder="test_folder",
|
||||||
|
serie_name="Model Test",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
await download_service.start_queue_processing()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
progress_broadcasts = [
|
||||||
|
b for b in broadcasts if b["type"] == "download_progress"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(progress_broadcasts) > 0
|
||||||
|
|
||||||
|
# Verify progress can be parsed as DownloadProgress
|
||||||
|
progress_data = progress_broadcasts[0]["data"]["progress"]
|
||||||
|
progress = DownloadProgress(**progress_data)
|
||||||
|
|
||||||
|
# Verify required fields
|
||||||
|
assert isinstance(progress.percent, float)
|
||||||
|
assert isinstance(progress.downloaded_mb, float)
|
||||||
|
assert 0 <= progress.percent <= 100
|
||||||
|
assert progress.downloaded_mb >= 0
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for the download queue service.
|
"""Unit tests for the download queue service.
|
||||||
|
|
||||||
Tests cover queue management, priority handling, persistence,
|
Tests cover queue management, manual download control, persistence,
|
||||||
concurrent downloads, and error scenarios.
|
and error scenarios for the simplified download service.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -42,7 +42,6 @@ def download_service(mock_anime_service, temp_persistence_path):
|
|||||||
"""Create a DownloadService instance for testing."""
|
"""Create a DownloadService instance for testing."""
|
||||||
return DownloadService(
|
return DownloadService(
|
||||||
anime_service=mock_anime_service,
|
anime_service=mock_anime_service,
|
||||||
max_concurrent_downloads=2,
|
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
persistence_path=temp_persistence_path,
|
persistence_path=temp_persistence_path,
|
||||||
)
|
)
|
||||||
@ -61,11 +60,10 @@ class TestDownloadServiceInitialization:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert len(service._pending_queue) == 0
|
assert len(service._pending_queue) == 0
|
||||||
assert len(service._active_downloads) == 0
|
assert service._active_download is None
|
||||||
assert len(service._completed_items) == 0
|
assert len(service._completed_items) == 0
|
||||||
assert len(service._failed_items) == 0
|
assert len(service._failed_items) == 0
|
||||||
assert service._is_running is False
|
assert service._is_stopped is True
|
||||||
assert service._is_paused is False
|
|
||||||
|
|
||||||
def test_initialization_loads_persisted_queue(
|
def test_initialization_loads_persisted_queue(
|
||||||
self, mock_anime_service, temp_persistence_path
|
self, mock_anime_service, temp_persistence_path
|
||||||
@ -152,29 +150,6 @@ class TestQueueManagement:
|
|||||||
assert len(item_ids) == 3
|
assert len(item_ids) == 3
|
||||||
assert len(download_service._pending_queue) == 3
|
assert len(download_service._pending_queue) == 3
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_high_priority_to_front(self, download_service):
|
|
||||||
"""Test that high priority items are added to front of queue."""
|
|
||||||
# Add normal priority item
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add high priority item
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-2",
|
|
||||||
serie_name="Priority Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.HIGH,
|
|
||||||
)
|
|
||||||
|
|
||||||
# High priority should be at front
|
|
||||||
assert download_service._pending_queue[0].serie_id == "series-2"
|
|
||||||
assert download_service._pending_queue[1].serie_id == "series-1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remove_from_pending_queue(self, download_service):
|
async def test_remove_from_pending_queue(self, download_service):
|
||||||
"""Test removing items from pending queue."""
|
"""Test removing items from pending queue."""
|
||||||
@ -191,32 +166,108 @@ class TestQueueManagement:
|
|||||||
assert len(download_service._pending_queue) == 0
|
assert len(download_service._pending_queue) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reorder_queue(self, download_service):
|
async def test_start_next_download(self, download_service):
|
||||||
"""Test reordering items in queue."""
|
"""Test starting the next download from queue."""
|
||||||
# Add three items
|
# Add items to queue
|
||||||
|
item_ids = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[
|
||||||
|
EpisodeIdentifier(season=1, episode=1),
|
||||||
|
EpisodeIdentifier(season=1, episode=2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start next download
|
||||||
|
started_id = await download_service.start_next_download()
|
||||||
|
|
||||||
|
assert started_id is not None
|
||||||
|
assert started_id == item_ids[0]
|
||||||
|
assert len(download_service._pending_queue) == 1
|
||||||
|
assert download_service._is_stopped is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_next_download_empty_queue(self, download_service):
|
||||||
|
"""Test starting download with empty queue returns None."""
|
||||||
|
result = await download_service.start_next_download()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_next_download_already_active(
|
||||||
|
self, download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Test that starting download while one is active raises error."""
|
||||||
|
# Add items and start one
|
||||||
await download_service.add_to_queue(
|
await download_service.add_to_queue(
|
||||||
serie_id="series-1",
|
serie_id="series-1",
|
||||||
serie_name="Series 1",
|
serie_name="Test Series",
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
episodes=[
|
||||||
|
EpisodeIdentifier(season=1, episode=1),
|
||||||
|
EpisodeIdentifier(season=1, episode=2),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Make download slow so it stays active
|
||||||
|
async def slow_download(**kwargs):
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
mock_anime_service.download = AsyncMock(side_effect=slow_download)
|
||||||
|
|
||||||
|
# Start first download (will block for 10s in background)
|
||||||
|
item_id = await download_service.start_next_download()
|
||||||
|
assert item_id is not None
|
||||||
|
await asyncio.sleep(0.1) # Let it start processing
|
||||||
|
|
||||||
|
# Try to start another - should fail because one is active
|
||||||
|
with pytest.raises(DownloadServiceError, match="already in progress"):
|
||||||
|
await download_service.start_next_download()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_downloads(self, download_service):
|
||||||
|
"""Test stopping queue processing."""
|
||||||
|
await download_service.stop_downloads()
|
||||||
|
assert download_service._is_stopped is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_completion_moves_to_list(
|
||||||
|
self, download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Test successful download moves item to completed list."""
|
||||||
|
# Add item
|
||||||
await download_service.add_to_queue(
|
await download_service.add_to_queue(
|
||||||
serie_id="series-2",
|
serie_id="series-1",
|
||||||
serie_name="Series 2",
|
serie_name="Test Series",
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-3",
|
|
||||||
serie_name="Series 3",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Move last item to position 0
|
# Start and wait for completion
|
||||||
item_to_move = download_service._pending_queue[2].id
|
await download_service.start_next_download()
|
||||||
success = await download_service.reorder_queue(item_to_move, 0)
|
await asyncio.sleep(0.2) # Wait for download to complete
|
||||||
|
|
||||||
assert success is True
|
assert len(download_service._completed_items) == 1
|
||||||
assert download_service._pending_queue[0].id == item_to_move
|
assert download_service._active_download is None
|
||||||
assert download_service._pending_queue[0].serie_id == "series-3"
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_failure_moves_to_list(
|
||||||
|
self, download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Test failed download moves item to failed list."""
|
||||||
|
# Make download fail
|
||||||
|
mock_anime_service.download = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
# Add item
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start and wait for failure
|
||||||
|
await download_service.start_next_download()
|
||||||
|
await asyncio.sleep(0.2) # Wait for download to fail
|
||||||
|
|
||||||
|
assert len(download_service._failed_items) == 1
|
||||||
|
assert download_service._active_download is None
|
||||||
|
|
||||||
|
|
||||||
class TestQueueStatus:
|
class TestQueueStatus:
|
||||||
@ -237,6 +288,7 @@ class TestQueueStatus:
|
|||||||
|
|
||||||
status = await download_service.get_queue_status()
|
status = await download_service.get_queue_status()
|
||||||
|
|
||||||
|
# Queue is stopped until start_next_download() is called
|
||||||
assert status.is_running is False
|
assert status.is_running is False
|
||||||
assert status.is_paused is False
|
assert status.is_paused is False
|
||||||
assert len(status.pending_queue) == 2
|
assert len(status.pending_queue) == 2
|
||||||
@ -270,19 +322,6 @@ class TestQueueStatus:
|
|||||||
class TestQueueControl:
|
class TestQueueControl:
|
||||||
"""Test queue control operations."""
|
"""Test queue control operations."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pause_queue(self, download_service):
|
|
||||||
"""Test pausing the queue."""
|
|
||||||
await download_service.pause_queue()
|
|
||||||
assert download_service._is_paused is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resume_queue(self, download_service):
|
|
||||||
"""Test resuming the queue."""
|
|
||||||
await download_service.pause_queue()
|
|
||||||
await download_service.resume_queue()
|
|
||||||
assert download_service._is_paused is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clear_completed(self, download_service):
|
async def test_clear_completed(self, download_service):
|
||||||
"""Test clearing completed downloads."""
|
"""Test clearing completed downloads."""
|
||||||
@ -301,6 +340,37 @@ class TestQueueControl:
|
|||||||
assert count == 1
|
assert count == 1
|
||||||
assert len(download_service._completed_items) == 0
|
assert len(download_service._completed_items) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_pending(self, download_service):
|
||||||
|
"""Test clearing all pending downloads from the queue."""
|
||||||
|
# Add multiple items to the queue
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="test-series-1",
|
||||||
|
serie_name="Test Series 1",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="series-2",
|
||||||
|
serie_folder="test-series-2",
|
||||||
|
serie_name="Test Series 2",
|
||||||
|
episodes=[
|
||||||
|
EpisodeIdentifier(season=1, episode=2),
|
||||||
|
EpisodeIdentifier(season=1, episode=3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify items were added
|
||||||
|
assert len(download_service._pending_queue) == 3
|
||||||
|
|
||||||
|
# Clear pending queue
|
||||||
|
count = await download_service.clear_pending()
|
||||||
|
|
||||||
|
# Verify all pending items were cleared
|
||||||
|
assert count == 3
|
||||||
|
assert len(download_service._pending_queue) == 0
|
||||||
|
assert len(download_service._pending_items_by_id) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestPersistence:
|
class TestPersistence:
|
||||||
"""Test queue persistence functionality."""
|
"""Test queue persistence functionality."""
|
||||||
@ -431,6 +501,82 @@ class TestBroadcastCallbacks:
|
|||||||
# Verify callback was called
|
# Verify callback was called
|
||||||
mock_callback.assert_called()
|
mock_callback.assert_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_callback_format(self, download_service):
|
||||||
|
"""Test that progress callback receives correct data format."""
|
||||||
|
# Set up a mock callback to capture progress updates
|
||||||
|
progress_updates = []
|
||||||
|
|
||||||
|
def capture_progress(progress_data: dict):
|
||||||
|
progress_updates.append(progress_data)
|
||||||
|
|
||||||
|
# Mock download to simulate progress
|
||||||
|
async def mock_download_with_progress(*args, **kwargs):
|
||||||
|
# Get the callback from kwargs
|
||||||
|
callback = kwargs.get('callback')
|
||||||
|
if callback:
|
||||||
|
# Simulate progress updates with the expected format
|
||||||
|
callback({
|
||||||
|
'percent': 50.0,
|
||||||
|
'downloaded_mb': 250.5,
|
||||||
|
'total_mb': 501.0,
|
||||||
|
'speed_mbps': 5.2,
|
||||||
|
'eta_seconds': 48,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
download_service._anime_service.download = mock_download_with_progress
|
||||||
|
|
||||||
|
# Add an item to the queue
|
||||||
|
await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process the download
|
||||||
|
item = download_service._pending_queue.popleft()
|
||||||
|
del download_service._pending_items_by_id[item.id]
|
||||||
|
|
||||||
|
# Replace the progress callback with our capture function
|
||||||
|
original_callback = download_service._create_progress_callback
|
||||||
|
|
||||||
|
def wrapper(item):
|
||||||
|
callback = original_callback(item)
|
||||||
|
|
||||||
|
def wrapped_callback(data):
|
||||||
|
capture_progress(data)
|
||||||
|
callback(data)
|
||||||
|
|
||||||
|
return wrapped_callback
|
||||||
|
|
||||||
|
download_service._create_progress_callback = wrapper
|
||||||
|
|
||||||
|
await download_service._process_download(item)
|
||||||
|
|
||||||
|
# Verify progress callback was called with correct format
|
||||||
|
assert len(progress_updates) > 0
|
||||||
|
progress_data = progress_updates[0]
|
||||||
|
|
||||||
|
# Check all expected keys are present
|
||||||
|
assert 'percent' in progress_data
|
||||||
|
assert 'downloaded_mb' in progress_data
|
||||||
|
assert 'total_mb' in progress_data
|
||||||
|
assert 'speed_mbps' in progress_data
|
||||||
|
assert 'eta_seconds' in progress_data
|
||||||
|
|
||||||
|
# Verify values are of correct type
|
||||||
|
assert isinstance(progress_data['percent'], (int, float))
|
||||||
|
assert isinstance(progress_data['downloaded_mb'], (int, float))
|
||||||
|
assert (
|
||||||
|
progress_data['total_mb'] is None
|
||||||
|
or isinstance(progress_data['total_mb'], (int, float))
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
progress_data['speed_mbps'] is None
|
||||||
|
or isinstance(progress_data['speed_mbps'], (int, float))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestServiceLifecycle:
|
class TestServiceLifecycle:
|
||||||
"""Test service start and stop operations."""
|
"""Test service start and stop operations."""
|
||||||
@ -438,33 +584,29 @@ class TestServiceLifecycle:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_service(self, download_service):
|
async def test_start_service(self, download_service):
|
||||||
"""Test starting the service."""
|
"""Test starting the service."""
|
||||||
|
# start() is now just for initialization/compatibility
|
||||||
await download_service.start()
|
await download_service.start()
|
||||||
assert download_service._is_running is True
|
# No _is_running attribute - simplified service doesn't track this
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_service(self, download_service):
|
async def test_stop_service(self, download_service):
|
||||||
"""Test stopping the service."""
|
"""Test stopping the service."""
|
||||||
await download_service.start()
|
await download_service.start()
|
||||||
await download_service.stop()
|
await download_service.stop()
|
||||||
assert download_service._is_running is False
|
# Verifies service can be stopped without errors
|
||||||
|
# No _is_running attribute in simplified service
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_already_running(self, download_service):
|
async def test_start_already_running(self, download_service):
|
||||||
"""Test starting service when already running."""
|
"""Test starting service when already running."""
|
||||||
await download_service.start()
|
await download_service.start()
|
||||||
await download_service.start() # Should not raise error
|
await download_service.start() # Should not raise error
|
||||||
assert download_service._is_running is True
|
# No _is_running attribute in simplified service
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHandling:
|
class TestErrorHandling:
|
||||||
"""Test error handling in download service."""
|
"""Test error handling in download service."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reorder_nonexistent_item(self, download_service):
|
|
||||||
"""Test reordering non-existent item raises error."""
|
|
||||||
with pytest.raises(DownloadServiceError):
|
|
||||||
await download_service.reorder_queue("nonexistent-id", 0)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_failure_moves_to_failed(self, download_service):
|
async def test_download_failure_moves_to_failed(self, download_service):
|
||||||
"""Test that download failures are handled correctly."""
|
"""Test that download failures are handled correctly."""
|
||||||
|
|||||||
@ -1,237 +0,0 @@
|
|||||||
"""Unit tests for monitoring service."""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.monitoring_service import (
|
|
||||||
ErrorMetrics,
|
|
||||||
MonitoringService,
|
|
||||||
QueueMetrics,
|
|
||||||
SystemMetrics,
|
|
||||||
get_monitoring_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_monitoring_service_initialization():
|
|
||||||
"""Test monitoring service initialization."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
assert service is not None
|
|
||||||
assert service._error_log == []
|
|
||||||
assert service._performance_samples == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_system_metrics():
|
|
||||||
"""Test system metrics collection."""
|
|
||||||
service = MonitoringService()
|
|
||||||
metrics = service.get_system_metrics()
|
|
||||||
|
|
||||||
assert isinstance(metrics, SystemMetrics)
|
|
||||||
assert metrics.cpu_percent >= 0
|
|
||||||
assert metrics.memory_percent >= 0
|
|
||||||
assert metrics.disk_percent >= 0
|
|
||||||
assert metrics.uptime_seconds > 0
|
|
||||||
assert metrics.memory_available_mb > 0
|
|
||||||
assert metrics.disk_free_mb > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_metrics_stored():
|
|
||||||
"""Test that system metrics are stored for performance tracking."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
metrics1 = service.get_system_metrics()
|
|
||||||
metrics2 = service.get_system_metrics()
|
|
||||||
|
|
||||||
assert len(service._performance_samples) == 2
|
|
||||||
assert service._performance_samples[0] == metrics1
|
|
||||||
assert service._performance_samples[1] == metrics2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_queue_metrics_empty():
|
|
||||||
"""Test queue metrics with no items."""
|
|
||||||
service = MonitoringService()
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
|
|
||||||
# Mock empty result
|
|
||||||
mock_scalars = AsyncMock()
|
|
||||||
mock_scalars.all = MagicMock(return_value=[])
|
|
||||||
|
|
||||||
mock_result = AsyncMock()
|
|
||||||
mock_result.scalars = MagicMock(return_value=mock_scalars)
|
|
||||||
|
|
||||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
|
||||||
|
|
||||||
metrics = await service.get_queue_metrics(mock_db)
|
|
||||||
|
|
||||||
assert isinstance(metrics, QueueMetrics)
|
|
||||||
assert metrics.total_items == 0
|
|
||||||
assert metrics.success_rate == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_queue_metrics_with_items():
|
|
||||||
"""Test queue metrics with download items."""
|
|
||||||
service = MonitoringService()
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
|
|
||||||
# Create mock queue items
|
|
||||||
item1 = MagicMock()
|
|
||||||
item1.status = "COMPLETED"
|
|
||||||
item1.total_bytes = 1000000
|
|
||||||
item1.downloaded_bytes = 1000000
|
|
||||||
item1.download_speed = 1000000
|
|
||||||
|
|
||||||
item2 = MagicMock()
|
|
||||||
item2.status = "DOWNLOADING"
|
|
||||||
item2.total_bytes = 2000000
|
|
||||||
item2.downloaded_bytes = 1000000
|
|
||||||
item2.download_speed = 500000
|
|
||||||
|
|
||||||
item3 = MagicMock()
|
|
||||||
item3.status = "FAILED"
|
|
||||||
item3.total_bytes = 500000
|
|
||||||
item3.downloaded_bytes = 0
|
|
||||||
item3.download_speed = None
|
|
||||||
|
|
||||||
# Mock result
|
|
||||||
mock_scalars = AsyncMock()
|
|
||||||
mock_scalars.all = MagicMock(return_value=[item1, item2, item3])
|
|
||||||
|
|
||||||
mock_result = AsyncMock()
|
|
||||||
mock_result.scalars = MagicMock(return_value=mock_scalars)
|
|
||||||
|
|
||||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
|
||||||
|
|
||||||
metrics = await service.get_queue_metrics(mock_db)
|
|
||||||
|
|
||||||
assert metrics.total_items == 3
|
|
||||||
assert metrics.completed_items == 1
|
|
||||||
assert metrics.downloading_items == 1
|
|
||||||
assert metrics.failed_items == 1
|
|
||||||
assert metrics.total_size_bytes == 3500000
|
|
||||||
assert metrics.downloaded_bytes == 2000000
|
|
||||||
assert metrics.success_rate > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_error():
|
|
||||||
"""Test error logging."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
service.log_error("Test error 1")
|
|
||||||
service.log_error("Test error 2")
|
|
||||||
|
|
||||||
assert len(service._error_log) == 2
|
|
||||||
assert service._error_log[0][1] == "Test error 1"
|
|
||||||
assert service._error_log[1][1] == "Test error 2"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_error_metrics_empty():
|
|
||||||
"""Test error metrics with no errors."""
|
|
||||||
service = MonitoringService()
|
|
||||||
metrics = service.get_error_metrics()
|
|
||||||
|
|
||||||
assert isinstance(metrics, ErrorMetrics)
|
|
||||||
assert metrics.total_errors == 0
|
|
||||||
assert metrics.errors_24h == 0
|
|
||||||
assert metrics.error_rate_per_hour == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_error_metrics_with_errors():
|
|
||||||
"""Test error metrics with multiple errors."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
service.log_error("ConnectionError: Failed to connect")
|
|
||||||
service.log_error("ConnectionError: Timeout")
|
|
||||||
service.log_error("TimeoutError: Download timeout")
|
|
||||||
|
|
||||||
metrics = service.get_error_metrics()
|
|
||||||
|
|
||||||
assert metrics.total_errors == 3
|
|
||||||
assert metrics.errors_24h == 3
|
|
||||||
assert metrics.last_error_time is not None
|
|
||||||
assert len(metrics.most_common_errors) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_error_metrics_old_errors():
|
|
||||||
"""Test error metrics excludes old errors."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
# Add old error (simulate by directly adding to log)
|
|
||||||
old_time = datetime.now() - timedelta(hours=25)
|
|
||||||
service._error_log.append((old_time, "Old error"))
|
|
||||||
|
|
||||||
# Add recent error
|
|
||||||
service.log_error("Recent error")
|
|
||||||
|
|
||||||
metrics = service.get_error_metrics()
|
|
||||||
|
|
||||||
assert metrics.total_errors == 2
|
|
||||||
assert metrics.errors_24h == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_performance_summary():
|
|
||||||
"""Test performance summary generation."""
|
|
||||||
service = MonitoringService()
|
|
||||||
|
|
||||||
# Collect some samples
|
|
||||||
service.get_system_metrics()
|
|
||||||
service.get_system_metrics()
|
|
||||||
service.get_system_metrics()
|
|
||||||
|
|
||||||
summary = service.get_performance_summary()
|
|
||||||
|
|
||||||
assert "cpu" in summary
|
|
||||||
assert "memory" in summary
|
|
||||||
assert "disk" in summary
|
|
||||||
assert "sample_count" in summary
|
|
||||||
assert summary["sample_count"] == 3
|
|
||||||
assert "current" in summary["cpu"]
|
|
||||||
assert "average" in summary["cpu"]
|
|
||||||
assert "max" in summary["cpu"]
|
|
||||||
assert "min" in summary["cpu"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_performance_summary_empty():
|
|
||||||
"""Test performance summary with no samples."""
|
|
||||||
service = MonitoringService()
|
|
||||||
summary = service.get_performance_summary()
|
|
||||||
|
|
||||||
assert summary == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_comprehensive_status():
|
|
||||||
"""Test comprehensive system status."""
|
|
||||||
service = MonitoringService()
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
|
|
||||||
# Mock empty queue
|
|
||||||
mock_scalars = AsyncMock()
|
|
||||||
mock_scalars.all = MagicMock(return_value=[])
|
|
||||||
|
|
||||||
mock_result = AsyncMock()
|
|
||||||
mock_result.scalars = MagicMock(return_value=mock_scalars)
|
|
||||||
|
|
||||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
|
||||||
|
|
||||||
status = await service.get_comprehensive_status(mock_db)
|
|
||||||
|
|
||||||
assert "timestamp" in status
|
|
||||||
assert "system" in status
|
|
||||||
assert "queue" in status
|
|
||||||
assert "errors" in status
|
|
||||||
assert "performance" in status
|
|
||||||
assert status["system"]["cpu_percent"] >= 0
|
|
||||||
assert status["queue"]["total_items"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_monitoring_service():
|
|
||||||
"""Test singleton monitoring service."""
|
|
||||||
service1 = get_monitoring_service()
|
|
||||||
service2 = get_monitoring_service()
|
|
||||||
|
|
||||||
assert service1 is service2
|
|
||||||
assert isinstance(service1, MonitoringService)
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
"""Tests for rate limiting middleware."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.server.middleware.rate_limit import (
|
|
||||||
RateLimitConfig,
|
|
||||||
RateLimitMiddleware,
|
|
||||||
RateLimitStore,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Shim for environments where httpx.Client.__init__ doesn't accept an
|
|
||||||
# 'app' kwarg (some httpx versions have a different signature). The
|
|
||||||
# TestClient in Starlette passes `app=` through; to keep tests portable
|
|
||||||
# we pop it before calling the real initializer.
|
|
||||||
_orig_httpx_init = httpx.Client.__init__
|
|
||||||
|
|
||||||
|
|
||||||
def _httpx_init_shim(self, *args, **kwargs):
|
|
||||||
kwargs.pop("app", None)
|
|
||||||
return _orig_httpx_init(self, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
httpx.Client.__init__ = _httpx_init_shim
|
|
||||||
|
|
||||||
|
|
||||||
class TestRateLimitStore:
|
|
||||||
"""Tests for RateLimitStore class."""
|
|
||||||
|
|
||||||
def test_check_limit_allows_within_limits(self):
|
|
||||||
"""Test that requests within limits are allowed."""
|
|
||||||
store = RateLimitStore()
|
|
||||||
|
|
||||||
# First request should be allowed
|
|
||||||
allowed, retry_after = store.check_limit("test_id", 10, 100)
|
|
||||||
assert allowed is True
|
|
||||||
assert retry_after is None
|
|
||||||
|
|
||||||
# Record the request
|
|
||||||
store.record_request("test_id")
|
|
||||||
|
|
||||||
# Next request should still be allowed
|
|
||||||
allowed, retry_after = store.check_limit("test_id", 10, 100)
|
|
||||||
assert allowed is True
|
|
||||||
assert retry_after is None
|
|
||||||
|
|
||||||
def test_check_limit_blocks_over_minute_limit(self):
|
|
||||||
"""Test that requests over minute limit are blocked."""
|
|
||||||
store = RateLimitStore()
|
|
||||||
|
|
||||||
# Fill up to the minute limit
|
|
||||||
for _ in range(5):
|
|
||||||
store.record_request("test_id")
|
|
||||||
|
|
||||||
# Next request should be blocked
|
|
||||||
allowed, retry_after = store.check_limit("test_id", 5, 100)
|
|
||||||
assert allowed is False
|
|
||||||
assert retry_after is not None
|
|
||||||
assert retry_after > 0
|
|
||||||
|
|
||||||
def test_check_limit_blocks_over_hour_limit(self):
|
|
||||||
"""Test that requests over hour limit are blocked."""
|
|
||||||
store = RateLimitStore()
|
|
||||||
|
|
||||||
# Fill up to hour limit
|
|
||||||
for _ in range(10):
|
|
||||||
store.record_request("test_id")
|
|
||||||
|
|
||||||
# Next request should be blocked
|
|
||||||
allowed, retry_after = store.check_limit("test_id", 100, 10)
|
|
||||||
assert allowed is False
|
|
||||||
assert retry_after is not None
|
|
||||||
assert retry_after > 0
|
|
||||||
|
|
||||||
def test_get_remaining_requests(self):
|
|
||||||
"""Test getting remaining requests."""
|
|
||||||
store = RateLimitStore()
|
|
||||||
|
|
||||||
# Initially, all requests are remaining
|
|
||||||
minute_rem, hour_rem = store.get_remaining_requests(
|
|
||||||
"test_id", 10, 100
|
|
||||||
)
|
|
||||||
assert minute_rem == 10
|
|
||||||
assert hour_rem == 100
|
|
||||||
|
|
||||||
# After one request
|
|
||||||
store.record_request("test_id")
|
|
||||||
minute_rem, hour_rem = store.get_remaining_requests(
|
|
||||||
"test_id", 10, 100
|
|
||||||
)
|
|
||||||
assert minute_rem == 9
|
|
||||||
assert hour_rem == 99
|
|
||||||
|
|
||||||
|
|
||||||
class TestRateLimitConfig:
|
|
||||||
"""Tests for RateLimitConfig class."""
|
|
||||||
|
|
||||||
def test_default_config(self):
|
|
||||||
"""Test default configuration values."""
|
|
||||||
config = RateLimitConfig()
|
|
||||||
assert config.requests_per_minute == 60
|
|
||||||
assert config.requests_per_hour == 1000
|
|
||||||
assert config.authenticated_multiplier == 2.0
|
|
||||||
|
|
||||||
def test_custom_config(self):
|
|
||||||
"""Test custom configuration values."""
|
|
||||||
config = RateLimitConfig(
|
|
||||||
requests_per_minute=10,
|
|
||||||
requests_per_hour=100,
|
|
||||||
authenticated_multiplier=3.0,
|
|
||||||
)
|
|
||||||
assert config.requests_per_minute == 10
|
|
||||||
assert config.requests_per_hour == 100
|
|
||||||
assert config.authenticated_multiplier == 3.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestRateLimitMiddleware:
|
|
||||||
"""Tests for RateLimitMiddleware class."""
|
|
||||||
|
|
||||||
def create_app(
|
|
||||||
self, default_config: Optional[RateLimitConfig] = None
|
|
||||||
) -> FastAPI:
|
|
||||||
"""Create a test FastAPI app with rate limiting.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_config: Optional default configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured FastAPI app
|
|
||||||
"""
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
# Add rate limiting middleware
|
|
||||||
app.add_middleware(
|
|
||||||
RateLimitMiddleware,
|
|
||||||
default_config=default_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/api/test")
|
|
||||||
async def test_endpoint():
|
|
||||||
return {"message": "success"}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_endpoint():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.get("/api/auth/login")
|
|
||||||
async def login_endpoint():
|
|
||||||
return {"message": "login"}
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
def test_allows_requests_within_limit(self):
|
|
||||||
"""Test that requests within limit are allowed."""
|
|
||||||
app = self.create_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
# Make several requests within limit
|
|
||||||
for _ in range(5):
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
def test_blocks_requests_over_limit(self):
|
|
||||||
"""Test that requests over limit are blocked."""
|
|
||||||
config = RateLimitConfig(
|
|
||||||
requests_per_minute=3,
|
|
||||||
requests_per_hour=100,
|
|
||||||
)
|
|
||||||
app = self.create_app(config)
|
|
||||||
client = TestClient(app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
# Make requests up to limit
|
|
||||||
for _ in range(3):
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Next request should be rate limited
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 429
|
|
||||||
assert "Retry-After" in response.headers
|
|
||||||
|
|
||||||
def test_bypass_health_endpoint(self):
|
|
||||||
"""Test that health endpoint bypasses rate limiting."""
|
|
||||||
config = RateLimitConfig(
|
|
||||||
requests_per_minute=1,
|
|
||||||
requests_per_hour=1,
|
|
||||||
)
|
|
||||||
app = self.create_app(config)
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
# Make many requests to health endpoint
|
|
||||||
for _ in range(10):
|
|
||||||
response = client.get("/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
def test_endpoint_specific_limits(self):
|
|
||||||
"""Test that endpoint-specific limits are applied."""
|
|
||||||
app = self.create_app()
|
|
||||||
client = TestClient(app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
# Login endpoint has strict limit (5 per minute)
|
|
||||||
for _ in range(5):
|
|
||||||
response = client.get("/api/auth/login")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Next login request should be rate limited
|
|
||||||
response = client.get("/api/auth/login")
|
|
||||||
assert response.status_code == 429
|
|
||||||
|
|
||||||
def test_rate_limit_headers(self):
|
|
||||||
"""Test that rate limit headers are added to response."""
|
|
||||||
app = self.create_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "X-RateLimit-Limit-Minute" in response.headers
|
|
||||||
assert "X-RateLimit-Limit-Hour" in response.headers
|
|
||||||
assert "X-RateLimit-Remaining-Minute" in response.headers
|
|
||||||
assert "X-RateLimit-Remaining-Hour" in response.headers
|
|
||||||
|
|
||||||
def test_authenticated_user_multiplier(self):
|
|
||||||
"""Test that authenticated users get higher limits."""
|
|
||||||
config = RateLimitConfig(
|
|
||||||
requests_per_minute=5,
|
|
||||||
requests_per_hour=100,
|
|
||||||
authenticated_multiplier=2.0,
|
|
||||||
)
|
|
||||||
app = self.create_app(config)
|
|
||||||
|
|
||||||
# Add middleware to simulate authentication
|
|
||||||
@app.middleware("http")
|
|
||||||
async def add_user_to_state(request: Request, call_next):
|
|
||||||
request.state.user_id = "user123"
|
|
||||||
response = await call_next(request)
|
|
||||||
return response
|
|
||||||
|
|
||||||
client = TestClient(app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
# Should be able to make 10 requests (5 * 2.0)
|
|
||||||
for _ in range(10):
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Next request should be rate limited
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 429
|
|
||||||
|
|
||||||
def test_different_ips_tracked_separately(self):
|
|
||||||
"""Test that different IPs are tracked separately."""
|
|
||||||
config = RateLimitConfig(
|
|
||||||
requests_per_minute=2,
|
|
||||||
requests_per_hour=100,
|
|
||||||
)
|
|
||||||
app = self.create_app(config)
|
|
||||||
client = TestClient(app, raise_server_exceptions=False)
|
|
||||||
|
|
||||||
# Make requests from "different" IPs
|
|
||||||
# Note: TestClient uses same IP, but we can test the logic
|
|
||||||
for _ in range(2):
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Third request should be rate limited
|
|
||||||
response = client.get("/api/test")
|
|
||||||
assert response.status_code == 429
|
|
||||||
@ -353,59 +353,6 @@ class TestSeriesAppReScan:
|
|||||||
assert "cancelled" in result.message.lower()
|
assert "cancelled" in result.message.lower()
|
||||||
|
|
||||||
|
|
||||||
class TestSeriesAppAsync:
|
|
||||||
"""Test async operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
|
||||||
async def test_async_download(
|
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
|
||||||
):
|
|
||||||
"""Test async download."""
|
|
||||||
test_dir = "/test/anime"
|
|
||||||
app = SeriesApp(test_dir)
|
|
||||||
|
|
||||||
# Mock download
|
|
||||||
app.loader.Download = Mock()
|
|
||||||
|
|
||||||
# Perform async download
|
|
||||||
result = await app.async_download(
|
|
||||||
"anime_folder",
|
|
||||||
season=1,
|
|
||||||
episode=1,
|
|
||||||
key="anime_key"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify result
|
|
||||||
assert isinstance(result, OperationResult)
|
|
||||||
assert result.success is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch('src.core.SeriesApp.Loaders')
|
|
||||||
@patch('src.core.SeriesApp.SerieScanner')
|
|
||||||
@patch('src.core.SeriesApp.SerieList')
|
|
||||||
async def test_async_rescan(
|
|
||||||
self, mock_serie_list, mock_scanner, mock_loaders
|
|
||||||
):
|
|
||||||
"""Test async rescan."""
|
|
||||||
test_dir = "/test/anime"
|
|
||||||
app = SeriesApp(test_dir)
|
|
||||||
|
|
||||||
# Mock scanner
|
|
||||||
app.SerieScanner.GetTotalToScan = Mock(return_value=5)
|
|
||||||
app.SerieScanner.Reinit = Mock()
|
|
||||||
app.SerieScanner.Scan = Mock()
|
|
||||||
|
|
||||||
# Perform async rescan
|
|
||||||
result = await app.async_rescan()
|
|
||||||
|
|
||||||
# Verify result
|
|
||||||
assert isinstance(result, OperationResult)
|
|
||||||
assert result.success is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestSeriesAppCancellation:
|
class TestSeriesAppCancellation:
|
||||||
"""Test operation cancellation."""
|
"""Test operation cancellation."""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user