From ca4bf72fdec6f392478ca0fbd35c533925e4da50 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 2 Nov 2025 08:33:44 +0100 Subject: [PATCH] fix progress issues --- SERVER_COMMANDS.md | 215 ++++++++++ data/download_queue.json | 222 ++++------ docs/logging_implementation_summary.md | 169 -------- instructions.md | 3 +- src/server/web/static/js/queue.js | 106 ++++- stop_server.sh | 23 + .../test_download_progress_integration.py | 398 +++++++++++++++++ .../unit/test_download_progress_websocket.py | 403 ++++++++++++++++++ 8 files changed, 1217 insertions(+), 322 deletions(-) create mode 100644 SERVER_COMMANDS.md delete mode 100644 docs/logging_implementation_summary.md create mode 100644 stop_server.sh create mode 100644 tests/integration/test_download_progress_integration.py create mode 100644 tests/unit/test_download_progress_websocket.py diff --git a/SERVER_COMMANDS.md b/SERVER_COMMANDS.md new file mode 100644 index 0000000..d0a0c7c --- /dev/null +++ b/SERVER_COMMANDS.md @@ -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) diff --git a/data/download_queue.json b/data/download_queue.json index 6e38f71..511d789 100644 --- a/data/download_queue.json +++ b/data/download_queue.json @@ -1,67 +1,7 @@ { "pending": [ { - "id": "1ee2224c-24bf-46ea-b577-30d04ce8ecb8", - "serie_id": "highschool-dxd", - "serie_folder": "Highschool DxD", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 7, - "title": null - }, - "status": "pending", - "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472425Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "40f88e7f-912d-431f-b840-e960851e9fdf", - "serie_id": "highschool-dxd", - "serie_folder": "Highschool DxD", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 8, - "title": null - }, - "status": "pending", - "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472460Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "3e9a2239-5a5f-4c3a-9d8f-b4d3172e17aa", - "serie_id": "highschool-dxd", - "serie_folder": "Highschool DxD", - "serie_name": "Highschool DxD", - "episode": { - "season": 1, - "episode": 9, - "title": null - }, - "status": "pending", - "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472495Z", - "started_at": null, - "completed_at": null, - "progress": null, - "error": null, - "retry_count": 0, - "source_url": null - }, - { - "id": "6a7f16e1-8621-402f-8c32-bed2db9e9749", + "id": "0c1563e1-db41-4163-95b5-b0a6a9531bf1", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -72,7 +12,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472529Z", + "added_at": "2025-11-02T06:44:29.298728Z", "started_at": null, "completed_at": null, "progress": null, @@ -81,7 +21,7 @@ "source_url": null }, { - "id": "c91d509d-66ed-4846-a61a-0d0e38034213", + "id": "5813d573-6834-47e6-a2c0-545aa9c28125", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -92,7 +32,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472562Z", + "added_at": "2025-11-02T06:44:29.298756Z", "started_at": null, "completed_at": null, "progress": null, @@ -101,7 +41,7 @@ "source_url": null }, { - "id": "eaf73ac6-d8fd-4714-bb4b-e6c7875a5bee", + "id": "f89d2f3a-b8f9-47f3-9cc1-15419489c883", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -112,7 +52,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472595Z", + "added_at": "2025-11-02T06:44:29.298786Z", "started_at": null, "completed_at": null, "progress": null, @@ -121,7 +61,7 @@ "source_url": null }, { - "id": "7b57ccf5-4d07-48ea-9306-41335a79eb8e", + "id": "30684faf-7d6e-4544-81c8-6a592d47cdfd", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -132,7 +72,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472629Z", + "added_at": "2025-11-02T06:44:29.298813Z", "started_at": null, "completed_at": null, "progress": null, @@ -141,7 +81,7 @@ "source_url": null }, { - "id": "67322d2f-b376-4111-9e44-4a5367f1e5c6", + "id": "94794c92-0546-485d-8829-0594d04536ee", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -152,7 +92,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472662Z", + "added_at": "2025-11-02T06:44:29.298840Z", "started_at": null, "completed_at": null, "progress": null, @@ -161,7 +101,7 @@ "source_url": null }, { - "id": "827e02b4-6599-4fa2-8260-8493d098858f", + "id": "23ba40ea-e7bb-4def-9bea-825466859d4c", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -172,7 +112,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472695Z", + "added_at": "2025-11-02T06:44:29.298867Z", "started_at": null, "completed_at": null, "progress": null, @@ -181,7 +121,7 @@ "source_url": null }, { - "id": "bf7a53ac-c45a-4d05-aa13-9da48f83093d", + "id": "33462781-c0e8-4e0c-b4d2-172f4856d796", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -192,7 +132,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472727Z", + "added_at": "2025-11-02T06:44:29.298895Z", "started_at": null, "completed_at": null, "progress": null, @@ -201,7 +141,7 @@ "source_url": null }, { - "id": "956fd623-91c5-408f-9173-73af645dfac9", + "id": "0314395b-9540-4f73-8a16-7007797a89b9", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -212,7 +152,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472759Z", + "added_at": "2025-11-02T06:44:29.298924Z", "started_at": null, "completed_at": null, "progress": null, @@ -221,7 +161,7 @@ "source_url": null }, { - "id": "0cf66d7f-0158-43f6-af13-a06d1302569b", + "id": "1074d85c-a66e-42f2-b299-70a2584944ff", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -232,7 +172,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472793Z", + "added_at": "2025-11-02T06:44:29.298952Z", "started_at": null, "completed_at": null, "progress": null, @@ -241,7 +181,7 @@ "source_url": null }, { - "id": "9f36b72c-d169-44ff-9c76-b9c326486546", + "id": "384ebe70-3f39-44e7-9162-372246fbff69", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -252,7 +192,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472825Z", + "added_at": "2025-11-02T06:44:29.298982Z", "started_at": null, "completed_at": null, "progress": null, @@ -261,7 +201,7 @@ "source_url": null }, { - "id": "f84e96e2-c41c-49f6-937a-dc6ef543b194", + "id": "63e51a26-c3c8-4ce0-950a-39d0d131ee0a", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -272,7 +212,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472874Z", + "added_at": "2025-11-02T06:44:29.299010Z", "started_at": null, "completed_at": null, "progress": null, @@ -281,7 +221,7 @@ "source_url": null }, { - "id": "b5718a39-a4a8-4ae5-b033-f7ac21444519", + "id": "8b38c4cb-9c1f-4f74-8dc3-5c5e8e292bf1", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -292,7 +232,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472909Z", + "added_at": "2025-11-02T06:44:29.299038Z", "started_at": null, "completed_at": null, "progress": null, @@ -301,7 +241,7 @@ "source_url": null }, { - "id": "2c9a0f29-f46a-4a50-9c60-38f98d5042d0", + "id": "3a19d684-5710-4c50-920e-12a5e0f933c9", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -312,7 +252,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472942Z", + "added_at": "2025-11-02T06:44:29.299066Z", "started_at": null, "completed_at": null, "progress": null, @@ -321,7 +261,7 @@ "source_url": null }, { - "id": "bb566a05-4308-4dd2-a4a8-a330facc7861", + "id": "62cd2622-ed1d-43fb-831b-3512718cabea", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -332,7 +272,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.472975Z", + "added_at": "2025-11-02T06:44:29.299094Z", "started_at": null, "completed_at": null, "progress": null, @@ -341,7 +281,7 @@ "source_url": null }, { - "id": "e00f909b-2614-4552-8774-984b230c962b", + "id": "160901aa-1a0f-4ae8-bc27-1753fec582cd", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -352,7 +292,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473014Z", + "added_at": "2025-11-02T06:44:29.299123Z", "started_at": null, "completed_at": null, "progress": null, @@ -361,7 +301,7 @@ "source_url": null }, { - "id": "ad91d8eb-c655-463c-88ef-5f0cc501b937", + "id": "efd0717e-708f-455a-942c-461195bc75ed", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -372,7 +312,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473050Z", + "added_at": "2025-11-02T06:44:29.299153Z", "started_at": null, "completed_at": null, "progress": null, @@ -381,7 +321,7 @@ "source_url": null }, { - "id": "af7429fa-2bd5-43b5-9ff1-a98ee6a80b0b", + "id": "c232eb7e-4745-457e-aad3-a103dd808664", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -392,7 +332,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473084Z", + "added_at": "2025-11-02T06:44:29.299182Z", "started_at": null, "completed_at": null, "progress": null, @@ -401,7 +341,7 @@ "source_url": null }, { - "id": "2082cb55-85f4-4fb9-8a22-398c566df455", + "id": "916b49f9-4af5-4de5-931b-082a68621592", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -412,7 +352,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473117Z", + "added_at": "2025-11-02T06:44:29.299213Z", "started_at": null, "completed_at": null, "progress": null, @@ -421,7 +361,7 @@ "source_url": null }, { - "id": "d7110004-a6a9-4f00-be8c-96b7771a585f", + "id": "ebe578cb-58eb-4380-b379-65f46f99b792", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -432,7 +372,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473150Z", + "added_at": "2025-11-02T06:44:29.299242Z", "started_at": null, "completed_at": null, "progress": null, @@ -441,7 +381,7 @@ "source_url": null }, { - "id": "21499a37-0ac0-497a-aebb-f61cb7b873eb", + "id": "523a2eab-371b-4a92-a9ba-179a3a0eca84", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -452,7 +392,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473184Z", + "added_at": "2025-11-02T06:44:29.299274Z", "started_at": null, "completed_at": null, "progress": null, @@ -461,7 +401,7 @@ "source_url": null }, { - "id": "1b523ae3-14e2-4147-ac9c-0fddebb35827", + "id": "846a365c-0300-4384-9548-3f4db3f612d0", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -472,7 +412,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473217Z", + "added_at": "2025-11-02T06:44:29.299305Z", "started_at": null, "completed_at": null, "progress": null, @@ -481,7 +421,7 @@ "source_url": null }, { - "id": "b8bf978b-7f8d-4aeb-930b-fb55fd052632", + "id": "af2100e8-2457-41ba-aec6-dbdc5f58a5b0", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -492,7 +432,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473250Z", + "added_at": "2025-11-02T06:44:29.299335Z", "started_at": null, "completed_at": null, "progress": null, @@ -501,7 +441,7 @@ "source_url": null }, { - "id": "3541e47d-4cc8-4f81-ad72-3022a978b308", + "id": "49c9dc78-5cc1-4036-ba07-b8d5d64197b3", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -512,7 +452,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473284Z", + "added_at": "2025-11-02T06:44:29.299367Z", "started_at": null, "completed_at": null, "progress": null, @@ -521,7 +461,7 @@ "source_url": null }, { - "id": "2ee4a535-31d2-4318-be84-2cdb0b853384", + "id": "b08b3060-7e4d-4220-8141-4006623a1855", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -532,7 +472,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473317Z", + "added_at": "2025-11-02T06:44:29.299396Z", "started_at": null, "completed_at": null, "progress": null, @@ -541,7 +481,7 @@ "source_url": null }, { - "id": "15db1133-a9c6-4869-9618-915bbf09c40c", + "id": "d0503f9b-6598-4a6e-afaa-f866a1fefaf0", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -552,7 +492,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473350Z", + "added_at": "2025-11-02T06:44:29.299430Z", "started_at": null, "completed_at": null, "progress": null, @@ -561,7 +501,7 @@ "source_url": null }, { - "id": "d0acdf2f-9563-4c09-8995-6da7ba76eeb5", + "id": "d87a4bb7-8d66-42c1-963a-7f7de6a57fc9", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -572,7 +512,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473384Z", + "added_at": "2025-11-02T06:44:29.299475Z", "started_at": null, "completed_at": null, "progress": null, @@ -581,7 +521,7 @@ "source_url": null }, { - "id": "9a4da2ad-46e8-4df2-aa5d-7cdea6a173fa", + "id": "08605fcb-408c-4a4e-a529-cbbd52305508", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -592,7 +532,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473416Z", + "added_at": "2025-11-02T06:44:29.299506Z", "started_at": null, "completed_at": null, "progress": null, @@ -601,7 +541,7 @@ "source_url": null }, { - "id": "8b5b8a10-4fa0-4a0b-8edc-cc9be0a742d0", + "id": "a0157d01-8631-4701-ab51-9c82787930be", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -612,7 +552,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473451Z", + "added_at": "2025-11-02T06:44:29.299542Z", "started_at": null, "completed_at": null, "progress": null, @@ -621,7 +561,7 @@ "source_url": null }, { - "id": "25e29c4b-f407-4d8b-b2ac-e4f39533e183", + "id": "d3dff6a3-c853-4270-b357-106659fc80bc", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -632,7 +572,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473483Z", + "added_at": "2025-11-02T06:44:29.299596Z", "started_at": null, "completed_at": null, "progress": null, @@ -641,7 +581,7 @@ "source_url": null }, { - "id": "a65e100c-1060-48e2-b065-979a9f0ae647", + "id": "e459527d-f518-4e04-947f-efaba1a17f47", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -652,7 +592,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473517Z", + "added_at": "2025-11-02T06:44:29.299649Z", "started_at": null, "completed_at": null, "progress": null, @@ -661,7 +601,7 @@ "source_url": null }, { - "id": "a731f0f0-9ac0-4ec5-ac51-bad3f285592d", + "id": "166f2724-a632-4925-80a9-466b47d27516", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -672,7 +612,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473553Z", + "added_at": "2025-11-02T06:44:29.299683Z", "started_at": null, "completed_at": null, "progress": null, @@ -681,7 +621,7 @@ "source_url": null }, { - "id": "db7c028c-a105-4592-aa2b-df76c11075b7", + "id": "c65e9f24-ab9d-4865-b23b-3e76e501c418", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -692,7 +632,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473587Z", + "added_at": "2025-11-02T06:44:29.299713Z", "started_at": null, "completed_at": null, "progress": null, @@ -701,7 +641,7 @@ "source_url": null }, { - "id": "e49a8143-c0af-480b-915c-1bd77e99572c", + "id": "e3b65bc9-0185-4b57-8223-5524f6eab5f2", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -712,7 +652,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473621Z", + "added_at": "2025-11-02T06:44:29.299743Z", "started_at": null, "completed_at": null, "progress": null, @@ -721,7 +661,7 @@ "source_url": null }, { - "id": "c68b02a7-1bca-4403-8ee4-81fd935c7d74", + "id": "8599b63a-ca44-4def-b0d7-d38377b84184", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -732,7 +672,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473655Z", + "added_at": "2025-11-02T06:44:29.299773Z", "started_at": null, "completed_at": null, "progress": null, @@ -741,7 +681,7 @@ "source_url": null }, { - "id": "dfaef101-ab10-46cc-b4cf-d1cde0b1382a", + "id": "8efe8645-2ff6-478c-a07d-b4c433645698", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -752,7 +692,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473690Z", + "added_at": "2025-11-02T06:44:29.299803Z", "started_at": null, "completed_at": null, "progress": null, @@ -761,7 +701,7 @@ "source_url": null }, { - "id": "e6ffbe50-00e6-4710-8fc3-41858baab084", + "id": "4fabf138-d193-462f-bec9-df907ebf46e4", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -772,7 +712,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473725Z", + "added_at": "2025-11-02T06:44:29.299833Z", "started_at": null, "completed_at": null, "progress": null, @@ -781,7 +721,7 @@ "source_url": null }, { - "id": "6dd00e02-269d-41a4-8318-037c451d1282", + "id": "4133c81b-df1c-4b82-be1a-358ed2d84f1a", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -792,7 +732,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473766Z", + "added_at": "2025-11-02T06:44:29.299860Z", "started_at": null, "completed_at": null, "progress": null, @@ -801,7 +741,7 @@ "source_url": null }, { - "id": "a72414f0-a1db-46fb-9292-f776cd732c3f", + "id": "84f6cff7-15d6-4ad5-b94e-e712626dfa19", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -812,7 +752,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473802Z", + "added_at": "2025-11-02T06:44:29.299888Z", "started_at": null, "completed_at": null, "progress": null, @@ -821,7 +761,7 @@ "source_url": null }, { - "id": "1a3541b5-63db-417b-9683-24ed2ee9325f", + "id": "adff47ad-57af-4dc6-a9bd-ea7284f52b87", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -832,7 +772,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473837Z", + "added_at": "2025-11-02T06:44:29.299916Z", "started_at": null, "completed_at": null, "progress": null, @@ -841,7 +781,7 @@ "source_url": null }, { - "id": "7a697f8b-f48a-4f8e-8c1d-457b1a802db5", + "id": "9df059cb-c3aa-41f9-92da-b9d11b618d42", "serie_id": "highschool-dxd", "serie_folder": "Highschool DxD", "serie_name": "Highschool DxD", @@ -852,7 +792,7 @@ }, "status": "pending", "priority": "NORMAL", - "added_at": "2025-11-01T17:50:20.473878Z", + "added_at": "2025-11-02T06:44:29.299945Z", "started_at": null, "completed_at": null, "progress": null, @@ -863,5 +803,5 @@ ], "active": [], "failed": [], - "timestamp": "2025-11-01T18:23:07.297144+00:00" + "timestamp": "2025-11-02T07:30:11.550330+00:00" } \ No newline at end of file diff --git a/docs/logging_implementation_summary.md b/docs/logging_implementation_summary.md deleted file mode 100644 index 59aae2a..0000000 --- a/docs/logging_implementation_summary.md +++ /dev/null @@ -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 diff --git a/instructions.md b/instructions.md index 5de42dd..de67f07 100644 --- a/instructions.md +++ b/instructions.md @@ -105,7 +105,6 @@ 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` @@ -129,4 +128,4 @@ For each task completed: - No database schema changes required - WebSocket infrastructure remains unchanged -# Tasks \ No newline at end of file +# Tasks diff --git a/src/server/web/static/js/queue.js b/src/server/web/static/js/queue.js index e921ca3..85376c2 100644 --- a/src/server/web/static/js/queue.js +++ b/src/server/web/static/js/queue.js @@ -6,6 +6,7 @@ class QueueManager { constructor() { this.socket = null; this.refreshInterval = null; + this.pendingProgressUpdates = new Map(); // Store progress updates waiting for cards this.init(); } @@ -78,6 +79,13 @@ class QueueManager { const serieName = data.serie_name || data.serie || 'Unknown'; const episode = data.episode || ''; this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success'); + + // 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(); }; @@ -88,6 +96,13 @@ class QueueManager { const handleDownloadError = (data) => { const message = data.error || data.message || 'Unknown error'; this.showToast(`Download failed: ${message}`, 'error'); + + // 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(); }; @@ -217,6 +232,9 @@ class QueueManager { const data = await response.json(); this.updateQueueDisplay(data); + + // Process any pending progress updates after queue is loaded + this.processPendingProgressUpdates(); } catch (error) { console.error('Error loading queue data:', error); @@ -286,20 +304,43 @@ class QueueManager { updateDownloadProgress(data) { console.log('updateDownloadProgress called with:', JSON.stringify(data, null, 2)); - // Extract download ID - handle different data structures - let downloadId = data.id || data.download_id || data.item_id; + // 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) { - downloadId = data.data.id || data.data.download_id || data.data.item_id; + 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 } - // Also try metadata.item_id as fallback - if (!downloadId && data.metadata && data.metadata.item_id) { - downloadId = data.metadata.item_id; - } - if (!downloadId) { console.warn('No download ID in progress data'); console.warn('Data structure:', data); @@ -307,15 +348,31 @@ class QueueManager { 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 - might need to reload queue to get new active download - console.log(`Download card not found for ID: ${downloadId}, reloading queue`); + // 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; @@ -364,6 +421,35 @@ class QueueManager { 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) { const container = document.getElementById('active-downloads'); diff --git a/stop_server.sh b/stop_server.sh new file mode 100644 index 0000000..d722e59 --- /dev/null +++ b/stop_server.sh @@ -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" diff --git a/tests/integration/test_download_progress_integration.py b/tests/integration/test_download_progress_integration.py new file mode 100644 index 0000000..49f7c15 --- /dev/null +++ b/tests/integration/test_download_progress_integration.py @@ -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 diff --git a/tests/unit/test_download_progress_websocket.py b/tests/unit/test_download_progress_websocket.py new file mode 100644 index 0000000..5e1d2ae --- /dev/null +++ b/tests/unit/test_download_progress_websocket.py @@ -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