Compare commits
36 Commits
e42e223f28
...
338e3feb4a
| Author | SHA1 | Date | |
|---|---|---|---|
| 338e3feb4a | |||
| 36acd3999e | |||
| 85a6b053eb | |||
| ddff43595f | |||
| 6e9087d0f4 | |||
| 0c8b296aa6 | |||
| a833077f97 | |||
| 5aabad4d13 | |||
| 5934c7666c | |||
| 014e22390e | |||
| c00224467f | |||
| 08c7264d7a | |||
| 3525629853 | |||
| 6d2a791a9d | |||
| 3c8ba1d48c | |||
| f4d14cf17e | |||
| f4dad969bc | |||
| 589141e9aa | |||
| da4973829e | |||
| ff5b364852 | |||
| 6726c176b2 | |||
| 84ca53a1bc | |||
| fb2cdd4bb6 | |||
| dda999fb98 | |||
| e8129f847c | |||
| e1c8b616a8 | |||
| 883f89b113 | |||
| 41a53bbf8f | |||
| 5c08bac248 | |||
| 8443de4e0f | |||
| 51cd319a24 | |||
| c4ec6c9f0e | |||
| aeb1ebe7a2 | |||
| 920a5b0eaf | |||
| 8b5b06ca9a | |||
| 048434d49c |
@ -17,7 +17,7 @@
|
||||
"keep_days": 30
|
||||
},
|
||||
"other": {
|
||||
"master_password_hash": "$pbkdf2-sha256$29000$AsCYU2pNCYHwHoPwnlPqXQ$uHLpvUnvj9GmNFgkAAgk3Yvvp2WzLyMNUBwKMyH79CQ",
|
||||
"master_password_hash": "$pbkdf2-sha256$29000$854zxnhvzXmPsVbqvXduTQ$G0HVRAt3kyO5eFwvo.ILkpX9JdmyXYJ9MNPTS/UxAGk",
|
||||
"anime_directory": "/mnt/server/serien/Serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
|
||||
24
data/config_backups/config_backup_20251128_161248.json
Normal file
24
data/config_backups/config_backup_20251128_161248.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"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$VCqllLL2vldKyTmHkJIyZg$jNllpzlpENdgCslmS.tG.PGxRZ9pUnrqFEQFveDEcYk",
|
||||
"anime_directory": "/mnt/server/serien/Serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
24
data/config_backups/config_backup_20251128_161448.json
Normal file
24
data/config_backups/config_backup_20251128_161448.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"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$3/t/7733PkdoTckZQyildA$Nz9SdX2ZgqBwyzhQ9FGNcnzG1X.TW9oce3sDxJbVSdY",
|
||||
"anime_directory": "/mnt/server/serien/Serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@ -1,20 +1,320 @@
|
||||
{
|
||||
"pending": [
|
||||
{
|
||||
"id": "03bfbf39-60b7-4790-ae9e-a158f654dafa",
|
||||
"serie_id": "a-star-brighter-than-the-sun",
|
||||
"serie_folder": "Ein Stern, heller als die Sonne (2025)",
|
||||
"serie_name": "A Star Brighter Than the Sun",
|
||||
"id": "ae6424dc-558b-4946-9f07-20db1a09bf33",
|
||||
"serie_id": "test-series-2",
|
||||
"serie_folder": "Another Series (2024)",
|
||||
"serie_name": "Another Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 8,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "cancelled",
|
||||
"status": "pending",
|
||||
"priority": "HIGH",
|
||||
"added_at": "2025-11-28T17:54:38.593236Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "011c2038-9fe3-41cb-844f-ce50c40e415f",
|
||||
"serie_id": "series-high",
|
||||
"serie_folder": "Series High (2024)",
|
||||
"serie_name": "Series High",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "HIGH",
|
||||
"added_at": "2025-11-28T17:54:38.632289Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "0eee56e0-414d-4cd7-8da7-b5a139abd8b5",
|
||||
"serie_id": "series-normal",
|
||||
"serie_folder": "Series Normal (2024)",
|
||||
"serie_name": "Series Normal",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-22T16:36:10.705099Z",
|
||||
"started_at": "2025-11-22T16:36:16.078860Z",
|
||||
"completed_at": "2025-11-22T16:37:02.340465Z",
|
||||
"added_at": "2025-11-28T17:54:38.635082Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "eea9f4f3-98e5-4041-9fc6-92e3d4c6fee6",
|
||||
"serie_id": "series-low",
|
||||
"serie_folder": "Series Low (2024)",
|
||||
"serie_name": "Series Low",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "LOW",
|
||||
"added_at": "2025-11-28T17:54:38.637038Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "b6f84ea9-86c8-4cc9-90e5-c7c6ce10c593",
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:38.801266Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "412aa28d-9763-41ef-913d-3d63919f9346",
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:38.867939Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "3a036824-2d14-41dd-81b8-094dd322a137",
|
||||
"serie_id": "invalid-series",
|
||||
"serie_folder": "Invalid Series (2024)",
|
||||
"serie_name": "Invalid Series",
|
||||
"episode": {
|
||||
"season": 99,
|
||||
"episode": 99,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:38.935125Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "1f4108ed-5488-4f46-ad5b-fe27e3b04790",
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:38.968296Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "5e880954-1a9f-450a-8008-5b9d6ac07d66",
|
||||
"serie_id": "series-2",
|
||||
"serie_folder": "Series 2 (2024)",
|
||||
"serie_name": "Series 2",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.055885Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "2415ac21-509b-4d71-b5b9-b824116d6785",
|
||||
"serie_id": "series-0",
|
||||
"serie_folder": "Series 0 (2024)",
|
||||
"serie_name": "Series 0",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.056795Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "716f9823-d59a-4b04-863b-c75fd54bc464",
|
||||
"serie_id": "series-1",
|
||||
"serie_folder": "Series 1 (2024)",
|
||||
"serie_name": "Series 1",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.057486Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "36ad4323-daa9-49c4-97e8-a0aec0cca7a1",
|
||||
"serie_id": "series-4",
|
||||
"serie_folder": "Series 4 (2024)",
|
||||
"serie_name": "Series 4",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.058179Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "695ee7a9-42bb-4953-9a8a-10bd7f533369",
|
||||
"serie_id": "series-3",
|
||||
"serie_folder": "Series 3 (2024)",
|
||||
"serie_name": "Series 3",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.058816Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "aa948908-c410-42ec-85d6-a0298d7d95a5",
|
||||
"serie_id": "persistent-series",
|
||||
"serie_folder": "Persistent Series (2024)",
|
||||
"serie_name": "Persistent Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.152427Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "2537f20e-f394-4c68-81d5-48be3c0c402a",
|
||||
"serie_id": "ws-series",
|
||||
"serie_folder": "WebSocket Series (2024)",
|
||||
"serie_name": "WebSocket Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL",
|
||||
"added_at": "2025-11-28T17:54:39.219061Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
"source_url": null
|
||||
},
|
||||
{
|
||||
"id": "aaaf3b05-cce8-47d5-b350-59c5d72533ad",
|
||||
"serie_id": "workflow-series",
|
||||
"serie_folder": "Workflow Test Series (2024)",
|
||||
"serie_name": "Workflow Test Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
"episode": 1,
|
||||
"title": null
|
||||
},
|
||||
"status": "pending",
|
||||
"priority": "HIGH",
|
||||
"added_at": "2025-11-28T17:54:39.254462Z",
|
||||
"started_at": null,
|
||||
"completed_at": null,
|
||||
"progress": null,
|
||||
"error": null,
|
||||
"retry_count": 0,
|
||||
@ -23,5 +323,5 @@
|
||||
],
|
||||
"active": [],
|
||||
"failed": [],
|
||||
"timestamp": "2025-11-22T16:37:02.340599+00:00"
|
||||
"timestamp": "2025-11-28T17:54:39.259761+00:00"
|
||||
}
|
||||
308
docs/README.md
308
docs/README.md
@ -1,308 +0,0 @@
|
||||
# Aniworld Documentation
|
||||
|
||||
Complete documentation for the Aniworld Download Manager application.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- **New Users**: Start with [User Guide](./user_guide.md)
|
||||
- **Developers**: Check [API Reference](./api_reference.md)
|
||||
- **System Admins**: See [Deployment Guide](./deployment.md)
|
||||
- **Interactive Docs**: Visit `http://localhost:8000/api/docs`
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
### 📖 User Guide (`user_guide.md`)
|
||||
|
||||
Complete guide for end users covering:
|
||||
|
||||
- Installation instructions
|
||||
- Initial setup and configuration
|
||||
- User interface walkthrough
|
||||
- Managing anime library
|
||||
- Download queue management
|
||||
- Configuration and settings
|
||||
- Troubleshooting common issues
|
||||
- Keyboard shortcuts
|
||||
- Frequently asked questions (FAQ)
|
||||
|
||||
**Best for**: Anyone using the Aniworld application
|
||||
|
||||
### 🔌 API Reference (`api_reference.md`)
|
||||
|
||||
Detailed API documentation including:
|
||||
|
||||
- Authentication and authorization
|
||||
- Error handling and status codes
|
||||
- All REST endpoints with examples
|
||||
- WebSocket real-time updates
|
||||
- Request/response formats
|
||||
- Rate limiting and pagination
|
||||
- Complete workflow examples
|
||||
- API changelog
|
||||
|
||||
**Best for**: Developers integrating with the API
|
||||
|
||||
### 🚀 Deployment Guide (`deployment.md`)
|
||||
|
||||
Production deployment instructions covering:
|
||||
|
||||
- System requirements
|
||||
- Pre-deployment checklist
|
||||
- Local development setup
|
||||
- Production deployment steps
|
||||
- Docker and Docker Compose setup
|
||||
- Nginx reverse proxy configuration
|
||||
- SSL/TLS certificate setup
|
||||
- Database configuration (SQLite and PostgreSQL)
|
||||
- Security best practices
|
||||
- Monitoring and maintenance
|
||||
- Troubleshooting deployment issues
|
||||
|
||||
**Best for**: System administrators and DevOps engineers
|
||||
|
||||
## Key Features Documented
|
||||
|
||||
### Authentication
|
||||
|
||||
- Master password setup and login
|
||||
- JWT token management
|
||||
- Session handling
|
||||
- Security best practices
|
||||
|
||||
### Configuration Management
|
||||
|
||||
- Application settings
|
||||
- Directory configuration
|
||||
- Backup and restore functionality
|
||||
- Environment variables
|
||||
|
||||
### Anime Management
|
||||
|
||||
- Browsing anime library
|
||||
- Adding new anime
|
||||
- Managing episodes
|
||||
- Search functionality
|
||||
|
||||
### Download Management
|
||||
|
||||
- Queue operations
|
||||
- Priority management
|
||||
- Progress tracking
|
||||
- Error recovery
|
||||
|
||||
### Real-time Features
|
||||
|
||||
- WebSocket connections
|
||||
- Live download updates
|
||||
- Status notifications
|
||||
- Error alerts
|
||||
|
||||
## Documentation Examples
|
||||
|
||||
### API Usage Example
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
curl -X POST http://localhost:8000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"master_password": "secure_pass"}'
|
||||
|
||||
# Login
|
||||
TOKEN=$(curl -X POST http://localhost:8000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password": "secure_pass"}' | jq -r '.token')
|
||||
|
||||
# List anime
|
||||
curl http://localhost:8000/api/v1/anime \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Deployment Example
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/your-repo/aniworld.git
|
||||
cd aniworld
|
||||
python3.10 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run application
|
||||
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
Access interactive API documentation at:
|
||||
|
||||
- **Swagger UI**: `http://localhost:8000/api/docs`
|
||||
- **ReDoc**: `http://localhost:8000/api/redoc`
|
||||
- **OpenAPI JSON**: `http://localhost:8000/openapi.json`
|
||||
|
||||
These provide:
|
||||
|
||||
- Interactive API explorer
|
||||
- Try-it-out functionality
|
||||
- Request/response examples
|
||||
- Schema validation
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### I want to...
|
||||
|
||||
**Use the application**
|
||||
→ Read [User Guide](./user_guide.md) → Getting Started section
|
||||
|
||||
**Set up on my computer**
|
||||
→ Read [User Guide](./user_guide.md) → Installation section
|
||||
|
||||
**Deploy to production**
|
||||
→ Read [Deployment Guide](./deployment.md) → Production Deployment
|
||||
|
||||
**Use the API**
|
||||
→ Read [API Reference](./api_reference.md) → API Endpoints section
|
||||
|
||||
**Troubleshoot problems**
|
||||
→ Read [User Guide](./user_guide.md) → Troubleshooting section
|
||||
|
||||
**Set up with Docker**
|
||||
→ Read [Deployment Guide](./deployment.md) → Docker Deployment
|
||||
|
||||
**Configure backup/restore**
|
||||
→ Read [User Guide](./user_guide.md) → Configuration section
|
||||
|
||||
**Debug API issues**
|
||||
→ Check [API Reference](./api_reference.md) → Error Handling section
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
All documentation follows these standards:
|
||||
|
||||
### Structure
|
||||
|
||||
- Clear table of contents
|
||||
- Logical section ordering
|
||||
- Cross-references to related topics
|
||||
- Code examples where appropriate
|
||||
|
||||
### Style
|
||||
|
||||
- Plain, accessible language
|
||||
- Step-by-step instructions
|
||||
- Visual formatting (code blocks, tables, lists)
|
||||
- Examples for common scenarios
|
||||
|
||||
### Completeness
|
||||
|
||||
- All major features covered
|
||||
- Edge cases documented
|
||||
- Troubleshooting guidance
|
||||
- FAQ section included
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Version number tracking
|
||||
- Last updated timestamp
|
||||
- Changelog for updates
|
||||
- Broken link checking
|
||||
|
||||
## Help & Support
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check Documentation First**
|
||||
|
||||
- Search in relevant guide
|
||||
- Check FAQ section
|
||||
- Look for similar examples
|
||||
|
||||
2. **Check Logs**
|
||||
|
||||
- Application logs in `/logs/`
|
||||
- Browser console (F12)
|
||||
- System logs
|
||||
|
||||
3. **Try Troubleshooting**
|
||||
|
||||
- Follow troubleshooting steps in user guide
|
||||
- Check known issues section
|
||||
- Verify system requirements
|
||||
|
||||
4. **Get Community Help**
|
||||
|
||||
- GitHub Issues
|
||||
- Discussion Forums
|
||||
- Community Discord
|
||||
|
||||
5. **Report Issues**
|
||||
- File GitHub issue
|
||||
- Include logs and error messages
|
||||
- Describe reproduction steps
|
||||
- Specify system details
|
||||
|
||||
### Feedback
|
||||
|
||||
We welcome feedback on documentation:
|
||||
|
||||
- Unclear sections
|
||||
- Missing information
|
||||
- Incorrect instructions
|
||||
- Outdated content
|
||||
- Suggest improvements
|
||||
|
||||
File documentation issues on GitHub with label `documentation`.
|
||||
|
||||
## Contributing to Documentation
|
||||
|
||||
Documentation improvements are welcome! To contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Edit documentation files
|
||||
3. Test changes locally
|
||||
4. Submit pull request
|
||||
5. Include summary of changes
|
||||
|
||||
See `CONTRIBUTING.md` for guidelines.
|
||||
|
||||
## Documentation Map
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md # This file
|
||||
├── user_guide.md # End-user documentation
|
||||
├── api_reference.md # API documentation
|
||||
├── deployment.md # Deployment instructions
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Related Resources
|
||||
|
||||
- **Source Code**: GitHub repository
|
||||
- **Interactive API**: `http://localhost:8000/api/docs`
|
||||
- **Issue Tracker**: GitHub Issues
|
||||
- **Releases**: GitHub Releases
|
||||
- **License**: See LICENSE file
|
||||
|
||||
## Document Info
|
||||
|
||||
- **Last Updated**: October 22, 2025
|
||||
- **Version**: 1.0.0
|
||||
- **Status**: Production Ready
|
||||
- **Maintainers**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Quick Links
|
||||
|
||||
| Resource | Link |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| User Guide | [user_guide.md](./user_guide.md) |
|
||||
| API Reference | [api_reference.md](./api_reference.md) |
|
||||
| Deployment Guide | [deployment.md](./deployment.md) |
|
||||
| Swagger UI | http://localhost:8000/api/docs |
|
||||
| GitHub Issues | https://github.com/your-repo/aniworld/issues |
|
||||
| Project Repository | https://github.com/your-repo/aniworld |
|
||||
|
||||
---
|
||||
|
||||
**For Questions**: Check relevant guide first, then file GitHub issue with details.
|
||||
@ -1,245 +0,0 @@
|
||||
# API Endpoints Implementation Summary
|
||||
|
||||
**Date:** October 24, 2025
|
||||
**Task:** Implement Missing API Endpoints
|
||||
**Status:** ✅ COMPLETED
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented all missing API endpoints that were referenced in the frontend but not yet available in the backend. This completes the frontend-backend integration and ensures all features in the web UI are fully functional.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. `src/server/api/scheduler.py`
|
||||
|
||||
**Purpose:** Scheduler configuration and manual trigger endpoints
|
||||
|
||||
**Endpoints Implemented:**
|
||||
|
||||
- `GET /api/scheduler/config` - Get current scheduler configuration
|
||||
- `POST /api/scheduler/config` - Update scheduler configuration
|
||||
- `POST /api/scheduler/trigger-rescan` - Manually trigger library rescan
|
||||
|
||||
**Features:**
|
||||
|
||||
- Type-safe configuration management using Pydantic models
|
||||
- Authentication required for configuration updates
|
||||
- Integration with existing SeriesApp rescan functionality
|
||||
- Proper error handling and logging
|
||||
|
||||
### 2. `src/server/api/logging.py`
|
||||
|
||||
**Purpose:** Logging configuration and log file management
|
||||
|
||||
**Endpoints Implemented:**
|
||||
|
||||
- `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 at all levels
|
||||
- `POST /api/logging/cleanup` - Clean up old log files
|
||||
|
||||
**Features:**
|
||||
|
||||
- Dynamic logging configuration updates
|
||||
- Secure file access with path validation
|
||||
- Support for log rotation
|
||||
- File streaming for large log files
|
||||
- Automatic cleanup with age-based filtering
|
||||
|
||||
### 3. `src/server/api/diagnostics.py`
|
||||
|
||||
**Purpose:** System diagnostics and health monitoring
|
||||
|
||||
**Endpoints Implemented:**
|
||||
|
||||
- `GET /api/diagnostics/network` - Network connectivity diagnostics
|
||||
- `GET /api/diagnostics/system` - System information
|
||||
|
||||
**Features:**
|
||||
|
||||
- Async network connectivity testing
|
||||
- DNS resolution validation
|
||||
- Multiple host testing (Google, Cloudflare, GitHub)
|
||||
- Response time measurement
|
||||
- System platform and version information
|
||||
|
||||
### 4. Extended `src/server/api/config.py`
|
||||
|
||||
**Purpose:** Additional configuration management endpoints
|
||||
|
||||
**New Endpoints Added:**
|
||||
|
||||
- `GET /api/config/section/advanced` - Get advanced configuration
|
||||
- `POST /api/config/section/advanced` - Update advanced configuration
|
||||
- `POST /api/config/directory` - Update anime directory
|
||||
- `POST /api/config/export` - Export configuration to JSON
|
||||
- `POST /api/config/reset` - Reset configuration to defaults
|
||||
|
||||
**Features:**
|
||||
|
||||
- Section-based configuration management
|
||||
- Configuration export with sensitive data filtering
|
||||
- Safe configuration reset with security preservation
|
||||
- Automatic backup creation before destructive operations
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `src/server/fastapi_app.py`
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Added imports for new routers (scheduler, logging, diagnostics)
|
||||
- Included new routers in the FastAPI application
|
||||
- Maintained proper router ordering for endpoint priority
|
||||
|
||||
### 2. `docs/api_reference.md`
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Added complete documentation for all new endpoints
|
||||
- Updated table of contents with new sections
|
||||
- Included request/response examples for each endpoint
|
||||
- Added error codes and status responses
|
||||
|
||||
### 3. `infrastructure.md`
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Added scheduler endpoints section
|
||||
- Added logging endpoints section
|
||||
- Added diagnostics endpoints section
|
||||
- Extended configuration endpoints documentation
|
||||
|
||||
### 4. `instructions.md`
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Marked "Missing API Endpoints" task as completed
|
||||
- Added implementation details summary
|
||||
- Updated pending tasks section
|
||||
|
||||
## Test Results
|
||||
|
||||
**Test Suite:** All Tests
|
||||
**Total Tests:** 802
|
||||
**Passed:** 752 (93.8%)
|
||||
**Failed:** 36 (mostly in SQL injection and performance tests - expected)
|
||||
**Errors:** 14 (in performance load testing - expected)
|
||||
|
||||
**Key Test Coverage:**
|
||||
|
||||
- ✅ All API endpoint tests passing
|
||||
- ✅ Authentication and authorization tests passing
|
||||
- ✅ Frontend integration tests passing
|
||||
- ✅ WebSocket integration tests passing
|
||||
- ✅ Configuration management tests passing
|
||||
|
||||
## Code Quality
|
||||
|
||||
**Standards Followed:**
|
||||
|
||||
- PEP 8 style guidelines
|
||||
- Type hints throughout
|
||||
- Comprehensive docstrings
|
||||
- Proper error handling with custom exceptions
|
||||
- Structured logging
|
||||
- Security best practices (path validation, authentication)
|
||||
|
||||
**Linting:**
|
||||
|
||||
- All critical lint errors resolved
|
||||
- Only import resolution warnings remaining (expected in development without installed packages)
|
||||
- Line length maintained under 79 characters where possible
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
All endpoints are now callable from the existing JavaScript frontend:
|
||||
|
||||
- Configuration modal fully functional
|
||||
- Scheduler configuration working
|
||||
- Logging management operational
|
||||
- Diagnostics accessible
|
||||
- Advanced configuration available
|
||||
|
||||
### Backend Integration
|
||||
|
||||
- Properly integrated with existing ConfigService
|
||||
- Uses existing authentication/authorization system
|
||||
- Follows established error handling patterns
|
||||
- Maintains consistency with existing API design
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Authentication:**
|
||||
|
||||
- All write operations require authentication
|
||||
- Read operations optionally authenticated
|
||||
- JWT token validation on protected endpoints
|
||||
|
||||
**Input Validation:**
|
||||
|
||||
- Path traversal prevention in file operations
|
||||
- Type validation using Pydantic models
|
||||
- Query parameter validation
|
||||
|
||||
**Data Protection:**
|
||||
|
||||
- Sensitive data filtering in config export
|
||||
- Security settings preservation in config reset
|
||||
- Secure file access controls
|
||||
|
||||
## Performance
|
||||
|
||||
**Optimizations:**
|
||||
|
||||
- Async/await for I/O operations
|
||||
- Efficient file streaming for large logs
|
||||
- Concurrent network diagnostics testing
|
||||
- Minimal memory footprint
|
||||
|
||||
**Resource Usage:**
|
||||
|
||||
- Log file operations don't load entire files
|
||||
- Network tests have configurable timeouts
|
||||
- File cleanup operates in controlled batches
|
||||
|
||||
## Documentation
|
||||
|
||||
**Complete Documentation Provided:**
|
||||
|
||||
- API reference with all endpoints
|
||||
- Request/response examples
|
||||
- Error codes and handling
|
||||
- Query parameters
|
||||
- Authentication requirements
|
||||
- Usage examples
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
**Potential Improvements:**
|
||||
|
||||
- Add pagination to log file listings
|
||||
- Implement log file search functionality
|
||||
- Add more network diagnostic targets
|
||||
- Enhanced configuration validation rules
|
||||
- Scheduled log cleanup
|
||||
- Log file compression for old files
|
||||
|
||||
## Conclusion
|
||||
|
||||
All missing API endpoints have been successfully implemented with:
|
||||
|
||||
- ✅ Full functionality
|
||||
- ✅ Proper authentication
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Complete documentation
|
||||
- ✅ Test coverage
|
||||
- ✅ Security best practices
|
||||
- ✅ Frontend integration
|
||||
|
||||
The web application is now feature-complete with all frontend functionality backed by corresponding API endpoints.
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,772 +0,0 @@
|
||||
# Aniworld Deployment Guide
|
||||
|
||||
Complete deployment guide for the Aniworld Download Manager application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Requirements](#system-requirements)
|
||||
2. [Pre-Deployment Checklist](#pre-deployment-checklist)
|
||||
3. [Local Development Setup](#local-development-setup)
|
||||
4. [Production Deployment](#production-deployment)
|
||||
5. [Docker Deployment](#docker-deployment)
|
||||
6. [Configuration](#configuration)
|
||||
7. [Database Setup](#database-setup)
|
||||
8. [Security Considerations](#security-considerations)
|
||||
9. [Monitoring & Maintenance](#monitoring--maintenance)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Minimum Requirements
|
||||
|
||||
- **OS**: Windows 10/11, macOS 10.14+, Ubuntu 20.04+, CentOS 8+
|
||||
- **CPU**: 2 cores minimum
|
||||
- **RAM**: 2GB minimum, 4GB recommended
|
||||
- **Disk**: 10GB minimum (excludes anime storage)
|
||||
- **Python**: 3.10 or higher
|
||||
- **Browser**: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
|
||||
|
||||
### Recommended Production Setup
|
||||
|
||||
- **OS**: Ubuntu 20.04 LTS or CentOS 8+
|
||||
- **CPU**: 4 cores minimum
|
||||
- **RAM**: 8GB minimum
|
||||
- **Disk**: SSD with 50GB+ free space
|
||||
- **Network**: Gigabit connection (for download speed)
|
||||
- **Database**: PostgreSQL 12+ (for multi-process deployments)
|
||||
|
||||
### Bandwidth Requirements
|
||||
|
||||
- **Download Speed**: 5+ Mbps recommended
|
||||
- **Upload**: 1+ Mbps for remote logging
|
||||
- **Latency**: <100ms for responsive UI
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
### Before Deployment
|
||||
|
||||
- [ ] System meets minimum requirements
|
||||
- [ ] Python 3.10+ installed and verified
|
||||
- [ ] Git installed for cloning repository
|
||||
- [ ] Sufficient disk space available
|
||||
- [ ] Network connectivity verified
|
||||
- [ ] Firewall rules configured
|
||||
- [ ] Backup strategy planned
|
||||
- [ ] SSL/TLS certificates prepared (if using HTTPS)
|
||||
|
||||
### Repository
|
||||
|
||||
- [ ] Repository cloned from GitHub
|
||||
- [ ] README.md reviewed
|
||||
- [ ] LICENSE checked
|
||||
- [ ] CONTRIBUTING.md understood
|
||||
- [ ] Code review completed
|
||||
|
||||
### Configuration
|
||||
|
||||
- [ ] Environment variables prepared
|
||||
- [ ] Master password decided
|
||||
- [ ] Anime directory paths identified
|
||||
- [ ] Download directory paths identified
|
||||
- [ ] Backup location planned
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [ ] All Python packages available
|
||||
- [ ] No version conflicts
|
||||
- [ ] Virtual environment ready
|
||||
- [ ] Dependencies documented
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] All unit tests passing
|
||||
- [ ] Integration tests passing
|
||||
- [ ] Load testing completed (production)
|
||||
- [ ] Security scanning done
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/aniworld.git
|
||||
cd aniworld
|
||||
```
|
||||
|
||||
### 2. Create Python Environment
|
||||
|
||||
**Using Conda** (Recommended):
|
||||
|
||||
```bash
|
||||
conda create -n AniWorld python=3.10
|
||||
conda activate AniWorld
|
||||
```
|
||||
|
||||
**Using venv**:
|
||||
|
||||
```bash
|
||||
python3.10 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Initialize Database
|
||||
|
||||
```bash
|
||||
# Create data directory
|
||||
mkdir -p data
|
||||
mkdir -p logs
|
||||
|
||||
# Database is created automatically on first run
|
||||
```
|
||||
|
||||
### 5. Configure Application
|
||||
|
||||
Create `.env` file in project root:
|
||||
|
||||
```bash
|
||||
# Core settings
|
||||
APP_NAME=Aniworld
|
||||
APP_ENV=development
|
||||
DEBUG=true
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///./data/aniworld.db
|
||||
|
||||
# Server
|
||||
HOST=127.0.0.1
|
||||
PORT=8000
|
||||
RELOAD=true
|
||||
|
||||
# Anime settings
|
||||
ANIME_DIRECTORY=/path/to/anime
|
||||
DOWNLOAD_DIRECTORY=/path/to/downloads
|
||||
|
||||
# Session
|
||||
JWT_SECRET_KEY=your-secret-key-here
|
||||
SESSION_TIMEOUT_HOURS=24
|
||||
```
|
||||
|
||||
### 6. Run Application
|
||||
|
||||
```bash
|
||||
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
|
||||
```
|
||||
|
||||
### 7. Verify Installation
|
||||
|
||||
Open browser: `http://localhost:8000`
|
||||
|
||||
Expected:
|
||||
|
||||
- Setup page loads (if first run)
|
||||
- No console errors
|
||||
- Static files load correctly
|
||||
|
||||
### 8. Run Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# Specific test file
|
||||
python -m pytest tests/unit/test_auth_service.py -v
|
||||
|
||||
# With coverage
|
||||
python -m pytest tests/ --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### 1. System Preparation
|
||||
|
||||
**Update System**:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get upgrade -y
|
||||
```
|
||||
|
||||
**Install Python**:
|
||||
|
||||
```bash
|
||||
sudo apt-get install python3.10 python3.10-venv python3-pip
|
||||
```
|
||||
|
||||
**Install System Dependencies**:
|
||||
|
||||
```bash
|
||||
sudo apt-get install git curl wget build-essential libssl-dev
|
||||
```
|
||||
|
||||
### 2. Create Application User
|
||||
|
||||
```bash
|
||||
# Create non-root user
|
||||
sudo useradd -m -s /bin/bash aniworld
|
||||
|
||||
# Switch to user
|
||||
sudo su - aniworld
|
||||
```
|
||||
|
||||
### 3. Clone and Setup Repository
|
||||
|
||||
```bash
|
||||
cd /home/aniworld
|
||||
git clone https://github.com/your-repo/aniworld.git
|
||||
cd aniworld
|
||||
```
|
||||
|
||||
### 4. Create Virtual Environment
|
||||
|
||||
```bash
|
||||
python3.10 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 5. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn uvicorn
|
||||
```
|
||||
|
||||
### 6. Configure Production Environment
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```bash
|
||||
# Core settings
|
||||
APP_NAME=Aniworld
|
||||
APP_ENV=production
|
||||
DEBUG=false
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database (use PostgreSQL for production)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/aniworld
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
WORKERS=4
|
||||
|
||||
# Anime settings
|
||||
ANIME_DIRECTORY=/var/aniworld/anime
|
||||
DOWNLOAD_DIRECTORY=/var/aniworld/downloads
|
||||
CACHE_DIRECTORY=/var/aniworld/cache
|
||||
|
||||
# Session
|
||||
JWT_SECRET_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(32))')
|
||||
SESSION_TIMEOUT_HOURS=24
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
CORS_ORIGINS=https://yourdomain.com
|
||||
|
||||
# SSL (if using HTTPS)
|
||||
SSL_KEYFILE=/path/to/key.pem
|
||||
SSL_CERTFILE=/path/to/cert.pem
|
||||
```
|
||||
|
||||
### 7. Create Required Directories
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/aniworld/{anime,downloads,cache}
|
||||
sudo chown -R aniworld:aniworld /var/aniworld
|
||||
sudo chmod -R 755 /var/aniworld
|
||||
```
|
||||
|
||||
### 8. Setup Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/aniworld.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Aniworld Download Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=aniworld
|
||||
WorkingDirectory=/home/aniworld/aniworld
|
||||
Environment="PATH=/home/aniworld/aniworld/venv/bin"
|
||||
ExecStart=/home/aniworld/aniworld/venv/bin/gunicorn \
|
||||
-w 4 \
|
||||
-k uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--timeout 120 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
src.server.fastapi_app:app
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 9. Enable and Start Service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable aniworld
|
||||
sudo systemctl start aniworld
|
||||
sudo systemctl status aniworld
|
||||
```
|
||||
|
||||
### 10. Setup Reverse Proxy (Nginx)
|
||||
|
||||
Create `/etc/nginx/sites-available/aniworld`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
# Redirect to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Proxy settings
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket settings
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable site:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/aniworld /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 11. Setup SSL with Let's Encrypt
|
||||
|
||||
```bash
|
||||
sudo apt-get install certbot python3-certbot-nginx
|
||||
sudo certbot certonly --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
### 12. Configure Firewall
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 80/tcp # HTTP
|
||||
sudo ufw allow 443/tcp # HTTPS
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### 1. Build Docker Image
|
||||
|
||||
Create `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
# Run application
|
||||
CMD ["uvicorn", "src.server.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build image:
|
||||
|
||||
```bash
|
||||
docker build -t aniworld:1.0.0 .
|
||||
```
|
||||
|
||||
### 2. Docker Compose
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
aniworld:
|
||||
image: aniworld:1.0.0
|
||||
container_name: aniworld
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /path/to/anime:/var/anime
|
||||
- /path/to/downloads:/var/downloads
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///./data/aniworld.db
|
||||
- ANIME_DIRECTORY=/var/anime
|
||||
- DOWNLOAD_DIRECTORY=/var/downloads
|
||||
- LOG_LEVEL=info
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aniworld-net
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: aniworld-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- aniworld
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aniworld-net
|
||||
|
||||
networks:
|
||||
aniworld-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 3. Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Core Settings**:
|
||||
|
||||
- `APP_NAME`: Application name
|
||||
- `APP_ENV`: Environment (development, production)
|
||||
- `DEBUG`: Enable debug mode
|
||||
- `LOG_LEVEL`: Logging level (debug, info, warning, error)
|
||||
|
||||
**Database**:
|
||||
|
||||
- `DATABASE_URL`: Database connection string
|
||||
- SQLite: `sqlite:///./data/aniworld.db`
|
||||
- PostgreSQL: `postgresql://user:pass@host:5432/dbname`
|
||||
|
||||
**Server**:
|
||||
|
||||
- `HOST`: Server bind address (0.0.0.0 for external access)
|
||||
- `PORT`: Server port
|
||||
- `WORKERS`: Number of worker processes
|
||||
|
||||
**Paths**:
|
||||
|
||||
- `ANIME_DIRECTORY`: Path to anime storage
|
||||
- `DOWNLOAD_DIRECTORY`: Path to download storage
|
||||
- `CACHE_DIRECTORY`: Temporary cache directory
|
||||
|
||||
**Security**:
|
||||
|
||||
- `JWT_SECRET_KEY`: JWT signing key
|
||||
- `SESSION_TIMEOUT_HOURS`: Session duration
|
||||
- `ALLOWED_HOSTS`: Allowed hostnames
|
||||
- `CORS_ORIGINS`: Allowed CORS origins
|
||||
|
||||
### Configuration File
|
||||
|
||||
Create `config.json` in data directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"anime_directory": "/path/to/anime",
|
||||
"download_directory": "/path/to/downloads",
|
||||
"cache_directory": "/path/to/cache",
|
||||
"session_timeout_hours": 24,
|
||||
"log_level": "info",
|
||||
"max_concurrent_downloads": 3,
|
||||
"retry_attempts": 3,
|
||||
"retry_delay_seconds": 60
|
||||
}
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
### SQLite (Development)
|
||||
|
||||
```bash
|
||||
# Automatically created on first run
|
||||
# Location: data/aniworld.db
|
||||
```
|
||||
|
||||
### PostgreSQL (Production)
|
||||
|
||||
**Install PostgreSQL**:
|
||||
|
||||
```bash
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
```
|
||||
|
||||
**Create Database**:
|
||||
|
||||
```bash
|
||||
sudo su - postgres
|
||||
createdb aniworld
|
||||
createuser aniworld_user
|
||||
psql -c "ALTER USER aniworld_user WITH PASSWORD 'password';"
|
||||
psql -c "GRANT ALL PRIVILEGES ON DATABASE aniworld TO aniworld_user;"
|
||||
exit
|
||||
```
|
||||
|
||||
**Update Connection String**:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://aniworld_user:password@localhost:5432/aniworld
|
||||
```
|
||||
|
||||
**Run Migrations** (if applicable):
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Access Control
|
||||
|
||||
1. **Master Password**: Use strong, complex password
|
||||
2. **User Permissions**: Run app with minimal required permissions
|
||||
3. **Firewall**: Restrict access to necessary ports only
|
||||
4. **SSL/TLS**: Always use HTTPS in production
|
||||
|
||||
### Data Protection
|
||||
|
||||
1. **Encryption**: Encrypt JWT secrets and sensitive data
|
||||
2. **Backups**: Regular automated backups
|
||||
3. **Audit Logging**: Enable comprehensive logging
|
||||
4. **Database**: Use PostgreSQL for better security than SQLite
|
||||
|
||||
### Network Security
|
||||
|
||||
1. **HTTPS**: Use SSL/TLS certificates
|
||||
2. **CORS**: Configure appropriate CORS origins
|
||||
3. **Rate Limiting**: Enable rate limiting on all endpoints
|
||||
4. **WAF**: Consider Web Application Firewall
|
||||
|
||||
### Secrets Management
|
||||
|
||||
1. **Environment Variables**: Use .env for secrets
|
||||
2. **Secret Store**: Use tools like HashiCorp Vault
|
||||
3. **Rotation**: Regularly rotate JWT secrets
|
||||
4. **Audit**: Monitor access to sensitive data
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Health Checks
|
||||
|
||||
**Basic Health**:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
**Detailed Health**:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health/detailed
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
**View Logs**:
|
||||
|
||||
```bash
|
||||
# Systemd
|
||||
sudo journalctl -u aniworld -f
|
||||
|
||||
# Docker
|
||||
docker logs -f aniworld
|
||||
|
||||
# Log file
|
||||
tail -f logs/app.log
|
||||
```
|
||||
|
||||
### Maintenance Tasks
|
||||
|
||||
**Daily**:
|
||||
|
||||
- Check disk space
|
||||
- Monitor error logs
|
||||
- Verify downloads completing
|
||||
|
||||
**Weekly**:
|
||||
|
||||
- Review system performance
|
||||
- Check for updates
|
||||
- Rotate old logs
|
||||
|
||||
**Monthly**:
|
||||
|
||||
- Full system backup
|
||||
- Database optimization
|
||||
- Security audit
|
||||
|
||||
### Updating Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
cd /home/aniworld/aniworld
|
||||
git pull origin main
|
||||
|
||||
# Update dependencies
|
||||
source venv/bin/activate
|
||||
pip install --upgrade -r requirements.txt
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart aniworld
|
||||
```
|
||||
|
||||
### Database Maintenance
|
||||
|
||||
```bash
|
||||
# PostgreSQL cleanup
|
||||
psql -d aniworld -c "VACUUM ANALYZE;"
|
||||
|
||||
# SQLite cleanup
|
||||
sqlite3 data/aniworld.db "VACUUM;"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Application Won't Start
|
||||
|
||||
**Check Logs**:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u aniworld -n 50
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- Port already in use: Change port or kill process
|
||||
- Database connection: Verify DATABASE_URL
|
||||
- File permissions: Check directory ownership
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Reduce worker processes
|
||||
- Check for memory leaks in logs
|
||||
- Restart application periodically
|
||||
- Monitor with `htop` or `top`
|
||||
|
||||
### Slow Performance
|
||||
|
||||
**Optimization**:
|
||||
|
||||
- Use PostgreSQL instead of SQLite
|
||||
- Increase worker processes
|
||||
- Add more RAM
|
||||
- Optimize database queries
|
||||
- Cache static files with CDN
|
||||
|
||||
### Downloads Failing
|
||||
|
||||
**Check**:
|
||||
|
||||
- Internet connection
|
||||
- Anime provider availability
|
||||
- Disk space on download directory
|
||||
- File permissions
|
||||
|
||||
**Debug**:
|
||||
|
||||
```bash
|
||||
curl -v http://provider-url/stream
|
||||
```
|
||||
|
||||
### SSL/TLS Issues
|
||||
|
||||
**Certificate Problems**:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
**Check Certificate**:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect yourdomain.com:443
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For additional help:
|
||||
|
||||
1. Check [User Guide](./user_guide.md)
|
||||
2. Review [API Reference](./api_reference.md)
|
||||
3. Check application logs
|
||||
4. File issue on GitHub
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: October 22, 2025
|
||||
**Version**: 1.0.0
|
||||
@ -1,485 +0,0 @@
|
||||
# Documentation and Error Handling Summary
|
||||
|
||||
**Project**: Aniworld Web Application
|
||||
**Generated**: October 23, 2025
|
||||
**Status**: ✅ Documentation Review Complete
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Comprehensive documentation and error handling review has been completed for the Aniworld project. This summary outlines the current state, achievements, and recommendations for completing the documentation tasks.
|
||||
|
||||
---
|
||||
|
||||
## Completed Tasks ✅
|
||||
|
||||
### 1. Frontend Integration Guide
|
||||
|
||||
**File Created**: `docs/frontend_integration.md`
|
||||
|
||||
Comprehensive guide covering:
|
||||
|
||||
- ✅ Frontend asset structure (templates, JavaScript, CSS)
|
||||
- ✅ API integration patterns and endpoints
|
||||
- ✅ WebSocket integration and event handling
|
||||
- ✅ Theme system (light/dark mode)
|
||||
- ✅ Authentication flow
|
||||
- ✅ Error handling patterns
|
||||
- ✅ Localization system
|
||||
- ✅ Accessibility features
|
||||
- ✅ Testing integration checklist
|
||||
|
||||
**Impact**: Provides complete reference for frontend-backend integration, ensuring consistency across the application.
|
||||
|
||||
### 2. Error Handling Validation Report
|
||||
|
||||
**File Created**: `docs/error_handling_validation.md`
|
||||
|
||||
Complete analysis covering:
|
||||
|
||||
- ✅ Exception hierarchy review
|
||||
- ✅ Middleware error handling validation
|
||||
- ✅ API endpoint error handling audit (all endpoints)
|
||||
- ✅ Response format consistency analysis
|
||||
- ✅ Logging standards review
|
||||
- ✅ Recommendations for improvements
|
||||
|
||||
**Key Findings**:
|
||||
|
||||
- Strong exception hierarchy with 11 custom exception classes
|
||||
- Comprehensive middleware error handling
|
||||
- Most endpoints have proper error handling
|
||||
- Analytics and backup endpoints need minor enhancements
|
||||
- Response format could be more consistent
|
||||
|
||||
---
|
||||
|
||||
## API Documentation Coverage Analysis
|
||||
|
||||
### Currently Documented Endpoints
|
||||
|
||||
**Authentication** (4/4 endpoints documented):
|
||||
|
||||
- ✅ POST `/api/auth/setup`
|
||||
- ✅ POST `/api/auth/login`
|
||||
- ✅ POST `/api/auth/logout`
|
||||
- ✅ GET `/api/auth/status`
|
||||
|
||||
**Configuration** (7/7 endpoints documented):
|
||||
|
||||
- ✅ GET `/api/config`
|
||||
- ✅ PUT `/api/config`
|
||||
- ✅ POST `/api/config/validate`
|
||||
- ✅ GET `/api/config/backups`
|
||||
- ✅ POST `/api/config/backups`
|
||||
- ✅ POST `/api/config/backups/{backup_name}/restore`
|
||||
- ✅ DELETE `/api/config/backups/{backup_name}`
|
||||
|
||||
**Anime** (4/4 endpoints documented):
|
||||
|
||||
- ✅ GET `/api/v1/anime`
|
||||
- ✅ GET `/api/v1/anime/{anime_id}`
|
||||
- ✅ POST `/api/v1/anime/rescan`
|
||||
- ✅ POST `/api/v1/anime/search`
|
||||
|
||||
**Download Queue** (Partially documented - 8/20 endpoints):
|
||||
|
||||
- ✅ GET `/api/queue/status`
|
||||
- ✅ POST `/api/queue/add`
|
||||
- ✅ DELETE `/api/queue/{item_id}`
|
||||
- ✅ POST `/api/queue/start`
|
||||
- ✅ POST `/api/queue/stop`
|
||||
- ✅ POST `/api/queue/pause`
|
||||
- ✅ POST `/api/queue/resume`
|
||||
- ✅ POST `/api/queue/reorder`
|
||||
|
||||
**WebSocket** (2/2 endpoints documented):
|
||||
|
||||
- ✅ WebSocket `/ws/connect`
|
||||
- ✅ GET `/ws/status`
|
||||
|
||||
**Health** (2/6 endpoints documented):
|
||||
|
||||
- ✅ GET `/health`
|
||||
- ✅ GET `/health/detailed`
|
||||
|
||||
### Undocumented Endpoints
|
||||
|
||||
#### Download Queue Endpoints (12 undocumented)
|
||||
|
||||
- ❌ DELETE `/api/queue/completed` - Clear completed downloads
|
||||
- ❌ DELETE `/api/queue/` - Clear entire queue
|
||||
- ❌ POST `/api/queue/control/start` - Alternative start endpoint
|
||||
- ❌ POST `/api/queue/control/stop` - Alternative stop endpoint
|
||||
- ❌ POST `/api/queue/control/pause` - Alternative pause endpoint
|
||||
- ❌ POST `/api/queue/control/resume` - Alternative resume endpoint
|
||||
- ❌ POST `/api/queue/control/clear_completed` - Clear completed via control
|
||||
- ❌ POST `/api/queue/retry` - Retry failed downloads
|
||||
|
||||
#### Health Endpoints (4 undocumented)
|
||||
|
||||
- ❌ GET `/health/metrics` - System metrics
|
||||
- ❌ GET `/health/metrics/prometheus` - Prometheus format metrics
|
||||
- ❌ GET `/health/metrics/json` - JSON format metrics
|
||||
|
||||
#### Maintenance Endpoints (16 undocumented)
|
||||
|
||||
- ❌ POST `/api/maintenance/cleanup` - Clean temporary files
|
||||
- ❌ GET `/api/maintenance/stats` - System statistics
|
||||
- ❌ POST `/api/maintenance/vacuum` - Database vacuum
|
||||
- ❌ POST `/api/maintenance/rebuild-index` - Rebuild search index
|
||||
- ❌ POST `/api/maintenance/prune-logs` - Prune old logs
|
||||
- ❌ GET `/api/maintenance/disk-usage` - Disk usage info
|
||||
- ❌ GET `/api/maintenance/processes` - Running processes
|
||||
- ❌ POST `/api/maintenance/health-check` - Run health check
|
||||
- ❌ GET `/api/maintenance/integrity/check` - Check integrity
|
||||
- ❌ POST `/api/maintenance/integrity/repair` - Repair integrity issues
|
||||
|
||||
#### Analytics Endpoints (5 undocumented)
|
||||
|
||||
- ❌ GET `/api/analytics/downloads` - Download statistics
|
||||
- ❌ GET `/api/analytics/series/popularity` - Series popularity
|
||||
- ❌ GET `/api/analytics/storage` - Storage analysis
|
||||
- ❌ GET `/api/analytics/performance` - Performance report
|
||||
- ❌ GET `/api/analytics/summary` - Summary report
|
||||
|
||||
#### Backup Endpoints (6 undocumented)
|
||||
|
||||
- ❌ POST `/api/backup/create` - Create backup
|
||||
- ❌ GET `/api/backup/list` - List backups
|
||||
- ❌ POST `/api/backup/restore` - Restore from backup
|
||||
- ❌ DELETE `/api/backup/{backup_name}` - Delete backup
|
||||
- ❌ POST `/api/backup/cleanup` - Cleanup old backups
|
||||
- ❌ POST `/api/backup/export/anime` - Export anime data
|
||||
- ❌ POST `/api/backup/import/anime` - Import anime data
|
||||
|
||||
**Total Undocumented**: 43 endpoints
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events Documentation
|
||||
|
||||
### Currently Documented Events
|
||||
|
||||
**Connection Events**:
|
||||
|
||||
- ✅ `connect` - Client connected
|
||||
- ✅ `disconnect` - Client disconnected
|
||||
- ✅ `connected` - Server confirmation
|
||||
|
||||
**Queue Events**:
|
||||
|
||||
- ✅ `queue_status` - Queue status update
|
||||
- ✅ `queue_updated` - Legacy queue update
|
||||
- ✅ `download_started` - Download started
|
||||
- ✅ `download_progress` - Progress update
|
||||
- ✅ `download_complete` - Download completed
|
||||
- ✅ `download_completed` - Legacy completion event
|
||||
- ✅ `download_failed` - Download failed
|
||||
- ✅ `download_error` - Legacy error event
|
||||
- ✅ `download_queue_completed` - All downloads complete
|
||||
- ✅ `download_stop_requested` - Queue stop requested
|
||||
|
||||
**Scan Events**:
|
||||
|
||||
- ✅ `scan_started` - Library scan started
|
||||
- ✅ `scan_progress` - Scan progress update
|
||||
- ✅ `scan_completed` - Scan completed
|
||||
- ✅ `scan_failed` - Scan failed
|
||||
|
||||
**Status**: WebSocket events are well-documented in `docs/frontend_integration.md`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Assets Integration Status
|
||||
|
||||
### Templates (5/5 reviewed)
|
||||
|
||||
- ✅ `index.html` - Main application interface
|
||||
- ✅ `queue.html` - Download queue management
|
||||
- ✅ `login.html` - Authentication page
|
||||
- ✅ `setup.html` - Initial setup page
|
||||
- ✅ `error.html` - Error display page
|
||||
|
||||
### JavaScript Files (16/16 cataloged)
|
||||
|
||||
**Core Files**:
|
||||
|
||||
- ✅ `app.js` (2086 lines) - Main application logic
|
||||
- ✅ `queue.js` (758 lines) - Queue management
|
||||
- ✅ `websocket_client.js` (234 lines) - WebSocket wrapper
|
||||
|
||||
**Feature Files** (13 files):
|
||||
|
||||
- ✅ All accessibility and UX enhancement files documented
|
||||
|
||||
### CSS Files (2/2 reviewed)
|
||||
|
||||
- ✅ `styles.css` - Main stylesheet
|
||||
- ✅ `ux_features.css` - UX enhancements
|
||||
|
||||
**Status**: All frontend assets cataloged and documented in `docs/frontend_integration.md`
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Status
|
||||
|
||||
### Exception Classes (11/11 implemented)
|
||||
|
||||
- ✅ `AniWorldAPIException` - Base exception
|
||||
- ✅ `AuthenticationError` - 401 errors
|
||||
- ✅ `AuthorizationError` - 403 errors
|
||||
- ✅ `ValidationError` - 422 errors
|
||||
- ✅ `NotFoundError` - 404 errors
|
||||
- ✅ `ConflictError` - 409 errors
|
||||
- ✅ `RateLimitError` - 429 errors
|
||||
- ✅ `ServerError` - 500 errors
|
||||
- ✅ `DownloadError` - Download failures
|
||||
- ✅ `ConfigurationError` - Config errors
|
||||
- ✅ `ProviderError` - Provider errors
|
||||
- ✅ `DatabaseError` - Database errors
|
||||
|
||||
### Middleware Error Handlers (Comprehensive)
|
||||
|
||||
- ✅ Global exception handlers registered for all exception types
|
||||
- ✅ Consistent error response format
|
||||
- ✅ Request ID support (partial implementation)
|
||||
- ✅ Structured logging in error handlers
|
||||
|
||||
### API Endpoint Error Handling
|
||||
|
||||
| API Module | Error Handling | Status |
|
||||
| ---------------- | -------------- | --------------------------------------------- |
|
||||
| `auth.py` | ✅ Excellent | Complete with proper status codes |
|
||||
| `anime.py` | ✅ Excellent | Comprehensive validation and error handling |
|
||||
| `download.py` | ✅ Excellent | Service exceptions properly handled |
|
||||
| `config.py` | ✅ Excellent | Validation and service errors separated |
|
||||
| `health.py` | ✅ Excellent | Graceful degradation |
|
||||
| `websocket.py` | ✅ Excellent | Proper cleanup and error messages |
|
||||
| `analytics.py` | ⚠️ Good | Needs explicit error handling in some methods |
|
||||
| `backup.py` | ✅ Good | Comprehensive with minor improvements needed |
|
||||
| `maintenance.py` | ✅ Excellent | All operations wrapped in try-catch |
|
||||
|
||||
---
|
||||
|
||||
## Theme Consistency
|
||||
|
||||
### Current Implementation
|
||||
|
||||
- ✅ Light/dark mode support via `data-theme` attribute
|
||||
- ✅ CSS custom properties for theming
|
||||
- ✅ Theme persistence in localStorage
|
||||
- ✅ Fluent UI design principles followed
|
||||
|
||||
### Fluent UI Compliance
|
||||
|
||||
- ✅ Rounded corners (4px border radius)
|
||||
- ✅ Subtle elevation shadows
|
||||
- ✅ Smooth transitions (200-300ms)
|
||||
- ✅ System font stack
|
||||
- ✅ 8px grid spacing system
|
||||
- ✅ Accessible color palette
|
||||
|
||||
**Status**: Theme implementation follows Fluent UI guidelines as specified in project standards.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations by Priority
|
||||
|
||||
### 🔴 Priority 1: Critical (Complete First)
|
||||
|
||||
1. **Document Missing API Endpoints** (43 endpoints)
|
||||
|
||||
- Create comprehensive documentation for all undocumented endpoints
|
||||
- Include request/response examples
|
||||
- Document error codes and scenarios
|
||||
- Add authentication requirements
|
||||
|
||||
2. **Enhance Analytics Error Handling**
|
||||
|
||||
- Add explicit try-catch blocks to all analytics methods
|
||||
- Implement proper error logging
|
||||
- Return meaningful error messages
|
||||
|
||||
3. **Standardize Response Formats**
|
||||
- Use consistent `{success, data, message}` format
|
||||
- Update all endpoints to follow standard
|
||||
- Document response format specification
|
||||
|
||||
### 🟡 Priority 2: Important (Complete Soon)
|
||||
|
||||
4. **Implement Request ID Tracking**
|
||||
|
||||
- Generate unique request IDs for all API calls
|
||||
- Include in all log messages
|
||||
- Return in all responses (success and error)
|
||||
|
||||
5. **Complete WebSocket Documentation**
|
||||
|
||||
- Document room subscription mechanism
|
||||
- Add more event examples
|
||||
- Document error scenarios
|
||||
|
||||
6. **Migrate to Structured Logging**
|
||||
- Replace `logging` with `structlog` everywhere
|
||||
- Add structured fields to all log messages
|
||||
- Include request context
|
||||
|
||||
### 🟢 Priority 3: Enhancement (Future)
|
||||
|
||||
7. **Create API Versioning Guide**
|
||||
|
||||
- Document versioning strategy
|
||||
- Add deprecation policy
|
||||
- Create changelog template
|
||||
|
||||
8. **Add OpenAPI Schema Enhancements**
|
||||
|
||||
- Add more detailed descriptions
|
||||
- Include comprehensive examples
|
||||
- Document edge cases
|
||||
|
||||
9. **Create Troubleshooting Guide**
|
||||
- Common error scenarios
|
||||
- Debugging techniques
|
||||
- FAQ for API consumers
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files Created
|
||||
|
||||
1. **`docs/frontend_integration.md`** (New)
|
||||
|
||||
- Complete frontend integration guide
|
||||
- API integration patterns
|
||||
- WebSocket event documentation
|
||||
- Authentication flow
|
||||
- Theme system
|
||||
- Testing checklist
|
||||
|
||||
2. **`docs/error_handling_validation.md`** (New)
|
||||
|
||||
- Exception hierarchy review
|
||||
- Middleware validation
|
||||
- API endpoint audit
|
||||
- Response format analysis
|
||||
- Logging standards
|
||||
- Recommendations
|
||||
|
||||
3. **`docs/api_reference.md`** (Existing - Needs Update)
|
||||
|
||||
- Currently documents ~29 endpoints
|
||||
- Needs 43 additional endpoints documented
|
||||
- WebSocket events well documented
|
||||
- Error handling documented
|
||||
|
||||
4. **`docs/README.md`** (Existing - Up to Date)
|
||||
- Documentation overview
|
||||
- Navigation guide
|
||||
- Quick start links
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Frontend Integration Testing
|
||||
|
||||
- [ ] Verify all API endpoints return expected format
|
||||
- [ ] Test WebSocket reconnection logic
|
||||
- [ ] Validate theme persistence across sessions
|
||||
- [ ] Test authentication flow end-to-end
|
||||
- [ ] Verify error handling displays correctly
|
||||
|
||||
### API Documentation Testing
|
||||
|
||||
- [ ] Test all documented endpoints with examples
|
||||
- [ ] Verify error responses match documentation
|
||||
- [ ] Test rate limiting behavior
|
||||
- [ ] Validate pagination on list endpoints
|
||||
- [ ] Test authentication on protected endpoints
|
||||
|
||||
### Error Handling Testing
|
||||
|
||||
- [ ] Trigger each exception type and verify response
|
||||
- [ ] Test error logging output
|
||||
- [ ] Verify request ID tracking
|
||||
- [ ] Test graceful degradation scenarios
|
||||
- [ ] Validate error messages are user-friendly
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
### Documentation Coverage
|
||||
|
||||
- **Endpoints Documented**: 29/72 (40%)
|
||||
- **WebSocket Events Documented**: 14/14 (100%)
|
||||
- **Frontend Assets Documented**: 21/21 (100%)
|
||||
- **Error Classes Documented**: 11/11 (100%)
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Exception Handling**: 95% (Excellent)
|
||||
- **Type Hints Coverage**: ~85% (Very Good)
|
||||
- **Docstring Coverage**: ~80% (Good)
|
||||
- **Logging Coverage**: ~90% (Excellent)
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Unit Tests**: Extensive (per QualityTODO.md)
|
||||
- **Integration Tests**: Comprehensive
|
||||
- **Frontend Tests**: Documented in integration guide
|
||||
- **Error Handling Tests**: Recommended in validation report
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. ✅ Complete this summary document
|
||||
2. ⏭️ Document missing API endpoints in `api_reference.md`
|
||||
3. ⏭️ Enhance analytics endpoint error handling
|
||||
4. ⏭️ Implement request ID tracking
|
||||
5. ⏭️ Standardize response format across all endpoints
|
||||
|
||||
### Short-term Actions (This Week)
|
||||
|
||||
6. ⏭️ Complete WebSocket documentation updates
|
||||
7. ⏭️ Migrate all modules to structured logging
|
||||
8. ⏭️ Update frontend JavaScript to match documented API
|
||||
9. ⏭️ Create testing scripts for all endpoints
|
||||
10. ⏭️ Update README with new documentation links
|
||||
|
||||
### Long-term Actions (This Month)
|
||||
|
||||
11. ⏭️ Create troubleshooting guide
|
||||
12. ⏭️ Add API versioning documentation
|
||||
13. ⏭️ Enhance OpenAPI schema
|
||||
14. ⏭️ Create video tutorials for API usage
|
||||
15. ⏭️ Set up documentation auto-generation
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Aniworld project demonstrates **strong documentation and error handling foundations** with:
|
||||
|
||||
✅ Comprehensive exception hierarchy
|
||||
✅ Well-documented frontend integration
|
||||
✅ Thorough error handling validation
|
||||
✅ Extensive WebSocket event documentation
|
||||
✅ Complete frontend asset catalog
|
||||
|
||||
**Key Achievement**: Created two major documentation files providing complete reference for frontend integration and error handling validation.
|
||||
|
||||
**Main Gap**: 43 API endpoints need documentation (60% of total endpoints).
|
||||
|
||||
**Recommended Focus**: Complete API endpoint documentation and implement request ID tracking to achieve comprehensive documentation coverage.
|
||||
|
||||
---
|
||||
|
||||
**Document Author**: AI Agent
|
||||
**Review Status**: Complete
|
||||
**Last Updated**: October 23, 2025
|
||||
@ -1,861 +0,0 @@
|
||||
# Error Handling Validation Report
|
||||
|
||||
Complete validation of error handling implementation across the Aniworld API.
|
||||
|
||||
**Generated**: October 23, 2025
|
||||
**Status**: ✅ COMPREHENSIVE ERROR HANDLING IMPLEMENTED
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#executive-summary)
|
||||
2. [Exception Hierarchy](#exception-hierarchy)
|
||||
3. [Middleware Error Handling](#middleware-error-handling)
|
||||
4. [API Endpoint Error Handling](#api-endpoint-error-handling)
|
||||
5. [Response Format Consistency](#response-format-consistency)
|
||||
6. [Logging Standards](#logging-standards)
|
||||
7. [Validation Summary](#validation-summary)
|
||||
8. [Recommendations](#recommendations)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Aniworld API demonstrates **excellent error handling implementation** with:
|
||||
|
||||
✅ **Custom exception hierarchy** with proper HTTP status code mapping
|
||||
✅ **Centralized error handling middleware** for consistent responses
|
||||
✅ **Comprehensive exception handling** in all API endpoints
|
||||
✅ **Structured logging** with appropriate log levels
|
||||
✅ **Input validation** with meaningful error messages
|
||||
✅ **Type hints and docstrings** throughout codebase
|
||||
|
||||
### Key Strengths
|
||||
|
||||
1. **Well-designed exception hierarchy** (`src/server/exceptions/__init__.py`)
|
||||
2. **Global exception handlers** registered in middleware
|
||||
3. **Consistent error response format** across all endpoints
|
||||
4. **Proper HTTP status codes** for different error scenarios
|
||||
5. **Defensive programming** with try-catch blocks
|
||||
6. **Custom error details** for debugging and troubleshooting
|
||||
|
||||
### Areas for Enhancement
|
||||
|
||||
1. Request ID tracking for distributed tracing
|
||||
2. Error rate monitoring and alerting
|
||||
3. Structured error logs for aggregation
|
||||
4. Client-friendly error messages in some endpoints
|
||||
|
||||
---
|
||||
|
||||
## Exception Hierarchy
|
||||
|
||||
### Base Exception Class
|
||||
|
||||
**Location**: `src/server/exceptions/__init__.py`
|
||||
|
||||
```python
|
||||
class AniWorldAPIException(Exception):
|
||||
"""Base exception for Aniworld API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert exception to dictionary for JSON response."""
|
||||
return {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Exception Classes
|
||||
|
||||
| Exception Class | Status Code | Error Code | Usage |
|
||||
| --------------------- | ----------- | ----------------------- | ------------------------- |
|
||||
| `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` | Failed authentication |
|
||||
| `AuthorizationError` | 403 | `AUTHORIZATION_ERROR` | Insufficient permissions |
|
||||
| `ValidationError` | 422 | `VALIDATION_ERROR` | Request validation failed |
|
||||
| `NotFoundError` | 404 | `NOT_FOUND` | Resource not found |
|
||||
| `ConflictError` | 409 | `CONFLICT` | Resource conflict |
|
||||
| `RateLimitError` | 429 | `RATE_LIMIT_EXCEEDED` | Rate limit exceeded |
|
||||
| `ServerError` | 500 | `INTERNAL_SERVER_ERROR` | Unexpected server error |
|
||||
| `DownloadError` | 500 | `DOWNLOAD_ERROR` | Download operation failed |
|
||||
| `ConfigurationError` | 500 | `CONFIGURATION_ERROR` | Configuration error |
|
||||
| `ProviderError` | 500 | `PROVIDER_ERROR` | Provider error |
|
||||
| `DatabaseError` | 500 | `DATABASE_ERROR` | Database operation failed |
|
||||
|
||||
**Status**: ✅ Complete and well-structured
|
||||
|
||||
---
|
||||
|
||||
## Middleware Error Handling
|
||||
|
||||
### Global Exception Handlers
|
||||
|
||||
**Location**: `src/server/middleware/error_handler.py`
|
||||
|
||||
The application registers global exception handlers for all custom exception classes:
|
||||
|
||||
```python
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""Register all exception handlers with FastAPI app."""
|
||||
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error_handler(
|
||||
request: Request, exc: AuthenticationError
|
||||
) -> JSONResponse:
|
||||
"""Handle authentication errors (401)."""
|
||||
logger.warning(
|
||||
f"Authentication error: {exc.message}",
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=create_error_response(
|
||||
status_code=exc.status_code,
|
||||
error=exc.error_code,
|
||||
message=exc.message,
|
||||
details=exc.details,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
),
|
||||
)
|
||||
|
||||
# ... similar handlers for all exception types
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
|
||||
All errors return a consistent JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "ERROR_CODE",
|
||||
"message": "Human-readable error message",
|
||||
"details": {
|
||||
"field": "specific_field",
|
||||
"reason": "error_reason"
|
||||
},
|
||||
"request_id": "uuid-request-identifier"
|
||||
}
|
||||
```
|
||||
|
||||
**Status**: ✅ Comprehensive and consistent
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint Error Handling
|
||||
|
||||
### Authentication Endpoints (`/api/auth`)
|
||||
|
||||
**File**: `src/server/api/auth.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Setup endpoint**: Checks if master password already configured
|
||||
- **Login endpoint**: Handles lockout errors (429) and authentication failures (401)
|
||||
- **Proper exception mapping**: `LockedOutError` → 429, `AuthError` → 400
|
||||
- **Token validation**: Graceful handling of invalid tokens
|
||||
|
||||
```python
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(req: LoginRequest):
|
||||
"""Validate master password and return JWT token."""
|
||||
identifier = "global"
|
||||
|
||||
try:
|
||||
valid = auth_service.validate_master_password(
|
||||
req.password, identifier=identifier
|
||||
)
|
||||
except LockedOutError as e:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
```
|
||||
|
||||
#### Recommendations
|
||||
|
||||
- ✓ Add structured logging for failed login attempts
|
||||
- ✓ Include request_id in error responses
|
||||
- ✓ Consider adding more detailed error messages for debugging
|
||||
|
||||
---
|
||||
|
||||
### Anime Endpoints (`/api/v1/anime`)
|
||||
|
||||
**File**: `src/server/api/anime.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Comprehensive try-catch blocks** around all operations
|
||||
- **Re-raising HTTPExceptions** to preserve status codes
|
||||
- **Generic 500 errors** for unexpected failures
|
||||
- **Input validation** with Pydantic models and custom validators
|
||||
|
||||
```python
|
||||
@router.get("/", response_model=List[AnimeSummary])
|
||||
async def list_anime(
|
||||
_auth: dict = Depends(require_auth),
|
||||
series_app: Any = Depends(get_series_app),
|
||||
) -> List[AnimeSummary]:
|
||||
"""List library series that still have missing episodes."""
|
||||
try:
|
||||
series = series_app.List.GetMissingEpisode()
|
||||
summaries: List[AnimeSummary] = []
|
||||
# ... processing logic
|
||||
return summaries
|
||||
except HTTPException:
|
||||
raise # Preserve status code
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve anime list",
|
||||
) from exc
|
||||
```
|
||||
|
||||
#### ✅ Advanced Input Validation
|
||||
|
||||
The search endpoint includes comprehensive input validation:
|
||||
|
||||
```python
|
||||
class SearchRequest(BaseModel):
|
||||
"""Request model for anime search with validation."""
|
||||
|
||||
query: str
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def validate_query(cls, v: str) -> str:
|
||||
"""Validate and sanitize search query."""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Search query cannot be empty")
|
||||
|
||||
# Limit query length to prevent abuse
|
||||
if len(v) > 200:
|
||||
raise ValueError("Search query too long (max 200 characters)")
|
||||
|
||||
# Strip and normalize whitespace
|
||||
normalized = " ".join(v.strip().split())
|
||||
|
||||
# Prevent SQL-like injection patterns
|
||||
dangerous_patterns = [
|
||||
"--", "/*", "*/", "xp_", "sp_", "exec", "execute"
|
||||
]
|
||||
lower_query = normalized.lower()
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern in lower_query:
|
||||
raise ValueError(f"Invalid character sequence: {pattern}")
|
||||
|
||||
return normalized
|
||||
```
|
||||
|
||||
**Status**: ✅ Excellent validation and security
|
||||
|
||||
---
|
||||
|
||||
### Download Queue Endpoints (`/api/queue`)
|
||||
|
||||
**File**: `src/server/api/download.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Comprehensive error handling** in all endpoints
|
||||
- **Custom service exceptions** (`DownloadServiceError`)
|
||||
- **Input validation** for queue operations
|
||||
- **Detailed error messages** with context
|
||||
|
||||
```python
|
||||
@router.post("/add", status_code=status.HTTP_201_CREATED)
|
||||
async def add_to_queue(
|
||||
request: DownloadRequest,
|
||||
_: dict = Depends(require_auth),
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
):
|
||||
"""Add episodes to the download queue."""
|
||||
try:
|
||||
# Validate request
|
||||
if not request.episodes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one episode must be specified",
|
||||
)
|
||||
|
||||
# Add to queue
|
||||
added_ids = await download_service.add_to_queue(
|
||||
serie_id=request.serie_id,
|
||||
serie_name=request.serie_name,
|
||||
episodes=request.episodes,
|
||||
priority=request.priority,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Added {len(added_ids)} episode(s) to download queue",
|
||||
"added_items": added_ids,
|
||||
}
|
||||
except DownloadServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add episodes to queue: {str(e)}",
|
||||
) from e
|
||||
```
|
||||
|
||||
**Status**: ✅ Robust error handling
|
||||
|
||||
---
|
||||
|
||||
### Configuration Endpoints (`/api/config`)
|
||||
|
||||
**File**: `src/server/api/config.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Service-specific exceptions** (`ConfigServiceError`, `ConfigValidationError`, `ConfigBackupError`)
|
||||
- **Proper status code mapping** (400 for validation, 404 for missing backups, 500 for service errors)
|
||||
- **Detailed error context** in exception messages
|
||||
|
||||
```python
|
||||
@router.put("", response_model=AppConfig)
|
||||
def update_config(
|
||||
update: ConfigUpdate, auth: dict = Depends(require_auth)
|
||||
) -> AppConfig:
|
||||
"""Apply an update to the configuration and persist it."""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
return config_service.update_config(update)
|
||||
except ConfigValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid configuration: {e}"
|
||||
) from e
|
||||
except ConfigServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update config: {e}"
|
||||
) from e
|
||||
```
|
||||
|
||||
**Status**: ✅ Excellent separation of validation and service errors
|
||||
|
||||
---
|
||||
|
||||
### Health Check Endpoints (`/health`)
|
||||
|
||||
**File**: `src/server/api/health.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Graceful degradation** - returns partial health status even if some checks fail
|
||||
- **Detailed error logging** for diagnostic purposes
|
||||
- **Structured health responses** with status indicators
|
||||
- **No exceptions thrown to client** - health checks always return 200
|
||||
|
||||
```python
|
||||
async def check_database_health(db: AsyncSession) -> DatabaseHealth:
|
||||
"""Check database connection and performance."""
|
||||
try:
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
await db.execute(text("SELECT 1"))
|
||||
connection_time = (time.time() - start_time) * 1000
|
||||
|
||||
return DatabaseHealth(
|
||||
status="healthy",
|
||||
connection_time_ms=connection_time,
|
||||
message="Database connection successful",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
return DatabaseHealth(
|
||||
status="unhealthy",
|
||||
connection_time_ms=0,
|
||||
message=f"Database connection failed: {str(e)}",
|
||||
)
|
||||
```
|
||||
|
||||
**Status**: ✅ Excellent resilience for monitoring endpoints
|
||||
|
||||
---
|
||||
|
||||
### WebSocket Endpoints (`/ws`)
|
||||
|
||||
**File**: `src/server/api/websocket.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Connection error handling** with proper disconnect cleanup
|
||||
- **Message parsing errors** sent back to client
|
||||
- **Structured error messages** via WebSocket protocol
|
||||
- **Comprehensive logging** for debugging
|
||||
|
||||
```python
|
||||
@router.websocket("/connect")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
ws_service: WebSocketService = Depends(get_websocket_service),
|
||||
user_id: Optional[str] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""WebSocket endpoint for client connections."""
|
||||
connection_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
await ws_service.connect(websocket, connection_id, user_id=user_id)
|
||||
|
||||
# ... connection handling
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
try:
|
||||
client_msg = ClientMessage(**data)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Invalid client message format",
|
||||
connection_id=connection_id,
|
||||
error=str(e),
|
||||
)
|
||||
await ws_service.send_error(
|
||||
connection_id,
|
||||
"Invalid message format",
|
||||
"INVALID_MESSAGE",
|
||||
)
|
||||
continue
|
||||
|
||||
# ... message handling
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Client disconnected", connection_id=connection_id)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error processing WebSocket message",
|
||||
connection_id=connection_id,
|
||||
error=str(e),
|
||||
)
|
||||
await ws_service.send_error(
|
||||
connection_id,
|
||||
"Internal server error",
|
||||
"INTERNAL_ERROR",
|
||||
)
|
||||
finally:
|
||||
await ws_service.disconnect(connection_id)
|
||||
logger.info("WebSocket connection closed", connection_id=connection_id)
|
||||
```
|
||||
|
||||
**Status**: ✅ Excellent WebSocket error handling with proper cleanup
|
||||
|
||||
---
|
||||
|
||||
### Analytics Endpoints (`/api/analytics`)
|
||||
|
||||
**File**: `src/server/api/analytics.py`
|
||||
|
||||
#### ⚠️ Error Handling Observations
|
||||
|
||||
- ✅ Pydantic models for response validation
|
||||
- ⚠️ **Missing explicit error handling** in some endpoints
|
||||
- ⚠️ Database session handling could be improved
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Add try-catch blocks to all analytics endpoints:
|
||||
|
||||
```python
|
||||
@router.get("/downloads", response_model=DownloadStatsResponse)
|
||||
async def get_download_statistics(
|
||||
days: int = 30,
|
||||
db: AsyncSession = None,
|
||||
) -> DownloadStatsResponse:
|
||||
"""Get download statistics for specified period."""
|
||||
try:
|
||||
if db is None:
|
||||
db = await get_db().__anext__()
|
||||
|
||||
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,
|
||||
# ... rest of response
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get download statistics: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve download statistics: {str(e)}",
|
||||
) from e
|
||||
```
|
||||
|
||||
**Status**: ⚠️ Needs enhancement
|
||||
|
||||
---
|
||||
|
||||
### Backup Endpoints (`/api/backup`)
|
||||
|
||||
**File**: `src/server/api/backup.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Custom exception handling** in create_backup endpoint
|
||||
- **ValueError handling** for invalid backup types
|
||||
- **Comprehensive logging** for all operations
|
||||
|
||||
#### ⚠️ Observations
|
||||
|
||||
Some endpoints may not have explicit error handling:
|
||||
|
||||
```python
|
||||
@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."""
|
||||
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}")
|
||||
|
||||
# ... rest of logic
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Backup creation failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create backup: {str(e)}",
|
||||
) from e
|
||||
```
|
||||
|
||||
**Status**: ✅ Good error handling with minor improvements possible
|
||||
|
||||
---
|
||||
|
||||
### Maintenance Endpoints (`/api/maintenance`)
|
||||
|
||||
**File**: `src/server/api/maintenance.py`
|
||||
|
||||
#### ✅ Error Handling Strengths
|
||||
|
||||
- **Comprehensive try-catch blocks** in all endpoints
|
||||
- **Detailed error logging** for troubleshooting
|
||||
- **Proper HTTP status codes** (500 for failures)
|
||||
- **Graceful degradation** where possible
|
||||
|
||||
```python
|
||||
@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."""
|
||||
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))
|
||||
```
|
||||
|
||||
**Status**: ✅ Excellent error handling
|
||||
|
||||
---
|
||||
|
||||
## Response Format Consistency
|
||||
|
||||
### Current Response Formats
|
||||
|
||||
The API uses **multiple response formats** depending on the endpoint:
|
||||
|
||||
#### Format 1: Success/Data Pattern (Most Common)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"message": "Optional message"
|
||||
}
|
||||
```
|
||||
|
||||
#### Format 2: Status/Message Pattern
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "Operation completed"
|
||||
}
|
||||
```
|
||||
|
||||
#### Format 3: Direct Data Return
|
||||
|
||||
```json
|
||||
{
|
||||
"field1": "value1",
|
||||
"field2": "value2"
|
||||
}
|
||||
```
|
||||
|
||||
#### Format 4: Error Response (Standardized)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "ERROR_CODE",
|
||||
"message": "Human-readable message",
|
||||
"details": { ... },
|
||||
"request_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ Consistency Recommendation
|
||||
|
||||
While error responses are highly consistent (Format 4), **success responses vary** between formats 1, 2, and 3.
|
||||
|
||||
#### Recommended Standard Format
|
||||
|
||||
```json
|
||||
// Success
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"message": "Optional success message"
|
||||
}
|
||||
|
||||
// Error
|
||||
{
|
||||
"success": false,
|
||||
"error": "ERROR_CODE",
|
||||
"message": "Error description",
|
||||
"details": { ... },
|
||||
"request_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Action Item**: Consider standardizing all success responses to Format 1 for consistency with error responses.
|
||||
|
||||
---
|
||||
|
||||
## Logging Standards
|
||||
|
||||
### Current Logging Implementation
|
||||
|
||||
#### ✅ Strengths
|
||||
|
||||
1. **Structured logging** with `structlog` in WebSocket module
|
||||
2. **Appropriate log levels**: INFO, WARNING, ERROR
|
||||
3. **Contextual information** in log messages
|
||||
4. **Extra fields** for better filtering
|
||||
|
||||
#### ⚠️ Areas for Improvement
|
||||
|
||||
1. **Inconsistent logging libraries**: Some modules use `logging`, others use `structlog`
|
||||
2. **Missing request IDs** in some log messages
|
||||
3. **Incomplete correlation** between logs and errors
|
||||
|
||||
### Recommended Logging Pattern
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@router.post("/endpoint")
|
||||
async def endpoint(request: Request, data: RequestModel):
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
|
||||
logger.info(
|
||||
"Processing request",
|
||||
request_id=request_id,
|
||||
endpoint="/endpoint",
|
||||
method="POST",
|
||||
user_id=getattr(request.state, "user_id", None),
|
||||
)
|
||||
|
||||
try:
|
||||
# ... processing logic
|
||||
|
||||
logger.info(
|
||||
"Request completed successfully",
|
||||
request_id=request_id,
|
||||
duration_ms=elapsed_time,
|
||||
)
|
||||
|
||||
return {"success": True, "data": result}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Request failed",
|
||||
request_id=request_id,
|
||||
error=str(e),
|
||||
error_type=type(e).__name__,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Summary
|
||||
|
||||
### ✅ Excellent Implementation
|
||||
|
||||
| Category | Status | Notes |
|
||||
| ------------------------ | ------------ | ------------------------------------------- |
|
||||
| Exception Hierarchy | ✅ Excellent | Well-structured, comprehensive |
|
||||
| Global Error Handlers | ✅ Excellent | Registered for all exception types |
|
||||
| Authentication Endpoints | ✅ Good | Proper status codes, could add more logging |
|
||||
| Anime Endpoints | ✅ Excellent | Input validation, security checks |
|
||||
| Download Endpoints | ✅ Excellent | Comprehensive error handling |
|
||||
| Config Endpoints | ✅ Excellent | Service-specific exceptions |
|
||||
| Health Endpoints | ✅ Excellent | Graceful degradation |
|
||||
| WebSocket Endpoints | ✅ Excellent | Proper cleanup, structured errors |
|
||||
| Maintenance Endpoints | ✅ Excellent | Comprehensive try-catch blocks |
|
||||
|
||||
### ⚠️ Needs Enhancement
|
||||
|
||||
| Category | Status | Issue | Priority |
|
||||
| --------------------------- | ----------- | ------------------------------------------- | -------- |
|
||||
| Analytics Endpoints | ⚠️ Fair | Missing error handling in some methods | Medium |
|
||||
| Backup Endpoints | ⚠️ Good | Could use more comprehensive error handling | Low |
|
||||
| Response Format Consistency | ⚠️ Moderate | Multiple success response formats | Medium |
|
||||
| Logging Consistency | ⚠️ Moderate | Mixed use of logging vs structlog | Low |
|
||||
| Request ID Tracking | ⚠️ Missing | Not consistently implemented | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Priority 1: Critical (Implement Soon)
|
||||
|
||||
1. **Add comprehensive error handling to analytics endpoints**
|
||||
|
||||
- Wrap all database operations in try-catch
|
||||
- Return meaningful error messages
|
||||
- Log all failures with context
|
||||
|
||||
2. **Implement request ID tracking**
|
||||
|
||||
- Generate unique request ID for each API call
|
||||
- Include in all log messages
|
||||
- Return in error responses
|
||||
- Enable distributed tracing
|
||||
|
||||
3. **Standardize success response format**
|
||||
- Use consistent `{success, data, message}` format
|
||||
- Update all endpoints to use standard format
|
||||
- Update frontend to expect standard format
|
||||
|
||||
### Priority 2: Important (Implement This Quarter)
|
||||
|
||||
4. **Migrate to structured logging everywhere**
|
||||
|
||||
- Replace all `logging` with `structlog`
|
||||
- Add structured fields to all log messages
|
||||
- Include request context in all logs
|
||||
|
||||
5. **Add error rate monitoring**
|
||||
|
||||
- Track error rates by endpoint
|
||||
- Alert on unusual error patterns
|
||||
- Dashboard for error trends
|
||||
|
||||
6. **Enhance error messages**
|
||||
- More descriptive error messages for users
|
||||
- Technical details only in `details` field
|
||||
- Actionable guidance where possible
|
||||
|
||||
### Priority 3: Nice to Have (Future Enhancement)
|
||||
|
||||
7. **Implement retry logic for transient failures**
|
||||
|
||||
- Automatic retries for database operations
|
||||
- Exponential backoff for external APIs
|
||||
- Circuit breaker pattern for providers
|
||||
|
||||
8. **Add error aggregation and reporting**
|
||||
|
||||
- Centralized error tracking (e.g., Sentry)
|
||||
- Error grouping and deduplication
|
||||
- Automatic issue creation for critical errors
|
||||
|
||||
9. **Create error documentation**
|
||||
- Comprehensive error code reference
|
||||
- Troubleshooting guide for common errors
|
||||
- Examples of error responses
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Aniworld API demonstrates **strong error handling practices** with:
|
||||
|
||||
✅ Well-designed exception hierarchy
|
||||
✅ Comprehensive middleware error handling
|
||||
✅ Proper HTTP status code usage
|
||||
✅ Input validation and sanitization
|
||||
✅ Defensive programming throughout
|
||||
|
||||
With the recommended enhancements, particularly around analytics endpoints, response format standardization, and request ID tracking, the error handling implementation will be **world-class**.
|
||||
|
||||
---
|
||||
|
||||
**Report Author**: AI Agent
|
||||
**Last Updated**: October 23, 2025
|
||||
**Version**: 1.0
|
||||
@ -1,174 +0,0 @@
|
||||
# Frontend-Backend Integration Summary
|
||||
|
||||
**Date:** October 24, 2025
|
||||
**Status:** Core integration completed
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully integrated the existing frontend JavaScript application with the new FastAPI backend by creating missing API endpoints and updating frontend API calls to match the new endpoint structure.
|
||||
|
||||
## Completed Work
|
||||
|
||||
### 1. Created Missing API Endpoints
|
||||
|
||||
Added the following endpoints to `/src/server/api/anime.py`:
|
||||
|
||||
#### `/api/v1/anime/status` (GET)
|
||||
|
||||
- Returns anime library status information
|
||||
- Response includes:
|
||||
- `directory`: Configured anime directory path
|
||||
- `series_count`: Number of series in the library
|
||||
- Used by frontend configuration modal to display current settings
|
||||
|
||||
#### `/api/v1/anime/add` (POST)
|
||||
|
||||
- Adds a new series to the library from search results
|
||||
- Request body: `{link: string, name: string}`
|
||||
- Validates input and calls `SeriesApp.AddSeries()` method
|
||||
- Returns success/error message
|
||||
|
||||
#### `/api/v1/anime/download` (POST)
|
||||
|
||||
- Starts downloading missing episodes from selected folders
|
||||
- Request body: `{folders: string[]}`
|
||||
- Calls `SeriesApp.Download()` with folder list
|
||||
- Used when user selects multiple series and clicks download
|
||||
|
||||
### 2. Updated Frontend API Calls
|
||||
|
||||
Modified `/src/server/web/static/js/app.js` to use correct endpoint paths:
|
||||
|
||||
| Old Path | New Path | Purpose |
|
||||
| ----------------- | ------------------------ | ------------------------- |
|
||||
| `/api/add_series` | `/api/v1/anime/add` | Add new series |
|
||||
| `/api/download` | `/api/v1/anime/download` | Download selected folders |
|
||||
| `/api/status` | `/api/v1/anime/status` | Get library status |
|
||||
|
||||
### 3. Verified Existing Endpoints
|
||||
|
||||
Confirmed the following endpoints are already correctly implemented:
|
||||
|
||||
- `/api/auth/status` - Authentication status check
|
||||
- `/api/auth/logout` - User logout
|
||||
- `/api/v1/anime` - List anime with missing episodes
|
||||
- `/api/v1/anime/search` - Search for anime
|
||||
- `/api/v1/anime/rescan` - Trigger library rescan
|
||||
- `/api/v1/anime/{anime_id}` - Get anime details
|
||||
- `/api/queue/*` - Download queue management
|
||||
- `/api/config/*` - Configuration management
|
||||
|
||||
## Request/Response Models
|
||||
|
||||
### AddSeriesRequest
|
||||
|
||||
```python
|
||||
class AddSeriesRequest(BaseModel):
|
||||
link: str # Series URL/link
|
||||
name: str # Series name
|
||||
```
|
||||
|
||||
### DownloadFoldersRequest
|
||||
|
||||
```python
|
||||
class DownloadFoldersRequest(BaseModel):
|
||||
folders: List[str] # List of folder names to download
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- All existing tests passing
|
||||
- Integration tested with frontend JavaScript
|
||||
- Endpoints follow existing patterns and conventions
|
||||
- Proper error handling and validation in place
|
||||
|
||||
## Remaining Work
|
||||
|
||||
The following endpoints are referenced in the frontend but not yet implemented:
|
||||
|
||||
### Scheduler API (`/api/scheduler/`)
|
||||
|
||||
- `/api/scheduler/config` (GET/POST) - Get/update scheduler configuration
|
||||
- `/api/scheduler/trigger-rescan` (POST) - Manually trigger scheduled rescan
|
||||
|
||||
### Logging API (`/api/logging/`)
|
||||
|
||||
- `/api/logging/config` (GET/POST) - Get/update logging configuration
|
||||
- `/api/logging/files` (GET) - List log files
|
||||
- `/api/logging/files/{filename}/download` (GET) - Download log file
|
||||
- `/api/logging/files/{filename}/tail` (GET) - Tail log file
|
||||
- `/api/logging/test` (POST) - Test logging configuration
|
||||
- `/api/logging/cleanup` (POST) - Clean up old log files
|
||||
|
||||
### Diagnostics API (`/api/diagnostics/`)
|
||||
|
||||
- `/api/diagnostics/network` (GET) - Network diagnostics
|
||||
|
||||
### Config API Extensions
|
||||
|
||||
The following config endpoints may need verification or implementation:
|
||||
|
||||
- `/api/config/section/advanced` (GET/POST) - Advanced configuration section
|
||||
- `/api/config/directory` (POST) - Update anime directory
|
||||
- `/api/config/backup` (POST) - Create configuration backup
|
||||
- `/api/config/backups` (GET) - List configuration backups
|
||||
- `/api/config/backup/{name}/restore` (POST) - Restore backup
|
||||
- `/api/config/backup/{name}/download` (GET) - Download backup
|
||||
- `/api/config/export` (POST) - Export configuration
|
||||
- `/api/config/validate` (POST) - Validate configuration
|
||||
- `/api/config/reset` (POST) - Reset configuration to defaults
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Endpoint Organization
|
||||
|
||||
- Anime-related endpoints: `/api/v1/anime/`
|
||||
- Queue management: `/api/queue/`
|
||||
- Configuration: `/api/config/`
|
||||
- Authentication: `/api/auth/`
|
||||
- Health checks: `/health`
|
||||
|
||||
### Design Patterns Used
|
||||
|
||||
- Dependency injection for `SeriesApp` instance
|
||||
- Request validation with Pydantic models
|
||||
- Consistent error handling and HTTP status codes
|
||||
- Authentication requirements on all endpoints
|
||||
- Proper async/await patterns
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- Frontend uses `makeAuthenticatedRequest()` helper for API calls
|
||||
- Bearer token authentication in Authorization header
|
||||
- Consistent response format expected: `{status: string, message: string, ...}`
|
||||
- WebSocket integration preserved for real-time updates
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All endpoints require authentication via `require_auth` dependency
|
||||
- Input validation on request models (link length, folder list)
|
||||
- Proper error messages without exposing internal details
|
||||
- No injection vulnerabilities in search/add operations
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Implement missing APIs**: Scheduler, Logging, Diagnostics
|
||||
2. **Enhanced validation**: Add more comprehensive input validation
|
||||
3. **Rate limiting**: Add per-endpoint rate limiting if needed
|
||||
4. **Caching**: Consider caching for status endpoints
|
||||
5. **Pagination**: Add pagination to anime list endpoint
|
||||
6. **Filtering**: Add filtering options to anime list
|
||||
7. **Batch operations**: Support batch add/download operations
|
||||
8. **Progress tracking**: Enhance real-time progress updates
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/server/api/anime.py` - Added 4 new endpoints
|
||||
- `src/server/web/static/js/app.js` - Updated 4 API call paths
|
||||
- `instructions.md` - Marked frontend integration tasks as completed
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core frontend-backend integration is now complete. The main user workflows (listing anime, searching, adding series, downloading) are fully functional. The remaining work involves implementing administrative and configuration features (scheduler, logging, diagnostics) that enhance the application but are not critical for basic operation.
|
||||
|
||||
All tests are passing, and the integration follows established patterns and best practices for the project.
|
||||
@ -1,839 +0,0 @@
|
||||
# Frontend Integration Guide
|
||||
|
||||
Complete guide for integrating the existing frontend assets with the FastAPI backend.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Frontend Asset Structure](#frontend-asset-structure)
|
||||
3. [API Integration](#api-integration)
|
||||
4. [WebSocket Integration](#websocket-integration)
|
||||
5. [Theme System](#theme-system)
|
||||
6. [Authentication Flow](#authentication-flow)
|
||||
7. [Error Handling](#error-handling)
|
||||
8. [Localization](#localization)
|
||||
9. [Accessibility Features](#accessibility-features)
|
||||
10. [Testing Integration](#testing-integration)
|
||||
|
||||
## Overview
|
||||
|
||||
The Aniworld frontend uses vanilla JavaScript with modern ES6+ features, integrated with a FastAPI backend through REST API endpoints and WebSocket connections. The design follows Fluent UI principles with comprehensive accessibility support.
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: Vanilla JavaScript (ES6+), HTML5, CSS3
|
||||
- **Backend**: FastAPI, Python 3.10+
|
||||
- **Communication**: REST API, WebSocket
|
||||
- **Styling**: Custom CSS with Fluent UI design principles
|
||||
- **Icons**: Font Awesome 6.0.0
|
||||
|
||||
## Frontend Asset Structure
|
||||
|
||||
### Templates (`src/server/web/templates/`)
|
||||
|
||||
- `index.html` - Main application interface
|
||||
- `queue.html` - Download queue management page
|
||||
- `login.html` - Authentication login page
|
||||
- `setup.html` - Initial setup page
|
||||
- `error.html` - Error display page
|
||||
|
||||
### JavaScript Files (`src/server/web/static/js/`)
|
||||
|
||||
#### Core Application Files
|
||||
|
||||
- **`app.js`** (2086 lines)
|
||||
|
||||
- Main application logic
|
||||
- Series management
|
||||
- Download operations
|
||||
- Search functionality
|
||||
- Theme management
|
||||
- Authentication handling
|
||||
|
||||
- **`queue.js`** (758 lines)
|
||||
|
||||
- Download queue management
|
||||
- Queue reordering
|
||||
- Download progress tracking
|
||||
- Queue status updates
|
||||
|
||||
- **`websocket_client.js`** (234 lines)
|
||||
- Native WebSocket wrapper
|
||||
- Socket.IO-like interface
|
||||
- Reconnection logic
|
||||
- Message routing
|
||||
|
||||
#### Feature Enhancement Files
|
||||
|
||||
- **`accessibility_features.js`** - ARIA labels, keyboard navigation
|
||||
- **`advanced_search.js`** - Advanced search filtering
|
||||
- **`bulk_operations.js`** - Batch operations on series
|
||||
- **`color_contrast_compliance.js`** - WCAG color contrast validation
|
||||
- **`drag_drop.js`** - Drag-and-drop queue reordering
|
||||
- **`keyboard_shortcuts.js`** - Global keyboard shortcuts
|
||||
- **`localization.js`** - Multi-language support
|
||||
- **`mobile_responsive.js`** - Mobile-specific enhancements
|
||||
- **`multi_screen_support.js`** - Multi-monitor support
|
||||
- **`screen_reader_support.js`** - Screen reader compatibility
|
||||
- **`touch_gestures.js`** - Touch gesture support
|
||||
- **`undo_redo.js`** - Undo/redo functionality
|
||||
- **`user_preferences.js`** - User preference management
|
||||
|
||||
### CSS Files (`src/server/web/static/css/`)
|
||||
|
||||
- **`styles.css`** - Main stylesheet with Fluent UI design
|
||||
- **`ux_features.css`** - UX enhancements and accessibility styles
|
||||
|
||||
## API Integration
|
||||
|
||||
### Current API Endpoints Used
|
||||
|
||||
#### Authentication Endpoints
|
||||
|
||||
```javascript
|
||||
// Check authentication status
|
||||
GET /api/auth/status
|
||||
Headers: { Authorization: Bearer <token> }
|
||||
|
||||
// Login
|
||||
POST /api/auth/login
|
||||
Body: { password: string }
|
||||
Response: { token: string, token_type: string }
|
||||
|
||||
// Logout
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
#### Anime Endpoints
|
||||
|
||||
```javascript
|
||||
// List all anime
|
||||
GET /api/v1/anime
|
||||
Response: { success: bool, data: Array<Anime> }
|
||||
|
||||
// Search anime
|
||||
GET /api/v1/anime/search?query=<search_term>
|
||||
Response: { success: bool, data: Array<Anime> }
|
||||
|
||||
// Get anime details
|
||||
GET /api/v1/anime/{anime_id}
|
||||
Response: { success: bool, data: Anime }
|
||||
```
|
||||
|
||||
#### Download Queue Endpoints
|
||||
|
||||
```javascript
|
||||
// Get queue status
|
||||
GET /api/v1/download/queue
|
||||
Response: { queue: Array<DownloadItem>, is_running: bool }
|
||||
|
||||
// Add to queue
|
||||
POST /api/v1/download/queue
|
||||
Body: { anime_id: string, episodes: Array<number> }
|
||||
|
||||
// Start queue
|
||||
POST /api/v1/download/queue/start
|
||||
|
||||
// Stop queue
|
||||
POST /api/v1/download/queue/stop
|
||||
|
||||
// Pause queue
|
||||
POST /api/v1/download/queue/pause
|
||||
|
||||
// Resume queue
|
||||
POST /api/v1/download/queue/resume
|
||||
|
||||
// Reorder queue
|
||||
PUT /api/v1/download/queue/reorder
|
||||
Body: { queue_order: Array<string> }
|
||||
|
||||
// Remove from queue
|
||||
DELETE /api/v1/download/queue/{item_id}
|
||||
```
|
||||
|
||||
#### Configuration Endpoints
|
||||
|
||||
```javascript
|
||||
// Get configuration
|
||||
GET / api / v1 / config;
|
||||
Response: {
|
||||
config: ConfigObject;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
PUT / api / v1 / config;
|
||||
Body: ConfigObject;
|
||||
```
|
||||
|
||||
### API Call Pattern
|
||||
|
||||
All API calls follow this pattern in the JavaScript files:
|
||||
|
||||
```javascript
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
try {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Redirect to login
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API call failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Required API Updates
|
||||
|
||||
The following API endpoints need to be verified/updated to match frontend expectations:
|
||||
|
||||
1. **Response Format Consistency**
|
||||
|
||||
- All responses should include `success` boolean
|
||||
- Error responses should include `error`, `message`, and `details`
|
||||
- Success responses should include `data` field
|
||||
|
||||
2. **Authentication Flow**
|
||||
|
||||
- `/api/auth/status` endpoint for checking authentication
|
||||
- Proper 401 responses for unauthenticated requests
|
||||
- Token refresh mechanism (if needed)
|
||||
|
||||
3. **Queue Operations**
|
||||
- Ensure queue reordering endpoint exists
|
||||
- Validate pause/resume functionality
|
||||
- Check queue status polling endpoint
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### WebSocket Connection
|
||||
|
||||
The frontend uses a custom WebSocket client (`websocket_client.js`) that provides a Socket.IO-like interface over native WebSocket.
|
||||
|
||||
#### Connection Endpoint
|
||||
|
||||
```javascript
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/ws/connect`;
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
#### Events Sent by Frontend
|
||||
|
||||
```javascript
|
||||
// Join a room (for targeted updates)
|
||||
socket.emit("join", { room: "downloads" });
|
||||
socket.emit("join", { room: "download_progress" });
|
||||
|
||||
// Leave a room
|
||||
socket.emit("leave", { room: "downloads" });
|
||||
|
||||
// Custom events (as needed)
|
||||
socket.emit("custom_event", { data: "value" });
|
||||
```
|
||||
|
||||
#### Events Received by Frontend
|
||||
|
||||
##### Connection Events
|
||||
|
||||
```javascript
|
||||
socket.on("connect", () => {
|
||||
// Connection established
|
||||
});
|
||||
|
||||
socket.on("disconnect", (data) => {
|
||||
// Connection lost - data: { code, reason }
|
||||
});
|
||||
|
||||
socket.on("connected", (data) => {
|
||||
// Server confirmation - data: { message, timestamp }
|
||||
});
|
||||
```
|
||||
|
||||
##### Queue Events
|
||||
|
||||
```javascript
|
||||
// Queue status updates
|
||||
socket.on("queue_status", (data) => {
|
||||
// data: { queue_status: { queue: [], is_running: bool } }
|
||||
});
|
||||
|
||||
socket.on("queue_updated", (data) => {
|
||||
// Legacy event - same as queue_status
|
||||
});
|
||||
|
||||
// Download lifecycle
|
||||
socket.on("queue_started", () => {
|
||||
// Queue processing started
|
||||
});
|
||||
|
||||
socket.on("download_started", (data) => {
|
||||
// Individual download started
|
||||
// data: { serie_name, episode }
|
||||
});
|
||||
|
||||
socket.on("download_progress", (data) => {
|
||||
// Download progress update
|
||||
// data: { serie_name, episode, progress, speed, eta }
|
||||
});
|
||||
|
||||
socket.on("download_complete", (data) => {
|
||||
// Download completed
|
||||
// data: { serie_name, episode }
|
||||
});
|
||||
|
||||
socket.on("download_completed", (data) => {
|
||||
// Legacy event - same as download_complete
|
||||
});
|
||||
|
||||
socket.on("download_failed", (data) => {
|
||||
// Download failed
|
||||
// data: { serie_name, episode, error }
|
||||
});
|
||||
|
||||
socket.on("download_error", (data) => {
|
||||
// Legacy event - same as download_failed
|
||||
});
|
||||
|
||||
socket.on("download_queue_completed", () => {
|
||||
// All downloads in queue completed
|
||||
});
|
||||
|
||||
socket.on("download_stop_requested", () => {
|
||||
// Queue stop requested
|
||||
});
|
||||
```
|
||||
|
||||
##### Scan Events
|
||||
|
||||
```javascript
|
||||
socket.on("scan_started", () => {
|
||||
// Library scan started
|
||||
});
|
||||
|
||||
socket.on("scan_progress", (data) => {
|
||||
// Scan progress update
|
||||
// data: { current, total, percentage }
|
||||
});
|
||||
|
||||
socket.on("scan_completed", (data) => {
|
||||
// Scan completed
|
||||
// data: { total_series, new_series, updated_series }
|
||||
});
|
||||
|
||||
socket.on("scan_failed", (data) => {
|
||||
// Scan failed
|
||||
// data: { error }
|
||||
});
|
||||
```
|
||||
|
||||
### Backend WebSocket Requirements
|
||||
|
||||
The backend WebSocket implementation (`src/server/api/websocket.py`) should:
|
||||
|
||||
1. **Accept connections at** `/ws/connect`
|
||||
2. **Handle room management** (join/leave messages)
|
||||
3. **Broadcast events** to appropriate rooms
|
||||
4. **Support message format**:
|
||||
```json
|
||||
{
|
||||
"event": "event_name",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
### Theme Implementation
|
||||
|
||||
The application supports light and dark modes with persistence.
|
||||
|
||||
#### Theme Toggle
|
||||
|
||||
```javascript
|
||||
// Toggle theme
|
||||
document.documentElement.setAttribute("data-theme", "light|dark");
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem("theme", "light|dark");
|
||||
|
||||
// Load on startup
|
||||
const savedTheme = localStorage.getItem("theme") || "light";
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
```
|
||||
|
||||
#### CSS Variables
|
||||
|
||||
Themes are defined using CSS custom properties:
|
||||
|
||||
```css
|
||||
:root[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #666666;
|
||||
--accent-color: #0078d4;
|
||||
/* ... more variables */
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #1e1e1e;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cccccc;
|
||||
--accent-color: #60a5fa;
|
||||
/* ... more variables */
|
||||
}
|
||||
```
|
||||
|
||||
### Fluent UI Design Principles
|
||||
|
||||
The frontend follows Microsoft Fluent UI design guidelines:
|
||||
|
||||
- **Rounded corners**: 4px border radius
|
||||
- **Shadows**: Subtle elevation shadows
|
||||
- **Transitions**: Smooth 200-300ms transitions
|
||||
- **Typography**: System font stack
|
||||
- **Spacing**: 8px grid system
|
||||
- **Colors**: Accessible color palette
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Authentication States
|
||||
|
||||
```javascript
|
||||
// State management
|
||||
const authStates = {
|
||||
UNAUTHENTICATED: "unauthenticated",
|
||||
AUTHENTICATED: "authenticated",
|
||||
SETUP_REQUIRED: "setup_required",
|
||||
};
|
||||
```
|
||||
|
||||
### Authentication Check
|
||||
|
||||
On page load, the application checks authentication status:
|
||||
|
||||
```javascript
|
||||
async checkAuthentication() {
|
||||
// Skip check on public pages
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/login' || currentPath === '/setup') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Login Flow
|
||||
|
||||
```javascript
|
||||
async login(password) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('access_token', data.token);
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
// Show error message
|
||||
this.showError('Invalid password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
this.showError('Login failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logout Flow
|
||||
|
||||
```javascript
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Frontend Error Display
|
||||
|
||||
The application uses toast notifications for errors:
|
||||
|
||||
```javascript
|
||||
showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
```
|
||||
|
||||
### API Error Handling
|
||||
|
||||
```javascript
|
||||
async function handleApiError(error, response) {
|
||||
if (response) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
// Show user-friendly error message
|
||||
const message = data.message || `Error: ${response.status}`;
|
||||
this.showToast(message, "error");
|
||||
|
||||
// Log details for debugging
|
||||
console.error("API Error:", {
|
||||
status: response.status,
|
||||
error: data.error,
|
||||
message: data.message,
|
||||
details: data.details,
|
||||
});
|
||||
|
||||
// Handle specific status codes
|
||||
if (response.status === 401) {
|
||||
// Redirect to login
|
||||
localStorage.removeItem("access_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
} else {
|
||||
// Network error
|
||||
this.showToast("Network error. Please check your connection.", "error");
|
||||
console.error("Network error:", error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Expected Error Response Format
|
||||
|
||||
The backend should return errors in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "ERROR_CODE",
|
||||
"message": "Human-readable error message",
|
||||
"details": {
|
||||
"field": "error_field",
|
||||
"reason": "specific_reason"
|
||||
},
|
||||
"request_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
The application includes a localization system (`localization.js`) for multi-language support.
|
||||
|
||||
### Localization Usage
|
||||
|
||||
```javascript
|
||||
// Initialize localization
|
||||
const localization = new Localization();
|
||||
|
||||
// Set language
|
||||
localization.setLanguage("en"); // or 'de', 'es', etc.
|
||||
|
||||
// Get translation
|
||||
const text = localization.get("key", "default_value");
|
||||
|
||||
// Update all page text
|
||||
localization.updatePageText();
|
||||
```
|
||||
|
||||
### Text Keys
|
||||
|
||||
Elements with `data-text` attributes are automatically translated:
|
||||
|
||||
```html
|
||||
<span data-text="download-queue">Download Queue</span>
|
||||
<button data-text="start-download">Start Download</button>
|
||||
```
|
||||
|
||||
### Adding New Translations
|
||||
|
||||
Translations are defined in `localization.js`:
|
||||
|
||||
```javascript
|
||||
const translations = {
|
||||
en: {
|
||||
"download-queue": "Download Queue",
|
||||
"start-download": "Start Download",
|
||||
// ... more keys
|
||||
},
|
||||
de: {
|
||||
"download-queue": "Download-Warteschlange",
|
||||
"start-download": "Download starten",
|
||||
// ... more keys
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
The application includes comprehensive accessibility support.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
All interactive elements are keyboard accessible:
|
||||
|
||||
- **Tab/Shift+Tab**: Navigate between elements
|
||||
- **Enter/Space**: Activate buttons
|
||||
- **Escape**: Close modals/dialogs
|
||||
- **Arrow Keys**: Navigate lists
|
||||
|
||||
Custom keyboard shortcuts are defined in `keyboard_shortcuts.js`.
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
ARIA labels and live regions are implemented:
|
||||
|
||||
```html
|
||||
<button aria-label="Start download" aria-describedby="download-help">
|
||||
<i class="fas fa-download" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<div role="status" aria-live="polite" id="status-message"></div>
|
||||
```
|
||||
|
||||
### Color Contrast
|
||||
|
||||
The application ensures WCAG AA compliance for color contrast:
|
||||
|
||||
- Normal text: 4.5:1 minimum
|
||||
- Large text: 3:1 minimum
|
||||
- Interactive elements: 3:1 minimum
|
||||
|
||||
`color_contrast_compliance.js` validates contrast ratios.
|
||||
|
||||
### Touch Support
|
||||
|
||||
Touch gestures are supported for mobile devices:
|
||||
|
||||
- **Swipe**: Navigate between sections
|
||||
- **Long press**: Show context menu
|
||||
- **Pinch**: Zoom (where applicable)
|
||||
|
||||
## Testing Integration
|
||||
|
||||
### Frontend Testing Checklist
|
||||
|
||||
- [ ] **API Integration**
|
||||
|
||||
- [ ] All API endpoints return expected response format
|
||||
- [ ] Error responses include proper error codes
|
||||
- [ ] Authentication flow works correctly
|
||||
- [ ] Token refresh mechanism works (if implemented)
|
||||
|
||||
- [ ] **WebSocket Integration**
|
||||
|
||||
- [ ] WebSocket connects successfully
|
||||
- [ ] All expected events are received
|
||||
- [ ] Reconnection works after disconnect
|
||||
- [ ] Room-based broadcasting works correctly
|
||||
|
||||
- [ ] **UI/UX**
|
||||
|
||||
- [ ] Theme toggle persists across sessions
|
||||
- [ ] All pages are responsive (mobile, tablet, desktop)
|
||||
- [ ] Animations are smooth and performant
|
||||
- [ ] Toast notifications display correctly
|
||||
|
||||
- [ ] **Authentication**
|
||||
|
||||
- [ ] Login redirects to home page
|
||||
- [ ] Logout clears session and redirects
|
||||
- [ ] Protected pages redirect unauthenticated users
|
||||
- [ ] Token expiration handled gracefully
|
||||
|
||||
- [ ] **Accessibility**
|
||||
|
||||
- [ ] Keyboard navigation works on all pages
|
||||
- [ ] Screen reader announces important changes
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Focus indicators are visible
|
||||
|
||||
- [ ] **Localization**
|
||||
|
||||
- [ ] All text is translatable
|
||||
- [ ] Language selection persists
|
||||
- [ ] Translations are complete for all supported languages
|
||||
|
||||
- [ ] **Error Handling**
|
||||
- [ ] Network errors show appropriate messages
|
||||
- [ ] API errors display user-friendly messages
|
||||
- [ ] Fatal errors redirect to error page
|
||||
- [ ] Errors are logged for debugging
|
||||
|
||||
### Integration Test Examples
|
||||
|
||||
#### API Integration Test
|
||||
|
||||
```javascript
|
||||
describe("API Integration", () => {
|
||||
test("should authenticate and fetch anime list", async () => {
|
||||
// Login
|
||||
const loginResponse = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: "test_password" }),
|
||||
});
|
||||
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Fetch anime
|
||||
const animeResponse = await fetch("/api/v1/anime", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const data = await animeResponse.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### WebSocket Integration Test
|
||||
|
||||
```javascript
|
||||
describe("WebSocket Integration", () => {
|
||||
test("should connect and receive events", (done) => {
|
||||
const socket = new WebSocketClient();
|
||||
|
||||
socket.on("connect", () => {
|
||||
expect(socket.isConnected).toBe(true);
|
||||
|
||||
// Join room
|
||||
socket.emit("join", { room: "downloads" });
|
||||
|
||||
// Wait for queue_status event
|
||||
socket.on("queue_status", (data) => {
|
||||
expect(data).toHaveProperty("queue_status");
|
||||
socket.disconnect();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend Integration Checklist
|
||||
|
||||
### Phase 1: API Endpoint Verification
|
||||
|
||||
- [ ] Verify `/api/auth/status` endpoint exists and returns proper format
|
||||
- [ ] Verify `/api/auth/login` returns token in expected format
|
||||
- [ ] Verify `/api/auth/logout` endpoint exists
|
||||
- [ ] Verify `/api/v1/anime` returns list with `success` and `data` fields
|
||||
- [ ] Verify `/api/v1/anime/search` endpoint exists
|
||||
- [ ] Verify `/api/v1/download/queue` endpoints match frontend expectations
|
||||
- [ ] Verify error responses include `success`, `error`, `message`, `details`
|
||||
|
||||
### Phase 2: WebSocket Integration
|
||||
|
||||
- [ ] Verify WebSocket endpoint is `/ws/connect`
|
||||
- [ ] Verify room join/leave functionality
|
||||
- [ ] Verify all queue events are emitted properly
|
||||
- [ ] Verify scan events are emitted properly
|
||||
- [ ] Test reconnection logic
|
||||
- [ ] Test message broadcasting to rooms
|
||||
|
||||
### Phase 3: Frontend Code Updates
|
||||
|
||||
- [ ] Update `app.js` API calls to match backend endpoints
|
||||
- [ ] Update `queue.js` API calls to match backend endpoints
|
||||
- [ ] Verify `websocket_client.js` message format matches backend
|
||||
- [ ] Update error handling to parse new error format
|
||||
- [ ] Test authentication flow end-to-end
|
||||
- [ ] Verify theme persistence works
|
||||
|
||||
### Phase 4: UI/UX Polish
|
||||
|
||||
- [ ] Verify responsive design on mobile devices
|
||||
- [ ] Test keyboard navigation on all pages
|
||||
- [ ] Verify screen reader compatibility
|
||||
- [ ] Test color contrast in both themes
|
||||
- [ ] Verify all animations are smooth
|
||||
- [ ] Test touch gestures on mobile
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
- [ ] Write integration tests for API endpoints
|
||||
- [ ] Write integration tests for WebSocket events
|
||||
- [ ] Write UI tests for critical user flows
|
||||
- [ ] Test error scenarios (network errors, auth failures)
|
||||
- [ ] Test performance under load
|
||||
- [ ] Test accessibility with screen reader
|
||||
|
||||
## Conclusion
|
||||
|
||||
This guide provides a comprehensive overview of the frontend integration requirements. All JavaScript files should be reviewed and updated to match the documented API endpoints and WebSocket events. The backend should ensure it provides the expected response formats and event structures.
|
||||
|
||||
For questions or issues, refer to:
|
||||
|
||||
- **API Reference**: `docs/api_reference.md`
|
||||
- **User Guide**: `docs/user_guide.md`
|
||||
- **Deployment Guide**: `docs/deployment.md`
|
||||
426
docs/identifier_standardization_validation.md
Normal file
426
docs/identifier_standardization_validation.md
Normal file
@ -0,0 +1,426 @@
|
||||
# Series Identifier Standardization - Validation Instructions
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides comprehensive instructions for AI agents to validate the **Series Identifier Standardization** change across the Aniworld codebase. The change standardizes `key` as the primary identifier for series and relegates `folder` to metadata-only status.
|
||||
|
||||
## Summary of the Change
|
||||
|
||||
| Field | Purpose | Usage |
|
||||
| -------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| `key` | **Primary Identifier** - Provider-assigned, URL-safe (e.g., `attack-on-titan`) | All lookups, API operations, database queries, WebSocket events |
|
||||
| `folder` | **Metadata Only** - Filesystem folder name (e.g., `Attack on Titan (2013)`) | Display purposes, filesystem operations only |
|
||||
| `id` | **Database Primary Key** - Internal auto-increment integer | Database relationships only |
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
### Phase 2: Application Layer Services
|
||||
|
||||
**Files to validate:**
|
||||
|
||||
1. **`src/server/services/anime_service.py`**
|
||||
|
||||
- [ ] Class docstring explains `key` vs `folder` convention
|
||||
- [ ] All public methods accept `key` parameter for series identification
|
||||
- [ ] No methods accept `folder` as an identifier parameter
|
||||
- [ ] Event handler methods document key/folder convention
|
||||
- [ ] Progress tracking uses `key` in progress IDs where possible
|
||||
|
||||
2. **`src/server/services/download_service.py`**
|
||||
|
||||
- [ ] `DownloadItem` uses `serie_id` (which should be the `key`)
|
||||
- [ ] `serie_folder` is documented as metadata only
|
||||
- [ ] Queue operations look up series by `key` not `folder`
|
||||
- [ ] Persistence format includes `serie_id` as the key identifier
|
||||
|
||||
3. **`src/server/services/websocket_service.py`**
|
||||
|
||||
- [ ] Module docstring explains key/folder convention
|
||||
- [ ] Broadcast methods include `key` in message payloads
|
||||
- [ ] `folder` is documented as optional/display only
|
||||
- [ ] Event broadcasts use `key` as primary identifier
|
||||
|
||||
4. **`src/server/services/scan_service.py`**
|
||||
|
||||
- [ ] Scan operations use `key` for identification
|
||||
- [ ] Progress events include `key` field
|
||||
|
||||
5. **`src/server/services/progress_service.py`**
|
||||
- [ ] Progress tracking includes `key` in metadata where applicable
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Check service layer for folder-based lookups
|
||||
grep -rn "by_folder\|folder.*=.*identifier\|folder.*lookup" src/server/services/ --include="*.py"
|
||||
|
||||
# Verify key is used in services
|
||||
grep -rn "serie_id\|series_key\|key.*identifier" src/server/services/ --include="*.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: API Endpoints and Responses
|
||||
|
||||
**Files to validate:**
|
||||
|
||||
1. **`src/server/api/anime.py`**
|
||||
|
||||
- [ ] `AnimeSummary` model has `key` field with proper description
|
||||
- [ ] `AnimeDetail` model has `key` field with proper description
|
||||
- [ ] API docstrings explain `key` is the primary identifier
|
||||
- [ ] `folder` field descriptions state "metadata only"
|
||||
- [ ] Endpoint paths use `key` parameter (e.g., `/api/anime/{key}`)
|
||||
- [ ] No endpoints use `folder` as path parameter for lookups
|
||||
|
||||
2. **`src/server/api/download.py`**
|
||||
|
||||
- [ ] Download endpoints use `serie_id` (key) for operations
|
||||
- [ ] Request models document key/folder convention
|
||||
- [ ] Response models include `key` as primary identifier
|
||||
|
||||
3. **`src/server/models/anime.py`**
|
||||
|
||||
- [ ] Module docstring explains identifier convention
|
||||
- [ ] `AnimeSeriesResponse` has `key` field properly documented
|
||||
- [ ] `SearchResult` has `key` field properly documented
|
||||
- [ ] Field validators normalize `key` to lowercase
|
||||
- [ ] `folder` fields document metadata-only purpose
|
||||
|
||||
4. **`src/server/models/download.py`**
|
||||
|
||||
- [ ] `DownloadItem` has `serie_id` documented as the key
|
||||
- [ ] `serie_folder` documented as metadata only
|
||||
- [ ] Field descriptions are clear about primary vs metadata
|
||||
|
||||
5. **`src/server/models/websocket.py`**
|
||||
- [ ] Module docstring explains key/folder convention
|
||||
- [ ] Message models document `key` as primary identifier
|
||||
- [ ] `folder` documented as optional display metadata
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Check API endpoints for folder-based paths
|
||||
grep -rn "folder.*Path\|/{folder}" src/server/api/ --include="*.py"
|
||||
|
||||
# Verify key is used in endpoints
|
||||
grep -rn "/{key}\|series_key\|serie_id" src/server/api/ --include="*.py"
|
||||
|
||||
# Check model field descriptions
|
||||
grep -rn "Field.*description.*identifier\|Field.*description.*key\|Field.*description.*folder" src/server/models/ --include="*.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Frontend Integration
|
||||
|
||||
**Files to validate:**
|
||||
|
||||
1. **`src/server/web/static/js/app.js`**
|
||||
|
||||
- [ ] `selectedSeries` Set uses `key` values, not `folder`
|
||||
- [ ] `seriesData` array comments indicate `key` as primary identifier
|
||||
- [ ] Selection operations use `key` property
|
||||
- [ ] API calls pass `key` for series identification
|
||||
- [ ] WebSocket message handlers extract `key` from data
|
||||
- [ ] No code uses `folder` for series lookups
|
||||
|
||||
2. **`src/server/web/static/js/queue.js`**
|
||||
|
||||
- [ ] Queue items reference series by `key` or `serie_id`
|
||||
- [ ] WebSocket handlers extract `key` from messages
|
||||
- [ ] UI operations use `key` for identification
|
||||
- [ ] `serie_folder` used only for display
|
||||
|
||||
3. **`src/server/web/static/js/websocket_client.js`**
|
||||
|
||||
- [ ] Message handling preserves `key` field
|
||||
- [ ] No transformation that loses `key` information
|
||||
|
||||
4. **HTML Templates** (`src/server/web/templates/`)
|
||||
- [ ] Data attributes use `key` for identification (e.g., `data-key`)
|
||||
- [ ] No `data-folder` used for identification purposes
|
||||
- [ ] Display uses `folder` or `name` appropriately
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Check JavaScript for folder-based lookups
|
||||
grep -rn "\.folder\s*==\|folder.*identifier\|getByFolder" src/server/web/static/js/ --include="*.js"
|
||||
|
||||
# Check data attributes in templates
|
||||
grep -rn "data-key\|data-folder\|data-series" src/server/web/templates/ --include="*.html"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Database Operations
|
||||
|
||||
**Files to validate:**
|
||||
|
||||
1. **`src/server/database/models.py`**
|
||||
|
||||
- [ ] `AnimeSeries` model has `key` column with unique constraint
|
||||
- [ ] `key` column is indexed
|
||||
- [ ] Model docstring explains identifier convention
|
||||
- [ ] `folder` column docstring states "metadata only"
|
||||
- [ ] Validators check `key` is not empty
|
||||
- [ ] No `folder` uniqueness constraint (unless intentional)
|
||||
|
||||
2. **`src/server/database/service.py`**
|
||||
|
||||
- [ ] `AnimeSeriesService` has `get_by_key()` method
|
||||
- [ ] Class docstring explains lookup convention
|
||||
- [ ] No `get_by_folder()` without deprecation
|
||||
- [ ] All CRUD operations use `key` for identification
|
||||
- [ ] Logging uses `key` in messages
|
||||
|
||||
3. **`src/server/database/migrations/`**
|
||||
- [ ] Migration files maintain `key` as unique, indexed column
|
||||
- [ ] No migrations that use `folder` as identifier
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Check database models
|
||||
grep -rn "unique=True\|index=True" src/server/database/models.py
|
||||
|
||||
# Check service lookups
|
||||
grep -rn "get_by_key\|get_by_folder\|filter.*key\|filter.*folder" src/server/database/service.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: WebSocket Events
|
||||
|
||||
**Files to validate:**
|
||||
|
||||
1. **All WebSocket broadcast calls** should include `key` in payload:
|
||||
|
||||
- `download_progress` → includes `key`
|
||||
- `download_complete` → includes `key`
|
||||
- `download_failed` → includes `key`
|
||||
- `scan_progress` → includes `key` (where applicable)
|
||||
- `queue_status` → items include `key`
|
||||
|
||||
2. **Message format validation:**
|
||||
```json
|
||||
{
|
||||
"type": "download_progress",
|
||||
"data": {
|
||||
"key": "attack-on-titan", // PRIMARY - always present
|
||||
"folder": "Attack on Titan (2013)", // OPTIONAL - display only
|
||||
"progress": 45.5,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Check WebSocket broadcast calls
|
||||
grep -rn "broadcast.*key\|send_json.*key" src/server/services/ --include="*.py"
|
||||
|
||||
# Check message construction
|
||||
grep -rn '"key":\|"folder":' src/server/services/ --include="*.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Test Coverage
|
||||
|
||||
**Test files to validate:**
|
||||
|
||||
1. **`tests/unit/test_serie_class.py`**
|
||||
|
||||
- [ ] Tests for key validation (empty, whitespace, None)
|
||||
- [ ] Tests for key as primary identifier
|
||||
- [ ] Tests for folder as metadata only
|
||||
|
||||
2. **`tests/unit/test_anime_service.py`**
|
||||
|
||||
- [ ] Service tests use `key` for operations
|
||||
- [ ] Mock objects have proper `key` attributes
|
||||
|
||||
3. **`tests/unit/test_database_models.py`**
|
||||
|
||||
- [ ] Tests for `key` uniqueness constraint
|
||||
- [ ] Tests for `key` validation
|
||||
|
||||
4. **`tests/unit/test_database_service.py`**
|
||||
|
||||
- [ ] Tests for `get_by_key()` method
|
||||
- [ ] No tests for deprecated folder lookups
|
||||
|
||||
5. **`tests/api/test_anime_endpoints.py`**
|
||||
|
||||
- [ ] API tests use `key` in requests
|
||||
- [ ] Mock `FakeSerie` has proper `key` attribute
|
||||
- [ ] Comments explain key/folder convention
|
||||
|
||||
6. **`tests/unit/test_websocket_service.py`**
|
||||
- [ ] WebSocket tests verify `key` in messages
|
||||
- [ ] Broadcast tests include `key` in payload
|
||||
|
||||
**Validation Commands:**
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
conda run -n AniWorld python -m pytest tests/ -v --tb=short
|
||||
|
||||
# Run specific test files
|
||||
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v
|
||||
conda run -n AniWorld python -m pytest tests/unit/test_database_models.py -v
|
||||
conda run -n AniWorld python -m pytest tests/api/test_anime_endpoints.py -v
|
||||
|
||||
# Search tests for identifier usage
|
||||
grep -rn "key.*identifier\|folder.*metadata" tests/ --include="*.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues to Check
|
||||
|
||||
### 1. Inconsistent Naming
|
||||
|
||||
Look for inconsistent parameter names:
|
||||
|
||||
- `serie_key` vs `series_key` vs `key`
|
||||
- `serie_id` should refer to `key`, not database `id`
|
||||
- `serie_folder` vs `folder`
|
||||
|
||||
### 2. Missing Documentation
|
||||
|
||||
Check that ALL models, services, and APIs document:
|
||||
|
||||
- What `key` is and how to use it
|
||||
- That `folder` is metadata only
|
||||
|
||||
### 3. Legacy Code Patterns
|
||||
|
||||
Search for deprecated patterns:
|
||||
|
||||
```python
|
||||
# Bad - using folder for lookup
|
||||
series = get_by_folder(folder_name)
|
||||
|
||||
# Good - using key for lookup
|
||||
series = get_by_key(series_key)
|
||||
```
|
||||
|
||||
### 4. API Response Consistency
|
||||
|
||||
Verify all API responses include:
|
||||
|
||||
- `key` field (primary identifier)
|
||||
- `folder` field (optional, for display)
|
||||
|
||||
### 5. Frontend Data Flow
|
||||
|
||||
Verify the frontend:
|
||||
|
||||
- Stores `key` in selection sets
|
||||
- Passes `key` to API calls
|
||||
- Uses `folder` only for display
|
||||
|
||||
---
|
||||
|
||||
## Deprecation Warnings
|
||||
|
||||
The following should have deprecation warnings (for removal in v3.0.0):
|
||||
|
||||
1. Any `get_by_folder()` or `GetByFolder()` methods
|
||||
2. Any API endpoints that accept `folder` as a lookup parameter
|
||||
3. Any frontend code that uses `folder` for identification
|
||||
|
||||
**Example deprecation:**
|
||||
|
||||
```python
|
||||
import warnings
|
||||
|
||||
def get_by_folder(self, folder: str):
|
||||
"""DEPRECATED: Use get_by_key() instead."""
|
||||
warnings.warn(
|
||||
"get_by_folder() is deprecated, use get_by_key(). "
|
||||
"Will be removed in v3.0.0",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# ... implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated Validation Script
|
||||
|
||||
Run this script to perform automated checks:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# identifier_validation.sh
|
||||
|
||||
echo "=== Series Identifier Standardization Validation ==="
|
||||
echo ""
|
||||
|
||||
echo "1. Checking core entities..."
|
||||
grep -rn "PRIMARY IDENTIFIER\|metadata only" src/core/entities/ --include="*.py" | head -20
|
||||
|
||||
echo ""
|
||||
echo "2. Checking for deprecated folder lookups..."
|
||||
grep -rn "get_by_folder\|GetByFolder" src/ --include="*.py"
|
||||
|
||||
echo ""
|
||||
echo "3. Checking API models for key field..."
|
||||
grep -rn 'key.*Field\|Field.*key' src/server/models/ --include="*.py" | head -20
|
||||
|
||||
echo ""
|
||||
echo "4. Checking database models..."
|
||||
grep -rn "key.*unique\|key.*index" src/server/database/models.py
|
||||
|
||||
echo ""
|
||||
echo "5. Checking frontend key usage..."
|
||||
grep -rn "selectedSeries\|\.key\|data-key" src/server/web/static/js/ --include="*.js" | head -20
|
||||
|
||||
echo ""
|
||||
echo "6. Running tests..."
|
||||
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v --tb=short
|
||||
|
||||
echo ""
|
||||
echo "=== Validation Complete ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Results
|
||||
|
||||
After validation, you should confirm:
|
||||
|
||||
1. ✅ All core entities use `key` as primary identifier
|
||||
2. ✅ All services look up series by `key`
|
||||
3. ✅ All API endpoints use `key` for operations
|
||||
4. ✅ All database queries use `key` for lookups
|
||||
5. ✅ Frontend uses `key` for selection and API calls
|
||||
6. ✅ WebSocket events include `key` in payload
|
||||
7. ✅ All tests pass
|
||||
8. ✅ Documentation clearly explains the convention
|
||||
9. ✅ Deprecation warnings exist for legacy patterns
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
Once validation is complete, update this section:
|
||||
|
||||
- [x] Phase 1: Core Entities - Validated by: **AI Agent** Date: **28 Nov 2025**
|
||||
- [x] Phase 2: Services - Validated by: **AI Agent** Date: **28 Nov 2025**
|
||||
- [ ] Phase 3: API - Validated by: **\_\_\_** Date: **\_\_\_**
|
||||
- [ ] Phase 4: Frontend - Validated by: **\_\_\_** Date: **\_\_\_**
|
||||
- [ ] Phase 5: Database - Validated by: **\_\_\_** Date: **\_\_\_**
|
||||
- [ ] Phase 6: WebSocket - Validated by: **\_\_\_** Date: **\_\_\_**
|
||||
- [ ] Phase 7: Tests - Validated by: **\_\_\_** Date: **\_\_\_**
|
||||
|
||||
**Final Approval:** \***\*\*\*\*\***\_\_\_\***\*\*\*\*\*** Date: **\*\***\_**\*\***
|
||||
337
docs/infrastructure.md
Normal file
337
docs/infrastructure.md
Normal file
@ -0,0 +1,337 @@
|
||||
# Aniworld Web Application Infrastructure
|
||||
|
||||
```bash
|
||||
conda activate AniWorld
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # Core application logic
|
||||
│ ├── SeriesApp.py # Main application class
|
||||
│ ├── SerieScanner.py # Directory scanner
|
||||
│ ├── entities/ # Domain entities (series.py, SerieList.py)
|
||||
│ ├── interfaces/ # Abstract interfaces (providers.py, callbacks.py)
|
||||
│ ├── providers/ # Content providers (aniworld, streaming)
|
||||
│ └── exceptions/ # Custom exceptions
|
||||
├── server/ # FastAPI web application
|
||||
│ ├── fastapi_app.py # Main FastAPI application
|
||||
│ ├── controllers/ # Route controllers (health, page, error)
|
||||
│ ├── api/ # API routes (auth, config, anime, download, websocket)
|
||||
│ ├── models/ # Pydantic models
|
||||
│ ├── services/ # Business logic services
|
||||
│ ├── database/ # SQLAlchemy ORM layer
|
||||
│ ├── utils/ # Utilities (dependencies, templates, security)
|
||||
│ └── web/ # Frontend (templates, static assets)
|
||||
├── cli/ # CLI application
|
||||
data/ # Config, database, queue state
|
||||
logs/ # Application logs
|
||||
tests/ # Test suites
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| --------- | ---------------------------------------------- |
|
||||
| Backend | FastAPI, Uvicorn, SQLAlchemy, SQLite, Pydantic |
|
||||
| Frontend | HTML5, CSS3, Vanilla JS, Bootstrap 5, HTMX |
|
||||
| Security | JWT (python-jose), bcrypt (passlib) |
|
||||
| Real-time | Native WebSocket |
|
||||
|
||||
## Series Identifier Convention
|
||||
|
||||
Throughout the codebase, three identifiers are used for anime series:
|
||||
|
||||
| Identifier | Type | Purpose | Example |
|
||||
| ---------- | --------------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `key` | Unique, Indexed | **PRIMARY** - All lookups, API operations, WebSocket events | `"attack-on-titan"` |
|
||||
| `folder` | String | Display/filesystem metadata only (never for lookups) | `"Attack on Titan (2013)"` |
|
||||
| `id` | Primary Key | Internal database key for relationships | `1`, `42` |
|
||||
|
||||
### Key Format Requirements
|
||||
|
||||
- **Lowercase only**: No uppercase letters allowed
|
||||
- **URL-safe**: Only alphanumeric characters and hyphens
|
||||
- **Hyphen-separated**: Words separated by single hyphens
|
||||
- **No leading/trailing hyphens**: Must start and end with alphanumeric
|
||||
- **No consecutive hyphens**: `attack--titan` is invalid
|
||||
|
||||
**Valid examples**: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`, `"re-zero"`
|
||||
**Invalid examples**: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
|
||||
|
||||
### Migration Notes
|
||||
|
||||
- **Backward Compatibility**: API endpoints accepting `anime_id` will check `key` first, then fall back to `folder` lookup
|
||||
- **Deprecation**: Folder-based lookups are deprecated and will be removed in a future version
|
||||
- **New Code**: Always use `key` for identification; `folder` is metadata only
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication (`/api/auth`)
|
||||
|
||||
- `POST /login` - Master password authentication (returns JWT)
|
||||
- `POST /logout` - Invalidate session
|
||||
- `GET /status` - Check authentication status
|
||||
|
||||
### Configuration (`/api/config`)
|
||||
|
||||
- `GET /` - Get configuration
|
||||
- `PUT /` - Update configuration
|
||||
- `POST /validate` - Validate without applying
|
||||
- `GET /backups` - List backups
|
||||
- `POST /backups/{name}/restore` - Restore backup
|
||||
|
||||
### Anime (`/api/anime`)
|
||||
|
||||
- `GET /` - List anime with missing episodes (returns `key` as identifier)
|
||||
- `GET /{anime_id}` - Get anime details (accepts `key` or `folder` for backward compatibility)
|
||||
- `POST /search` - Search for anime (returns `key` as identifier)
|
||||
- `POST /add` - Add new series (extracts `key` from link URL)
|
||||
- `POST /rescan` - Trigger library rescan
|
||||
|
||||
**Response Models:**
|
||||
|
||||
- `AnimeSummary`: `key` (primary identifier), `name`, `site`, `folder` (metadata), `missing_episodes`, `link`
|
||||
- `AnimeDetail`: `key` (primary identifier), `title`, `folder` (metadata), `episodes`, `description`
|
||||
|
||||
### Download Queue (`/api/queue`)
|
||||
|
||||
- `GET /status` - Queue status and statistics
|
||||
- `POST /add` - Add episodes to queue
|
||||
- `DELETE /{item_id}` - Remove item
|
||||
- `POST /start` | `/stop` | `/pause` | `/resume` - Queue control
|
||||
- `POST /retry` - Retry failed downloads
|
||||
- `DELETE /completed` - Clear completed items
|
||||
|
||||
**Request Models:**
|
||||
|
||||
- `DownloadRequest`: `serie_id` (key, primary identifier), `serie_folder` (filesystem path), `serie_name` (display), `episodes`, `priority`
|
||||
|
||||
**Response Models:**
|
||||
|
||||
- `DownloadItem`: `id`, `serie_id` (key), `serie_folder` (metadata), `serie_name`, `episode`, `status`, `progress`
|
||||
- `QueueStatus`: `is_running`, `is_paused`, `active_downloads`, `pending_queue`, `completed_downloads`, `failed_downloads`
|
||||
|
||||
### WebSocket (`/ws/connect`)
|
||||
|
||||
Real-time updates for downloads, scans, and queue operations.
|
||||
|
||||
**Rooms**: `downloads`, `download_progress`, `scan_progress`
|
||||
|
||||
**Message Types**: `download_progress`, `download_complete`, `download_failed`, `queue_status`, `scan_progress`, `scan_complete`, `scan_failed`
|
||||
|
||||
**Series Identifier in Messages:**
|
||||
All series-related WebSocket events include `key` as the primary identifier in their data payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "download_progress",
|
||||
"timestamp": "2025-10-17T10:30:00.000Z",
|
||||
"data": {
|
||||
"download_id": "abc123",
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"percent": 45.2,
|
||||
"speed_mbps": 2.5,
|
||||
"eta_seconds": 180
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Models
|
||||
|
||||
| Model | Purpose |
|
||||
| ----------------- | ---------------------------------------- |
|
||||
| AnimeSeries | Series metadata (key, name, folder, etc) |
|
||||
| Episode | Episodes linked to series |
|
||||
| DownloadQueueItem | Queue items with status and progress |
|
||||
| UserSession | JWT sessions with expiry |
|
||||
|
||||
**Mixins**: `TimestampMixin` (created_at, updated_at), `SoftDeleteMixin`
|
||||
|
||||
### AnimeSeries Identifier Fields
|
||||
|
||||
| Field | Type | Purpose |
|
||||
| -------- | --------------- | ------------------------------------------------- |
|
||||
| `id` | Primary Key | Internal database key for relationships |
|
||||
| `key` | Unique, Indexed | **PRIMARY IDENTIFIER** for all lookups |
|
||||
| `folder` | String | Filesystem metadata only (not for identification) |
|
||||
|
||||
**Database Service Methods:**
|
||||
|
||||
- `AnimeSeriesService.get_by_key(key)` - **Primary lookup method**
|
||||
- `AnimeSeriesService.get_by_id(id)` - Internal lookup by database ID
|
||||
- No `get_by_folder()` method exists - folder is never used for lookups
|
||||
|
||||
## Core Services
|
||||
|
||||
### SeriesApp (`src/core/SeriesApp.py`)
|
||||
|
||||
Main engine for anime series management with async support, progress callbacks, and cancellation.
|
||||
|
||||
### Callback System (`src/core/interfaces/callbacks.py`)
|
||||
|
||||
- `ProgressCallback`, `ErrorCallback`, `CompletionCallback`
|
||||
- Context classes include `key` + optional `folder` fields
|
||||
- Thread-safe `CallbackManager` for multiple callback registration
|
||||
|
||||
### Services (`src/server/services/`)
|
||||
|
||||
| Service | Purpose |
|
||||
| ---------------- | ----------------------------------------- |
|
||||
| AnimeService | Series management, scans (uses SeriesApp) |
|
||||
| DownloadService | Queue management, download execution |
|
||||
| ScanService | Library scan operations with callbacks |
|
||||
| ProgressService | Centralized progress tracking + WebSocket |
|
||||
| WebSocketService | Real-time connection management |
|
||||
| AuthService | JWT authentication, rate limiting |
|
||||
| ConfigService | Configuration persistence with backups |
|
||||
|
||||
## Validation Utilities (`src/server/utils/validators.py`)
|
||||
|
||||
Provides data validation functions for ensuring data integrity across the application.
|
||||
|
||||
### Series Key Validation
|
||||
|
||||
- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only)
|
||||
- Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`
|
||||
- Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
|
||||
- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation
|
||||
- Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format
|
||||
- Set `allow_folder=False` to require strict key format
|
||||
|
||||
### Other Validators
|
||||
|
||||
| Function | Purpose |
|
||||
| --------------------------- | ------------------------------------------ |
|
||||
| `validate_series_name` | Series display name validation |
|
||||
| `validate_episode_range` | Episode range validation (1-1000) |
|
||||
| `validate_download_quality` | Quality setting (360p-1080p, best, worst) |
|
||||
| `validate_language` | Language codes (ger-sub, ger-dub, etc.) |
|
||||
| `validate_anime_url` | Aniworld.to/s.to URL validation |
|
||||
| `validate_backup_name` | Backup filename validation |
|
||||
| `validate_config_data` | Configuration data structure validation |
|
||||
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
|
||||
|
||||
## Template Helpers (`src/server/utils/template_helpers.py`)
|
||||
|
||||
Provides utilities for template rendering and series data preparation.
|
||||
|
||||
### Core Functions
|
||||
|
||||
| Function | Purpose |
|
||||
| -------------------------- | --------------------------------- |
|
||||
| `get_base_context` | Base context for all templates |
|
||||
| `render_template` | Render template with context |
|
||||
| `validate_template_exists` | Check if template file exists |
|
||||
| `list_available_templates` | List all available template files |
|
||||
|
||||
### Series Context Helpers
|
||||
|
||||
All series helpers use `key` as the primary identifier:
|
||||
|
||||
| Function | Purpose |
|
||||
| ----------------------------------- | ---------------------------------------------- |
|
||||
| `prepare_series_context` | Prepare series data for templates (uses `key`) |
|
||||
| `get_series_by_key` | Find series by `key` (not `folder`) |
|
||||
| `filter_series_by_missing_episodes` | Filter series with missing episodes |
|
||||
|
||||
**Example Usage:**
|
||||
|
||||
```python
|
||||
from src.server.utils.template_helpers import prepare_series_context
|
||||
|
||||
series_data = [
|
||||
{"key": "attack-on-titan", "name": "Attack on Titan", "folder": "Attack on Titan (2013)"},
|
||||
{"key": "one-piece", "name": "One Piece", "folder": "One Piece (1999)"}
|
||||
]
|
||||
prepared = prepare_series_context(series_data, sort_by="name")
|
||||
# Returns sorted list using 'key' as identifier
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Static Files
|
||||
|
||||
- CSS: `styles.css` (Fluent UI design), `ux_features.css` (accessibility)
|
||||
- JS: `app.js`, `queue.js`, `websocket_client.js`, accessibility modules
|
||||
|
||||
### WebSocket Client
|
||||
|
||||
Native WebSocket wrapper with Socket.IO-compatible API:
|
||||
|
||||
```javascript
|
||||
const socket = io();
|
||||
socket.join("download_progress");
|
||||
socket.on("download_progress", (data) => {
|
||||
/* ... */
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
JWT tokens stored in localStorage, included as `Authorization: Bearer <token>`.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
conda run -n AniWorld python -m pytest tests/ -v
|
||||
|
||||
# Unit tests only
|
||||
conda run -n AniWorld python -m pytest tests/unit/ -v
|
||||
|
||||
# API tests
|
||||
conda run -n AniWorld python -m pytest tests/api/ -v
|
||||
```
|
||||
|
||||
## Production Notes
|
||||
|
||||
### Current (Single-Process)
|
||||
|
||||
- SQLite with WAL mode
|
||||
- In-memory WebSocket connections
|
||||
- File-based config and queue persistence
|
||||
|
||||
### Multi-Process Deployment
|
||||
|
||||
- Switch to PostgreSQL/MySQL
|
||||
- Move WebSocket registry to Redis
|
||||
- Use distributed locking for queue operations
|
||||
- Consider Redis for session/cache storage
|
||||
|
||||
## Code Examples
|
||||
|
||||
### API Usage with Key Identifier
|
||||
|
||||
```python
|
||||
# Fetching anime list - response includes 'key' as identifier
|
||||
response = requests.get("/api/anime", headers={"Authorization": f"Bearer {token}"})
|
||||
anime_list = response.json()
|
||||
# Each item has: key="attack-on-titan", folder="Attack on Titan (2013)", ...
|
||||
|
||||
# Fetching specific anime by key (preferred)
|
||||
response = requests.get("/api/anime/attack-on-titan", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
# Adding to download queue using key
|
||||
download_request = {
|
||||
"serie_id": "attack-on-titan", # Use key, not folder
|
||||
"serie_folder": "Attack on Titan (2013)", # Metadata for filesystem
|
||||
"serie_name": "Attack on Titan",
|
||||
"episodes": ["S01E01", "S01E02"],
|
||||
"priority": 1
|
||||
}
|
||||
response = requests.post("/api/queue/add", json=download_request, headers=headers)
|
||||
```
|
||||
|
||||
### WebSocket Event Handling
|
||||
|
||||
```javascript
|
||||
// WebSocket events always include 'key' as identifier
|
||||
socket.on("download_progress", (data) => {
|
||||
const key = data.key; // Primary identifier: "attack-on-titan"
|
||||
const folder = data.folder; // Metadata: "Attack on Titan (2013)"
|
||||
updateProgressBar(key, data.percent);
|
||||
});
|
||||
```
|
||||
155
docs/logging.md
155
docs/logging.md
@ -1,155 +0,0 @@
|
||||
# Logging Configuration
|
||||
|
||||
This document describes the logging setup for the Aniworld FastAPI application.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses Python's built-in `logging` module with both console and file output. All logs are written to:
|
||||
|
||||
- **Console**: Colored output for development
|
||||
- **Log File**: `logs/fastapi_app.log` with detailed timestamps
|
||||
|
||||
## Log Levels
|
||||
|
||||
By default, the application logs at `INFO` level. You can change this by setting the `LOG_LEVEL` environment variable:
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL=DEBUG # More verbose
|
||||
export LOG_LEVEL=INFO # Default
|
||||
export LOG_LEVEL=WARNING # Less verbose
|
||||
export LOG_LEVEL=ERROR # Errors only
|
||||
```
|
||||
|
||||
Or in your `.env` file:
|
||||
|
||||
```
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Option 1: Using the run_server.py script (Recommended)
|
||||
|
||||
```bash
|
||||
conda run -n AniWorld python run_server.py
|
||||
```
|
||||
|
||||
This script uses the custom uvicorn logging configuration that ensures proper console and file logging.
|
||||
|
||||
### Option 2: Using the shell script
|
||||
|
||||
```bash
|
||||
./start_server.sh
|
||||
```
|
||||
|
||||
### Option 3: Using uvicorn directly
|
||||
|
||||
```bash
|
||||
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
|
||||
```
|
||||
|
||||
**Note**: When using `conda run`, console output may not be visible in real-time. The logs will still be written to the file.
|
||||
|
||||
## Log File Location
|
||||
|
||||
All logs are written to: `logs/fastapi_app.log`
|
||||
|
||||
To view logs in real-time:
|
||||
|
||||
```bash
|
||||
tail -f logs/fastapi_app.log
|
||||
```
|
||||
|
||||
## Log Format
|
||||
|
||||
### Console Output
|
||||
|
||||
```
|
||||
INFO: Starting FastAPI application...
|
||||
INFO: Server running on http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### File Output
|
||||
|
||||
```
|
||||
2025-10-25 17:31:19 - aniworld - INFO - Starting FastAPI application...
|
||||
2025-10-25 17:31:19 - aniworld - INFO - Server running on http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
## What Gets Logged
|
||||
|
||||
The application logs:
|
||||
|
||||
- **Startup/Shutdown**: Application lifecycle events
|
||||
- **Configuration**: Loaded settings and configuration
|
||||
- **HTTP Requests**: Via uvicorn.access logger
|
||||
- **Errors**: Exception tracebacks with full context
|
||||
- **WebSocket Events**: Connection/disconnection events
|
||||
- **Download Progress**: Progress updates for anime downloads
|
||||
- **File Operations**: File creation, deletion, scanning
|
||||
|
||||
## Logger Names
|
||||
|
||||
Different parts of the application use different logger names:
|
||||
|
||||
- `aniworld`: Main application logger
|
||||
- `uvicorn.error`: Uvicorn server errors
|
||||
- `uvicorn.access`: HTTP request logs
|
||||
- `src.core.SeriesApp`: Core anime logic
|
||||
- `src.core.SerieScanner`: File scanning operations
|
||||
- `src.server.*`: Web API endpoints and services
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
To use logging in your code:
|
||||
|
||||
```python
|
||||
from src.infrastructure.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.info("This is an info message")
|
||||
logger.warning("This is a warning")
|
||||
logger.error("This is an error", exc_info=True) # Includes traceback
|
||||
```
|
||||
|
||||
## Log Rotation
|
||||
|
||||
Log files can grow large over time. Consider implementing log rotation:
|
||||
|
||||
```bash
|
||||
# Archive old logs
|
||||
mkdir -p logs/archived
|
||||
mv logs/fastapi_app.log logs/archived/fastapi_app_$(date +%Y%m%d_%H%M%S).log
|
||||
```
|
||||
|
||||
Or use Python's `RotatingFileHandler` (can be added to `src/infrastructure/logging/logger.py`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No console output when using `conda run`
|
||||
|
||||
This is a known limitation of `conda run`. The logs are still being written to the file. To see console output:
|
||||
|
||||
1. Use the log file: `tail -f logs/fastapi_app.log`
|
||||
2. Or run without conda: `python run_server.py` (after activating environment with `conda activate AniWorld`)
|
||||
|
||||
### Log file not created
|
||||
|
||||
- Check that the `logs/` directory exists (it's created automatically)
|
||||
- Verify write permissions on the `logs/` directory
|
||||
- Check the `LOG_LEVEL` environment variable
|
||||
|
||||
### Too much logging
|
||||
|
||||
Set a higher log level:
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
### Missing logs
|
||||
|
||||
- Check that you're using the logger, not `print()`
|
||||
- Verify the log level is appropriate for your messages
|
||||
- Ensure the logger is properly configured (should happen automatically on startup)
|
||||
@ -1,450 +0,0 @@
|
||||
# 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)
|
||||
@ -1,628 +0,0 @@
|
||||
# Aniworld User Guide
|
||||
|
||||
Complete user guide for the Aniworld Download Manager web application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#getting-started)
|
||||
2. [Installation](#installation)
|
||||
3. [Initial Setup](#initial-setup)
|
||||
4. [User Interface](#user-interface)
|
||||
5. [Configuration](#configuration)
|
||||
6. [Managing Anime](#managing-anime)
|
||||
7. [Download Queue](#download-queue)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
9. [Keyboard Shortcuts](#keyboard-shortcuts)
|
||||
10. [FAQ](#faq)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Aniworld is a modern web application for managing and downloading anime series. It provides:
|
||||
|
||||
- **Web-based Interface**: Access via any modern web browser
|
||||
- **Real-time Updates**: Live download progress tracking
|
||||
- **Queue Management**: Organize and prioritize downloads
|
||||
- **Configuration Management**: Easy setup and configuration
|
||||
- **Backup & Restore**: Automatic configuration backups
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **OS**: Windows, macOS, or Linux
|
||||
- **Browser**: Chrome, Firefox, Safari, or Edge (modern versions)
|
||||
- **Internet**: Required for downloading anime
|
||||
- **Storage**: Sufficient space for anime files (adjustable)
|
||||
- **RAM**: Minimum 2GB recommended
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Poetry (Python package manager)
|
||||
- Git (for cloning the repository)
|
||||
|
||||
### Step-by-Step Installation
|
||||
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/aniworld.git
|
||||
cd aniworld
|
||||
```
|
||||
|
||||
#### 2. Create Python Environment
|
||||
|
||||
```bash
|
||||
# Using conda (recommended)
|
||||
conda create -n AniWorld python=3.10
|
||||
conda activate AniWorld
|
||||
|
||||
# Or using venv
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
#### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Using pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or using poetry
|
||||
poetry install
|
||||
```
|
||||
|
||||
#### 4. Start the Application
|
||||
|
||||
```bash
|
||||
# Using conda
|
||||
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
|
||||
|
||||
# Or directly
|
||||
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
#### 5. Access the Application
|
||||
|
||||
Open your browser and navigate to:
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### Setting Master Password
|
||||
|
||||
On first launch, you'll be prompted to set a master password:
|
||||
|
||||
1. **Navigate to Setup Page**: `http://localhost:8000/setup`
|
||||
2. **Enter Password**: Choose a strong password (minimum 8 characters recommended)
|
||||
3. **Confirm Password**: Re-enter the password for confirmation
|
||||
4. **Save**: Click "Set Master Password"
|
||||
|
||||
The master password protects access to your anime library and download settings.
|
||||
|
||||
### Configuration
|
||||
|
||||
After setting the master password, configure the application:
|
||||
|
||||
1. **Login**: Use your master password to log in
|
||||
2. **Go to Settings**: Click the settings icon in the navigation bar
|
||||
3. **Configure Directories**:
|
||||
|
||||
- **Anime Directory**: Where anime series are stored
|
||||
- **Download Directory**: Where downloads are saved
|
||||
- **Cache Directory**: Temporary file storage (optional)
|
||||
|
||||
4. **Advanced Settings** (optional):
|
||||
|
||||
- **Session Timeout**: How long before auto-logout
|
||||
- **Log Level**: Application logging detail level
|
||||
- **Theme**: Light or dark mode preference
|
||||
|
||||
5. **Save**: Click "Save Configuration"
|
||||
|
||||
### Automatic Backups
|
||||
|
||||
The application automatically creates backups when you update configuration. You can:
|
||||
|
||||
- View all backups in Settings → Backups
|
||||
- Manually create a backup anytime
|
||||
- Restore previous configuration versions
|
||||
- Delete old backups to save space
|
||||
|
||||
## User Interface
|
||||
|
||||
### Dashboard
|
||||
|
||||
The main dashboard shows:
|
||||
|
||||
- **Quick Stats**: Total anime, episodes, storage used
|
||||
- **Recent Activity**: Latest downloads and actions
|
||||
- **Quick Actions**: Add anime, manage queue, view settings
|
||||
|
||||
### Navigation
|
||||
|
||||
**Top Navigation Bar**:
|
||||
|
||||
- **Logo**: Return to dashboard
|
||||
- **Anime**: Browse and manage anime library
|
||||
- **Downloads**: View download queue and history
|
||||
- **Settings**: Configure application
|
||||
- **Account**: User menu (logout, profile)
|
||||
|
||||
### Theme
|
||||
|
||||
**Dark Mode / Light Mode**:
|
||||
|
||||
- Toggle theme in Settings
|
||||
- Theme preference is saved automatically
|
||||
- Default theme can be set in configuration
|
||||
|
||||
## Managing Anime
|
||||
|
||||
### Browsing Anime Library
|
||||
|
||||
1. **Click "Anime"** in navigation
|
||||
2. **View Anime List**: Shows all anime with missing episodes
|
||||
3. **Filter**: Filter by series status or search by name
|
||||
|
||||
### Adding New Anime
|
||||
|
||||
1. **Click "Add Anime"** button
|
||||
2. **Search**: Enter anime title or key
|
||||
3. **Select**: Choose anime from search results
|
||||
4. **Confirm**: Click "Add to Library"
|
||||
|
||||
### Viewing Anime Details
|
||||
|
||||
1. **Click Anime Title** in the list
|
||||
2. **View Information**: Episodes, status, total count
|
||||
3. **Add Episodes**: Select specific episodes to download
|
||||
|
||||
### Managing Episodes
|
||||
|
||||
**View Episodes**:
|
||||
|
||||
- All seasons and episodes for the series
|
||||
- Downloaded status indicators
|
||||
- File size information
|
||||
|
||||
**Download Episodes**:
|
||||
|
||||
1. Select episodes to download
|
||||
2. Click "Add to Queue"
|
||||
3. Choose priority (Low, Normal, High)
|
||||
4. Confirm
|
||||
|
||||
**Delete Episodes**:
|
||||
|
||||
1. Select downloaded episodes
|
||||
2. Click "Delete"
|
||||
3. Choose whether to keep or remove files
|
||||
4. Confirm
|
||||
|
||||
## Download Queue
|
||||
|
||||
### Queue Status
|
||||
|
||||
The queue page shows:
|
||||
|
||||
- **Queue Stats**: Total items, status breakdown
|
||||
- **Current Download**: What's downloading now
|
||||
- **Progress**: Download speed and time remaining
|
||||
- **Queue List**: All pending downloads
|
||||
|
||||
### Queue Management
|
||||
|
||||
### Add Episodes to Queue
|
||||
|
||||
1. Go to "Anime" or "Downloads"
|
||||
2. Select anime and episodes
|
||||
3. Click "Add to Queue"
|
||||
4. Set priority and confirm
|
||||
|
||||
### Manage Queue Items
|
||||
|
||||
**Pause/Resume**:
|
||||
|
||||
- Click pause icon to pause individual download
|
||||
- Resume when ready
|
||||
|
||||
**Prioritize**:
|
||||
|
||||
1. Click item in queue
|
||||
2. Select "Increase Priority" or "Decrease Priority"
|
||||
3. Items with higher priority download first
|
||||
|
||||
**Remove**:
|
||||
|
||||
1. Select item
|
||||
2. Click "Remove" button
|
||||
3. Confirm deletion
|
||||
|
||||
### Control Queue Processing
|
||||
|
||||
**Start Queue**: Begin downloading queued items
|
||||
|
||||
- Click "Start" button
|
||||
- Downloads begin in priority order
|
||||
|
||||
**Pause Queue**: Pause all downloads temporarily
|
||||
|
||||
- Click "Pause" button
|
||||
- Current download pauses
|
||||
- Click "Resume" to continue
|
||||
|
||||
**Stop Queue**: Stop all downloads
|
||||
|
||||
- Click "Stop" button
|
||||
- Current download stops
|
||||
- Queue items remain
|
||||
|
||||
**Clear Completed**: Remove completed items from queue
|
||||
|
||||
- Click "Clear Completed"
|
||||
- Frees up queue space
|
||||
|
||||
### Monitor Progress
|
||||
|
||||
**Real-time Updates**:
|
||||
|
||||
- Download speed (MB/s)
|
||||
- Progress percentage
|
||||
- Time remaining
|
||||
- Current file size
|
||||
|
||||
**Status Indicators**:
|
||||
|
||||
- 🔵 Pending: Waiting to download
|
||||
- 🟡 Downloading: Currently downloading
|
||||
- 🟢 Completed: Successfully downloaded
|
||||
- 🔴 Failed: Download failed
|
||||
|
||||
### Retry Failed Downloads
|
||||
|
||||
1. Find failed item in queue
|
||||
2. Click "Retry" button
|
||||
3. Item moves back to pending
|
||||
4. Download restarts when queue processes
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Settings
|
||||
|
||||
**Anime Directory**:
|
||||
|
||||
- Path where anime series are stored
|
||||
- Must be readable and writable
|
||||
- Can contain nested folders
|
||||
|
||||
**Download Directory**:
|
||||
|
||||
- Where new downloads are saved
|
||||
- Should have sufficient free space
|
||||
- Temporary files stored during download
|
||||
|
||||
**Session Timeout**:
|
||||
|
||||
- Minutes before automatic logout
|
||||
- Default: 1440 (24 hours)
|
||||
- Minimum: 15 minutes
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
**Log Level**:
|
||||
|
||||
- DEBUG: Verbose logging (development)
|
||||
- INFO: Standard information
|
||||
- WARNING: Warnings and errors
|
||||
- ERROR: Only errors
|
||||
|
||||
**Update Frequency**:
|
||||
|
||||
- How often to check for new episodes
|
||||
- Default: Daily
|
||||
- Options: Hourly, Daily, Weekly, Manual
|
||||
|
||||
**Provider Settings**:
|
||||
|
||||
- Anime provider configuration
|
||||
- Streaming server preferences
|
||||
- Retry attempts and timeouts
|
||||
|
||||
### Storage Management
|
||||
|
||||
**View Storage Statistics**:
|
||||
|
||||
- Total anime library size
|
||||
- Available disk space
|
||||
- Downloaded vs. pending size
|
||||
|
||||
**Manage Storage**:
|
||||
|
||||
1. Go to Settings → Storage
|
||||
2. View breakdown by series
|
||||
3. Delete old anime to free space
|
||||
|
||||
### Backup Management
|
||||
|
||||
**Create Backup**:
|
||||
|
||||
1. Go to Settings → Backups
|
||||
2. Click "Create Backup"
|
||||
3. Backup created with timestamp
|
||||
|
||||
**View Backups**:
|
||||
|
||||
- List of all configuration backups
|
||||
- Creation date and time
|
||||
- Size of each backup
|
||||
|
||||
**Restore from Backup**:
|
||||
|
||||
1. Click backup name
|
||||
2. Review changes
|
||||
3. Click "Restore"
|
||||
4. Application reloads with restored config
|
||||
|
||||
**Delete Backup**:
|
||||
|
||||
1. Select backup
|
||||
2. Click "Delete"
|
||||
3. Confirm deletion
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Can't Access Application
|
||||
|
||||
**Problem**: Browser shows "Connection Refused"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Verify application is running: Check terminal for startup messages
|
||||
- Check port: Application uses port 8000 by default
|
||||
- Try different port: Modify configuration if 8000 is in use
|
||||
- Firewall: Check if firewall is blocking port 8000
|
||||
|
||||
#### Login Issues
|
||||
|
||||
**Problem**: Can't log in or session expires
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Clear browser cookies: Settings → Clear browsing data
|
||||
- Try incognito mode: May help with cache issues
|
||||
- Reset master password: Delete `data/config.json` and restart
|
||||
- Check session timeout: Verify in settings
|
||||
|
||||
#### Download Failures
|
||||
|
||||
**Problem**: Downloads keep failing
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Check internet connection: Ensure stable connection
|
||||
- Verify provider: Check if anime provider is accessible
|
||||
- View error logs: Go to Settings → Logs for details
|
||||
- Retry download: Use "Retry" button on failed items
|
||||
- Contact provider: Provider might be down or blocking access
|
||||
|
||||
#### Slow Downloads
|
||||
|
||||
**Problem**: Downloads are very slow
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Check bandwidth: Other applications might be using internet
|
||||
- Provider issue: Provider might be throttling
|
||||
- Try different quality: Lower quality might download faster
|
||||
- Queue priority: Reduce queue size for faster downloads
|
||||
- Hardware: Ensure sufficient CPU and disk performance
|
||||
|
||||
#### Application Crashes
|
||||
|
||||
**Problem**: Application stops working
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Check logs: View logs in Settings → Logs
|
||||
- Restart application: Stop and restart the process
|
||||
- Clear cache: Delete temporary files in Settings
|
||||
- Reinstall: As last resort, reinstall application
|
||||
|
||||
### Error Messages
|
||||
|
||||
#### "Authentication Failed"
|
||||
|
||||
- Incorrect master password
|
||||
- Session expired (need to log in again)
|
||||
- Browser cookies cleared
|
||||
|
||||
#### "Configuration Error"
|
||||
|
||||
- Invalid directory path
|
||||
- Insufficient permissions
|
||||
- Disk space issues
|
||||
|
||||
#### "Download Error: Provider Error"
|
||||
|
||||
- Anime provider is down
|
||||
- Content no longer available
|
||||
- Streaming server error
|
||||
|
||||
#### "Database Error"
|
||||
|
||||
- Database file corrupted
|
||||
- Disk write permission denied
|
||||
- Low disk space
|
||||
|
||||
### Getting Help
|
||||
|
||||
**Check Application Logs**:
|
||||
|
||||
1. Go to Settings → Logs
|
||||
2. Search for error messages
|
||||
3. Check timestamp and context
|
||||
|
||||
**Review Documentation**:
|
||||
|
||||
- Check [API Reference](./api_reference.md)
|
||||
- Review [Deployment Guide](./deployment.md)
|
||||
- Consult inline code comments
|
||||
|
||||
**Community Support**:
|
||||
|
||||
- Check GitHub issues
|
||||
- Ask on forums or Discord
|
||||
- File bug report with logs
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### General
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------ | ------------------- |
|
||||
| `Ctrl+S` / `Cmd+S` | Save settings |
|
||||
| `Ctrl+L` / `Cmd+L` | Focus search |
|
||||
| `Escape` | Close dialogs |
|
||||
| `?` | Show shortcuts help |
|
||||
|
||||
### Anime Management
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------- |
|
||||
| `Ctrl+A` | Add new anime |
|
||||
| `Ctrl+F` | Search anime |
|
||||
| `Delete` | Remove anime |
|
||||
| `Enter` | View details |
|
||||
|
||||
### Download Queue
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------------- | ------------------- |
|
||||
| `Ctrl+D` | Add to queue |
|
||||
| `Space` | Play/Pause queue |
|
||||
| `Ctrl+Shift+P` | Pause all downloads |
|
||||
| `Ctrl+Shift+S` | Stop all downloads |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | --------------- |
|
||||
| `Ctrl+1` | Go to Dashboard |
|
||||
| `Ctrl+2` | Go to Anime |
|
||||
| `Ctrl+3` | Go to Downloads |
|
||||
| `Ctrl+4` | Go to Settings |
|
||||
|
||||
### Accessibility
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------- | ------------------------- |
|
||||
| `Tab` | Navigate between elements |
|
||||
| `Shift+Tab` | Navigate backwards |
|
||||
| `Alt+M` | Skip to main content |
|
||||
| `Alt+H` | Show help |
|
||||
|
||||
## FAQ
|
||||
|
||||
### General Questions
|
||||
|
||||
**Q: Is Aniworld free?**
|
||||
A: Yes, Aniworld is open-source and completely free to use.
|
||||
|
||||
**Q: Do I need internet connection?**
|
||||
A: Yes, to download anime. Once downloaded, you can watch offline.
|
||||
|
||||
**Q: What formats are supported?**
|
||||
A: Supports most video formats (MP4, MKV, AVI, etc.) depending on provider.
|
||||
|
||||
**Q: Can I use it on mobile?**
|
||||
A: The web interface works on mobile browsers, but is optimized for desktop.
|
||||
|
||||
### Installation & Setup
|
||||
|
||||
**Q: Can I run multiple instances?**
|
||||
A: Not recommended. Use single instance with same database.
|
||||
|
||||
**Q: Can I change installation directory?**
|
||||
A: Yes, reconfigure paths in Settings → Directories.
|
||||
|
||||
**Q: What if I forget my master password?**
|
||||
A: Delete `data/config.json` and restart (loses all settings).
|
||||
|
||||
### Downloads
|
||||
|
||||
**Q: How long do downloads take?**
|
||||
A: Depends on file size and internet speed. Typically 5-30 minutes per episode.
|
||||
|
||||
**Q: Can I pause/resume downloads?**
|
||||
A: Yes, pause individual items or entire queue.
|
||||
|
||||
**Q: What happens if download fails?**
|
||||
A: Item remains in queue. Use "Retry" to attempt again.
|
||||
|
||||
**Q: Can I download multiple episodes simultaneously?**
|
||||
A: Yes, configure concurrent downloads in settings.
|
||||
|
||||
### Storage
|
||||
|
||||
**Q: How much space do I need?**
|
||||
A: Depends on anime count. Plan for 500MB-2GB per episode.
|
||||
|
||||
**Q: Where are files stored?**
|
||||
A: In the configured "Anime Directory" in settings.
|
||||
|
||||
**Q: Can I move downloaded files?**
|
||||
A: Yes, but update the path in configuration afterwards.
|
||||
|
||||
### Performance
|
||||
|
||||
**Q: Application is slow, what can I do?**
|
||||
A: Reduce queue size, check disk space, restart application.
|
||||
|
||||
**Q: How do I free up storage?**
|
||||
A: Go to Settings → Storage and delete anime you no longer need.
|
||||
|
||||
**Q: Is there a way to optimize database?**
|
||||
A: Go to Settings → Maintenance and run database optimization.
|
||||
|
||||
### Support
|
||||
|
||||
**Q: Where can I report bugs?**
|
||||
A: File issues on GitHub repository.
|
||||
|
||||
**Q: How do I contribute?**
|
||||
A: See CONTRIBUTING.md for guidelines.
|
||||
|
||||
**Q: Where's the source code?**
|
||||
A: Available on GitHub (link in application footer).
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [API Reference](./api_reference.md) - For developers
|
||||
- [Deployment Guide](./deployment.md) - For system administrators
|
||||
- [GitHub Repository](https://github.com/your-repo/aniworld)
|
||||
- [Interactive API Documentation](http://localhost:8000/api/docs)
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For additional help:
|
||||
|
||||
1. Check this user guide first
|
||||
2. Review [Troubleshooting](#troubleshooting) section
|
||||
3. Check application logs in Settings
|
||||
4. File issue on GitHub
|
||||
5. Contact community forums
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: October 22, 2025
|
||||
**Version**: 1.0.0
|
||||
2169
infrastructure.md
2169
infrastructure.md
File diff suppressed because it is too large
Load Diff
1186
instructions.md
1186
instructions.md
File diff suppressed because it is too large
Load Diff
@ -65,7 +65,7 @@ class SerieScanner:
|
||||
raise ValueError(f"Base path is not a directory: {abs_path}")
|
||||
|
||||
self.directory: str = abs_path
|
||||
self.folderDict: dict[str, Serie] = {}
|
||||
self.keyDict: dict[str, Serie] = {}
|
||||
self.loader: Loader = loader
|
||||
self._callback_manager: CallbackManager = (
|
||||
callback_manager or CallbackManager()
|
||||
@ -80,8 +80,8 @@ class SerieScanner:
|
||||
return self._callback_manager
|
||||
|
||||
def reinit(self) -> None:
|
||||
"""Reinitialize the folder dictionary."""
|
||||
self.folderDict: dict[str, Serie] = {}
|
||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
||||
self.keyDict: dict[str, Serie] = {}
|
||||
|
||||
def get_total_to_scan(self) -> int:
|
||||
"""Get the total number of folders to scan.
|
||||
@ -187,12 +187,21 @@ class SerieScanner:
|
||||
)
|
||||
serie.save_to_file(data_path)
|
||||
|
||||
if serie.key in self.folderDict:
|
||||
# Store by key (primary identifier), not folder
|
||||
if serie.key in self.keyDict:
|
||||
logger.error(
|
||||
"Duplication found: %s", serie.key
|
||||
"Duplicate series found with key '%s' "
|
||||
"(folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
else:
|
||||
self.folderDict[serie.key] = serie
|
||||
self.keyDict[serie.key] = serie
|
||||
logger.debug(
|
||||
"Stored series with key '%s' (folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
no_key_found_logger.info(
|
||||
"Saved Serie: '%s'", str(serie)
|
||||
)
|
||||
@ -209,7 +218,7 @@ class SerieScanner:
|
||||
error=nkfe,
|
||||
message=error_msg,
|
||||
recoverable=True,
|
||||
metadata={"folder": folder}
|
||||
metadata={"folder": folder, "key": None}
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
@ -231,7 +240,7 @@ class SerieScanner:
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=True,
|
||||
metadata={"folder": folder}
|
||||
metadata={"folder": folder, "key": None}
|
||||
)
|
||||
)
|
||||
continue
|
||||
@ -245,7 +254,7 @@ class SerieScanner:
|
||||
message=f"Scan completed. Processed {counter} folders.",
|
||||
statistics={
|
||||
"total_folders": counter,
|
||||
"series_found": len(self.folderDict)
|
||||
"series_found": len(self.keyDict)
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -253,7 +262,7 @@ class SerieScanner:
|
||||
logger.info(
|
||||
"Scan completed. Processed %d folders, found %d series",
|
||||
counter,
|
||||
len(self.folderDict)
|
||||
len(self.keyDict)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@ -311,10 +320,15 @@ class SerieScanner:
|
||||
"""Read serie data from file or key file.
|
||||
|
||||
Args:
|
||||
folder_name: Name of the folder containing serie data
|
||||
folder_name: Filesystem folder name
|
||||
(used only to locate data files)
|
||||
|
||||
Returns:
|
||||
Serie object if found, None otherwise
|
||||
Serie object with valid key if found, None otherwise
|
||||
|
||||
Note:
|
||||
The returned Serie will have its 'key' as the primary identifier.
|
||||
The 'folder' field is metadata only.
|
||||
"""
|
||||
folder_path = os.path.join(self.directory, folder_name)
|
||||
key = None
|
||||
|
||||
@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
from events import Events
|
||||
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.providers.provider_factory import Loaders
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
|
||||
@ -28,6 +29,7 @@ class DownloadStatusEventArgs:
|
||||
season: int,
|
||||
episode: int,
|
||||
status: str,
|
||||
key: Optional[str] = None,
|
||||
progress: float = 0.0,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[Exception] = None,
|
||||
@ -39,10 +41,14 @@ class DownloadStatusEventArgs:
|
||||
Initialize download status event arguments.
|
||||
|
||||
Args:
|
||||
serie_folder: Serie folder name
|
||||
serie_folder: Serie folder name (metadata only, used for
|
||||
file paths)
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
status: Status message (e.g., "started", "progress", "completed", "failed")
|
||||
status: Status message (e.g., "started", "progress",
|
||||
"completed", "failed")
|
||||
key: Serie unique identifier (provider key, primary
|
||||
identifier)
|
||||
progress: Download progress (0.0 to 1.0)
|
||||
message: Optional status message
|
||||
error: Optional error if status is "failed"
|
||||
@ -51,6 +57,7 @@ class DownloadStatusEventArgs:
|
||||
item_id: Optional download queue item ID for tracking
|
||||
"""
|
||||
self.serie_folder = serie_folder
|
||||
self.key = key
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.status = status
|
||||
@ -61,6 +68,7 @@ class DownloadStatusEventArgs:
|
||||
self.mbper_sec = mbper_sec
|
||||
self.item_id = item_id
|
||||
|
||||
|
||||
class ScanStatusEventArgs:
|
||||
"""Event arguments for scan status events."""
|
||||
|
||||
@ -70,6 +78,7 @@ class ScanStatusEventArgs:
|
||||
total: int,
|
||||
folder: str,
|
||||
status: str,
|
||||
key: Optional[str] = None,
|
||||
progress: float = 0.0,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[Exception] = None,
|
||||
@ -80,8 +89,11 @@ class ScanStatusEventArgs:
|
||||
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")
|
||||
folder: Current folder being scanned (metadata only)
|
||||
status: Status message (e.g., "started", "progress",
|
||||
"completed", "failed", "cancelled")
|
||||
key: Serie unique identifier if applicable (provider key,
|
||||
primary identifier)
|
||||
progress: Scan progress (0.0 to 1.0)
|
||||
message: Optional status message
|
||||
error: Optional error if status is "failed"
|
||||
@ -89,11 +101,13 @@ class ScanStatusEventArgs:
|
||||
self.current = current
|
||||
self.total = total
|
||||
self.folder = folder
|
||||
self.key = key
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.message = message
|
||||
self.error = error
|
||||
|
||||
|
||||
class SeriesApp:
|
||||
"""
|
||||
Main application class for anime series management.
|
||||
@ -135,10 +149,14 @@ class SeriesApp:
|
||||
self.loader = self.loaders.GetLoader(key="aniworld.to")
|
||||
self.serie_scanner = SerieScanner(directory_to_search, self.loader)
|
||||
self.list = SerieList(self.directory_to_search)
|
||||
# Synchronous init used during constructor to avoid awaiting in __init__
|
||||
# Synchronous init used during constructor to avoid awaiting
|
||||
# in __init__
|
||||
self._init_list_sync()
|
||||
|
||||
logger.info("SeriesApp initialized for directory: %s", directory_to_search)
|
||||
logger.info(
|
||||
"SeriesApp initialized for directory: %s",
|
||||
directory_to_search
|
||||
)
|
||||
|
||||
@property
|
||||
def download_status(self):
|
||||
@ -173,13 +191,20 @@ class SeriesApp:
|
||||
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))
|
||||
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))
|
||||
|
||||
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]]:
|
||||
"""
|
||||
@ -212,22 +237,37 @@ class SeriesApp:
|
||||
Download an episode (async).
|
||||
|
||||
Args:
|
||||
serie_folder: Serie folder name
|
||||
serie_folder: Serie folder name (metadata only, used for
|
||||
file path construction)
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
key: Serie key
|
||||
key: Serie unique identifier (provider key, primary
|
||||
identifier for lookups)
|
||||
language: Language preference
|
||||
item_id: Optional download queue item ID for progress tracking
|
||||
item_id: Optional download queue item ID for progress
|
||||
tracking
|
||||
|
||||
Returns:
|
||||
True if download succeeded, False otherwise
|
||||
|
||||
Note:
|
||||
The 'key' parameter is the primary identifier for series
|
||||
lookups. The 'serie_folder' parameter is only used for
|
||||
filesystem operations.
|
||||
"""
|
||||
logger.info("Starting download: %s S%02dE%02d", serie_folder, season, episode)
|
||||
logger.info(
|
||||
"Starting download: %s (key: %s) S%02dE%02d",
|
||||
serie_folder,
|
||||
key,
|
||||
season,
|
||||
episode
|
||||
)
|
||||
|
||||
# Fire download started event
|
||||
self._events.download_status(
|
||||
DownloadStatusEventArgs(
|
||||
serie_folder=serie_folder,
|
||||
key=key,
|
||||
season=season,
|
||||
episode=episode,
|
||||
status="started",
|
||||
@ -238,7 +278,9 @@ class SeriesApp:
|
||||
|
||||
try:
|
||||
def download_callback(progress_info):
|
||||
logger.debug(f"wrapped_callback called with: {progress_info}")
|
||||
logger.debug(
|
||||
"wrapped_callback called with: %s", progress_info
|
||||
)
|
||||
|
||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||
total_bytes = (
|
||||
@ -253,11 +295,15 @@ class SeriesApp:
|
||||
self._events.download_status(
|
||||
DownloadStatusEventArgs(
|
||||
serie_folder=serie_folder,
|
||||
key=key,
|
||||
season=season,
|
||||
episode=episode,
|
||||
status="progress",
|
||||
message="Download progress",
|
||||
progress=(downloaded / total_bytes) * 100 if total_bytes else 0,
|
||||
progress=(
|
||||
(downloaded / total_bytes) * 100
|
||||
if total_bytes else 0
|
||||
),
|
||||
eta=eta,
|
||||
mbper_sec=mbper_sec,
|
||||
item_id=item_id,
|
||||
@ -277,13 +323,18 @@ class SeriesApp:
|
||||
|
||||
if download_success:
|
||||
logger.info(
|
||||
"Download completed: %s S%02dE%02d", serie_folder, season, episode
|
||||
"Download completed: %s (key: %s) S%02dE%02d",
|
||||
serie_folder,
|
||||
key,
|
||||
season,
|
||||
episode
|
||||
)
|
||||
|
||||
# Fire download completed event
|
||||
self._events.download_status(
|
||||
DownloadStatusEventArgs(
|
||||
serie_folder=serie_folder,
|
||||
key=key,
|
||||
season=season,
|
||||
episode=episode,
|
||||
status="completed",
|
||||
@ -294,13 +345,18 @@ class SeriesApp:
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Download failed: %s S%02dE%02d", serie_folder, season, episode
|
||||
"Download failed: %s (key: %s) S%02dE%02d",
|
||||
serie_folder,
|
||||
key,
|
||||
season,
|
||||
episode
|
||||
)
|
||||
|
||||
# Fire download failed event
|
||||
self._events.download_status(
|
||||
DownloadStatusEventArgs(
|
||||
serie_folder=serie_folder,
|
||||
key=key,
|
||||
season=season,
|
||||
episode=episode,
|
||||
status="failed",
|
||||
@ -313,8 +369,9 @@ class SeriesApp:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Download error: %s S%02dE%02d - %s",
|
||||
"Download error: %s (key: %s) S%02dE%02d - %s",
|
||||
serie_folder,
|
||||
key,
|
||||
season,
|
||||
episode,
|
||||
str(e),
|
||||
@ -325,6 +382,7 @@ class SeriesApp:
|
||||
self._events.download_status(
|
||||
DownloadStatusEventArgs(
|
||||
serie_folder=serie_folder,
|
||||
key=key,
|
||||
season=season,
|
||||
episode=episode,
|
||||
status="failed",
|
||||
@ -347,7 +405,9 @@ class SeriesApp:
|
||||
|
||||
try:
|
||||
# Get total items to scan
|
||||
total_to_scan = await asyncio.to_thread(self.serie_scanner.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)
|
||||
|
||||
# Fire scan started event
|
||||
@ -401,7 +461,10 @@ class SeriesApp:
|
||||
folder="",
|
||||
status="completed",
|
||||
progress=1.0,
|
||||
message=f"Scan completed. Found {len(self.series_list)} series with missing episodes.",
|
||||
message=(
|
||||
f"Scan completed. Found {len(self.series_list)} "
|
||||
"series with missing episodes."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@ -448,5 +511,28 @@ class SeriesApp:
|
||||
return self.series_list
|
||||
|
||||
async def refresh_series_list(self) -> None:
|
||||
"""Reload the cached series list from the underlying data store (async)."""
|
||||
"""
|
||||
Reload the cached series list from the underlying data store.
|
||||
|
||||
This is an async operation.
|
||||
"""
|
||||
await self._init_list()
|
||||
|
||||
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
|
||||
"""
|
||||
Get a series by its unique provider key.
|
||||
|
||||
This is the primary method for series lookups within SeriesApp.
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier (e.g.,
|
||||
"attack-on-titan")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
|
||||
Note:
|
||||
This method uses the SerieList.get_by_key() method which
|
||||
looks up series by their unique key, not by folder name.
|
||||
"""
|
||||
return self.list.get_by_key(key)
|
||||
|
||||
@ -2,23 +2,37 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from json import JSONDecodeError
|
||||
from typing import Dict, Iterable, List
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
|
||||
class SerieList:
|
||||
"""Represents the collection of cached series stored on disk."""
|
||||
"""
|
||||
Represents the collection of cached series stored on disk.
|
||||
|
||||
Series are identified by their unique 'key' (provider identifier).
|
||||
The 'folder' is metadata only and not used for lookups.
|
||||
"""
|
||||
|
||||
def __init__(self, base_path: str) -> None:
|
||||
self.directory: str = base_path
|
||||
self.folderDict: Dict[str, Serie] = {}
|
||||
# Internal storage using serie.key as the dictionary key
|
||||
self.keyDict: Dict[str, Serie] = {}
|
||||
self.load_series()
|
||||
|
||||
def add(self, serie: Serie) -> None:
|
||||
"""Persist a new series if it is not already present."""
|
||||
"""
|
||||
Persist a new series if it is not already present.
|
||||
|
||||
Uses serie.key for identification. The serie.folder is used for
|
||||
filesystem operations only.
|
||||
|
||||
Args:
|
||||
serie: The Serie instance to add
|
||||
"""
|
||||
if self.contains(serie.key):
|
||||
return
|
||||
|
||||
@ -27,12 +41,20 @@ class SerieList:
|
||||
os.makedirs(anime_path, exist_ok=True)
|
||||
if not os.path.isfile(data_path):
|
||||
serie.save_to_file(data_path)
|
||||
self.folderDict[serie.folder] = serie
|
||||
# Store by key, not folder
|
||||
self.keyDict[serie.key] = serie
|
||||
|
||||
def contains(self, key: str) -> bool:
|
||||
"""Return True when a series identified by ``key`` already exists."""
|
||||
"""
|
||||
Return True when a series identified by ``key`` already exists.
|
||||
|
||||
return any(value.key == key for value in self.folderDict.values())
|
||||
Args:
|
||||
key: The unique provider identifier for the series
|
||||
|
||||
Returns:
|
||||
True if the series exists in the collection
|
||||
"""
|
||||
return key in self.keyDict
|
||||
|
||||
def load_series(self) -> None:
|
||||
"""Populate the in-memory map with metadata discovered on disk."""
|
||||
@ -61,11 +83,22 @@ class SerieList:
|
||||
)
|
||||
|
||||
def _load_data(self, anime_folder: str, data_path: str) -> None:
|
||||
"""Load a single series metadata file into the in-memory collection."""
|
||||
"""
|
||||
Load a single series metadata file into the in-memory collection.
|
||||
|
||||
Args:
|
||||
anime_folder: The folder name (for logging only)
|
||||
data_path: Path to the metadata file
|
||||
"""
|
||||
try:
|
||||
self.folderDict[anime_folder] = Serie.load_from_file(data_path)
|
||||
logging.debug("Successfully loaded metadata for %s", anime_folder)
|
||||
serie = Serie.load_from_file(data_path)
|
||||
# Store by key, not folder
|
||||
self.keyDict[serie.key] = serie
|
||||
logging.debug(
|
||||
"Successfully loaded metadata for %s (key: %s)",
|
||||
anime_folder,
|
||||
serie.key
|
||||
)
|
||||
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
||||
logging.error(
|
||||
"Failed to load metadata for folder %s from %s: %s",
|
||||
@ -76,24 +109,63 @@ class SerieList:
|
||||
|
||||
def GetMissingEpisode(self) -> List[Serie]:
|
||||
"""Return all series that still contain missing episodes."""
|
||||
|
||||
return [
|
||||
serie
|
||||
for serie in self.folderDict.values()
|
||||
for serie in self.keyDict.values()
|
||||
if serie.episodeDict
|
||||
]
|
||||
|
||||
def get_missing_episodes(self) -> List[Serie]:
|
||||
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
||||
|
||||
return self.GetMissingEpisode()
|
||||
|
||||
def GetList(self) -> List[Serie]:
|
||||
"""Return all series instances stored in the list."""
|
||||
|
||||
return list(self.folderDict.values())
|
||||
return list(self.keyDict.values())
|
||||
|
||||
def get_all(self) -> List[Serie]:
|
||||
"""PEP8-friendly alias for :meth:`GetList`."""
|
||||
|
||||
return self.GetList()
|
||||
|
||||
def get_by_key(self, key: str) -> Optional[Serie]:
|
||||
"""
|
||||
Get a series by its unique provider key.
|
||||
|
||||
This is the primary method for series lookup.
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier (e.g., "attack-on-titan")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
"""
|
||||
return self.keyDict.get(key)
|
||||
|
||||
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
||||
"""
|
||||
Get a series by its folder name.
|
||||
|
||||
.. deprecated:: 2.0.0
|
||||
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
||||
removed in version 3.0.0. The `folder` field is metadata only
|
||||
and should not be used for identification.
|
||||
|
||||
This method is provided for backward compatibility only.
|
||||
Prefer using get_by_key() for new code.
|
||||
|
||||
Args:
|
||||
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
"""
|
||||
warnings.warn(
|
||||
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
||||
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
for serie in self.keyDict.values():
|
||||
if serie.folder == folder:
|
||||
return serie
|
||||
return None
|
||||
|
||||
@ -1,23 +1,82 @@
|
||||
import json
|
||||
|
||||
|
||||
class Serie:
|
||||
def __init__(self, key: str, name: str, site: str, folder: str, episodeDict: dict[int, list[int]]):
|
||||
self._key = key
|
||||
"""
|
||||
Represents an anime series with metadata and episode information.
|
||||
|
||||
The `key` property is the unique identifier for the series
|
||||
(provider-assigned, URL-safe).
|
||||
The `folder` property is the filesystem folder name
|
||||
(metadata only, not used for lookups).
|
||||
|
||||
Args:
|
||||
key: Unique series identifier from provider
|
||||
(e.g., "attack-on-titan"). Cannot be empty.
|
||||
name: Display name of the series
|
||||
site: Provider site URL
|
||||
folder: Filesystem folder name (metadata only,
|
||||
e.g., "Attack on Titan (2013)")
|
||||
episodeDict: Dictionary mapping season numbers to
|
||||
lists of episode numbers
|
||||
|
||||
Raises:
|
||||
ValueError: If key is None or empty string
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
name: str,
|
||||
site: str,
|
||||
folder: str,
|
||||
episodeDict: dict[int, list[int]]
|
||||
):
|
||||
if not key or not key.strip():
|
||||
raise ValueError("Serie key cannot be None or empty")
|
||||
|
||||
self._key = key.strip()
|
||||
self._name = name
|
||||
self._site = site
|
||||
self._folder = folder
|
||||
self._episodeDict = episodeDict
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of Serie object"""
|
||||
return f"Serie(key='{self.key}', name='{self.name}', site='{self.site}', folder='{self.folder}', episodeDict={self.episodeDict})"
|
||||
return (
|
||||
f"Serie(key='{self.key}', name='{self.name}', "
|
||||
f"site='{self.site}', folder='{self.folder}', "
|
||||
f"episodeDict={self.episodeDict})"
|
||||
)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""
|
||||
Unique series identifier (primary identifier for all lookups).
|
||||
|
||||
This is the provider-assigned, URL-safe identifier used
|
||||
throughout the application for series identification,
|
||||
lookups, and operations.
|
||||
|
||||
Returns:
|
||||
str: The unique series key
|
||||
"""
|
||||
return self._key
|
||||
|
||||
@key.setter
|
||||
def key(self, value: str):
|
||||
self._key = value
|
||||
"""
|
||||
Set the unique series identifier.
|
||||
|
||||
Args:
|
||||
value: New key value
|
||||
|
||||
Raises:
|
||||
ValueError: If value is None or empty string
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
raise ValueError("Serie key cannot be None or empty")
|
||||
self._key = value.strip()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -37,10 +96,26 @@ class Serie:
|
||||
|
||||
@property
|
||||
def folder(self) -> str:
|
||||
"""
|
||||
Filesystem folder name (metadata only, not used for lookups).
|
||||
|
||||
This property contains the local directory name where the series
|
||||
files are stored. It should NOT be used as an identifier for
|
||||
series lookups - use `key` instead.
|
||||
|
||||
Returns:
|
||||
str: The filesystem folder name
|
||||
"""
|
||||
return self._folder
|
||||
|
||||
@folder.setter
|
||||
def folder(self, value: str):
|
||||
"""
|
||||
Set the filesystem folder name.
|
||||
|
||||
Args:
|
||||
value: Folder name for the series
|
||||
"""
|
||||
self._folder = value
|
||||
|
||||
@property
|
||||
@ -58,25 +133,34 @@ class Serie:
|
||||
"name": self.name,
|
||||
"site": self.site,
|
||||
"folder": self.folder,
|
||||
"episodeDict": {str(k): list(v) for k, v in self.episodeDict.items()}
|
||||
"episodeDict": {
|
||||
str(k): list(v) for k, v in self.episodeDict.items()
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict):
|
||||
"""Create a Serie object from dictionary."""
|
||||
episode_dict = {int(k): v for k, v in data["episodeDict"].items()} # Convert keys to int
|
||||
return Serie(data["key"], data["name"], data["site"], data["folder"], episode_dict)
|
||||
|
||||
# Convert keys to int
|
||||
episode_dict = {
|
||||
int(k): v for k, v in data["episodeDict"].items()
|
||||
}
|
||||
return Serie(
|
||||
data["key"],
|
||||
data["name"],
|
||||
data["site"],
|
||||
data["folder"],
|
||||
episode_dict
|
||||
)
|
||||
|
||||
def save_to_file(self, filename: str):
|
||||
"""Save Serie object to JSON file."""
|
||||
with open(filename, "w") as file:
|
||||
with open(filename, "w", encoding="utf-8") as file:
|
||||
json.dump(self.to_dict(), file, indent=4)
|
||||
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, filename: str) -> "Serie":
|
||||
"""Load Serie object from JSON file."""
|
||||
with open(filename, "r") as file:
|
||||
with open(filename, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
return cls.from_dict(data)
|
||||
@ -47,6 +47,8 @@ class ProgressContext:
|
||||
percentage: Completion percentage (0.0 to 100.0)
|
||||
message: Human-readable progress message
|
||||
details: Additional context-specific details
|
||||
key: Provider-assigned series identifier (None when not applicable)
|
||||
folder: Optional folder metadata for display purposes only
|
||||
metadata: Extra metadata for specialized use cases
|
||||
"""
|
||||
|
||||
@ -58,6 +60,8 @@ class ProgressContext:
|
||||
percentage: float
|
||||
message: str
|
||||
details: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
folder: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@ -71,6 +75,8 @@ class ProgressContext:
|
||||
"percentage": round(self.percentage, 2),
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
"key": self.key,
|
||||
"folder": self.folder,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@ -87,6 +93,8 @@ class ErrorContext:
|
||||
message: Human-readable error message
|
||||
recoverable: Whether the error is recoverable
|
||||
retry_count: Number of retry attempts made
|
||||
key: Provider-assigned series identifier (None when not applicable)
|
||||
folder: Optional folder metadata for display purposes only
|
||||
metadata: Additional error context
|
||||
"""
|
||||
|
||||
@ -96,6 +104,8 @@ class ErrorContext:
|
||||
message: str
|
||||
recoverable: bool = False
|
||||
retry_count: int = 0
|
||||
key: Optional[str] = None
|
||||
folder: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@ -108,6 +118,8 @@ class ErrorContext:
|
||||
"message": self.message,
|
||||
"recoverable": self.recoverable,
|
||||
"retry_count": self.retry_count,
|
||||
"key": self.key,
|
||||
"folder": self.folder,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@ -124,6 +136,8 @@ class CompletionContext:
|
||||
message: Human-readable completion message
|
||||
result_data: Result data from the operation
|
||||
statistics: Operation statistics (duration, items processed, etc.)
|
||||
key: Provider-assigned series identifier (None when not applicable)
|
||||
folder: Optional folder metadata for display purposes only
|
||||
metadata: Additional completion context
|
||||
"""
|
||||
|
||||
@ -133,6 +147,8 @@ class CompletionContext:
|
||||
message: str
|
||||
result_data: Optional[Any] = None
|
||||
statistics: Dict[str, Any] = field(default_factory=dict)
|
||||
key: Optional[str] = None
|
||||
folder: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@ -143,6 +159,8 @@ class CompletionContext:
|
||||
"success": self.success,
|
||||
"message": self.message,
|
||||
"statistics": self.statistics,
|
||||
"key": self.key,
|
||||
"folder": self.folder,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@ -208,8 +208,26 @@ class AniworldLoader(Loader):
|
||||
language: str = "German Dub",
|
||||
progress_callback=None
|
||||
) -> bool:
|
||||
"""Download episode to specified directory."""
|
||||
logging.info(f"Starting download for S{season:02}E{episode:03} ({key}) in {language}")
|
||||
"""Download episode to specified directory.
|
||||
|
||||
Args:
|
||||
base_directory: Base download directory path
|
||||
serie_folder: Filesystem folder name (metadata only, used for
|
||||
file path construction)
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
key: Series unique identifier from provider (used for
|
||||
identification and API calls)
|
||||
language: Audio language preference (default: German Dub)
|
||||
progress_callback: Optional callback for download progress
|
||||
|
||||
Returns:
|
||||
bool: True if download succeeded, False otherwise
|
||||
"""
|
||||
logging.info(
|
||||
f"Starting download for S{season:02}E{episode:03} "
|
||||
f"({key}) in {language}"
|
||||
)
|
||||
sanitized_anime_title = ''.join(
|
||||
char for char in self.get_title(key)
|
||||
if char not in self.INVALID_PATH_CHARS
|
||||
|
||||
@ -349,7 +349,27 @@ class EnhancedAniWorldLoader(Loader):
|
||||
language: str = "German Dub",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Download episode with comprehensive error handling."""
|
||||
"""Download episode with comprehensive error handling.
|
||||
|
||||
Args:
|
||||
baseDirectory: Base download directory path
|
||||
serieFolder: Filesystem folder name (metadata only, used for
|
||||
file path construction)
|
||||
season: Season number (0 for movies)
|
||||
episode: Episode number
|
||||
key: Series unique identifier from provider (used for
|
||||
identification and API calls)
|
||||
language: Audio language preference (default: German Dub)
|
||||
progress_callback: Optional callback for download progress
|
||||
updates
|
||||
|
||||
Returns:
|
||||
bool: True if download succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
DownloadError: If download fails after all retry attempts
|
||||
ValueError: If required parameters are missing or invalid
|
||||
"""
|
||||
self.download_stats["total_downloads"] += 1
|
||||
|
||||
try:
|
||||
|
||||
@ -1,10 +1,56 @@
|
||||
"""Provider factory for managing anime content providers.
|
||||
|
||||
This module provides a factory class for accessing different anime content
|
||||
providers (loaders). The factory uses provider identifiers (keys) to return
|
||||
the appropriate provider instance.
|
||||
|
||||
Note: The 'key' parameter in this factory refers to the provider identifier
|
||||
(e.g., 'aniworld.to'), not to be confused with series keys used within
|
||||
providers to identify specific anime series.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from .aniworld_provider import AniworldLoader
|
||||
from .base_provider import Loader
|
||||
|
||||
class Loaders:
|
||||
|
||||
def __init__(self):
|
||||
self.dict = {"aniworld.to": AniworldLoader()}
|
||||
class Loaders:
|
||||
"""Factory class for managing and retrieving anime content providers.
|
||||
|
||||
This factory maintains a registry of available providers and provides
|
||||
access to them via provider keys. Each provider implements the Loader
|
||||
interface for searching and downloading anime content.
|
||||
|
||||
Attributes:
|
||||
dict: Dictionary mapping provider keys to provider instances.
|
||||
Provider keys are site identifiers (e.g., 'aniworld.to').
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the provider factory with available providers.
|
||||
|
||||
Currently supports:
|
||||
- 'aniworld.to': AniworldLoader for aniworld.to content
|
||||
"""
|
||||
self.dict: Dict[str, Loader] = {"aniworld.to": AniworldLoader()}
|
||||
|
||||
def GetLoader(self, key: str) -> Loader:
|
||||
"""Retrieve a provider instance by its provider key.
|
||||
|
||||
Args:
|
||||
key: Provider identifier (e.g., 'aniworld.to').
|
||||
This is the site/provider key, not a series key.
|
||||
|
||||
Returns:
|
||||
Loader instance for the specified provider.
|
||||
|
||||
Raises:
|
||||
KeyError: If the provider key is not found in the registry.
|
||||
|
||||
Note:
|
||||
The 'key' parameter here identifies the provider/site, while
|
||||
series-specific operations on the returned Loader use series
|
||||
keys to identify individual anime series.
|
||||
"""
|
||||
return self.dict[key]
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import warnings
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@ -11,6 +13,8 @@ from src.server.utils.dependencies import (
|
||||
require_auth,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
||||
|
||||
|
||||
@ -55,13 +59,46 @@ async def get_anime_status(
|
||||
|
||||
|
||||
class AnimeSummary(BaseModel):
|
||||
"""Summary of an anime series with missing episodes."""
|
||||
key: str # Unique identifier (used as id in frontend)
|
||||
name: str # Series name (can be empty)
|
||||
site: str # Provider site
|
||||
folder: str # Local folder name
|
||||
missing_episodes: dict # Episode dictionary: {season: [episode_numbers]}
|
||||
link: Optional[str] = "" # Link to the series page (for adding new series)
|
||||
"""Summary of an anime series with missing episodes.
|
||||
|
||||
The `key` field is the unique provider-assigned identifier used for all
|
||||
lookups and operations (URL-safe, e.g., "attack-on-titan").
|
||||
|
||||
The `folder` field is metadata only for filesystem operations and display
|
||||
(e.g., "Attack on Titan (2013)") - not used for identification.
|
||||
|
||||
Attributes:
|
||||
key: Unique series identifier (primary key for all operations)
|
||||
name: Display name of the series
|
||||
site: Provider site URL
|
||||
folder: Filesystem folder name (metadata only)
|
||||
missing_episodes: Episode dictionary mapping seasons to episode numbers
|
||||
link: Optional link to the series page (used when adding new series)
|
||||
"""
|
||||
key: str = Field(
|
||||
...,
|
||||
description="Unique series identifier (primary key for all operations)"
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Display name of the series"
|
||||
)
|
||||
site: str = Field(
|
||||
...,
|
||||
description="Provider site URL"
|
||||
)
|
||||
folder: str = Field(
|
||||
...,
|
||||
description="Filesystem folder name (metadata, not for lookups)"
|
||||
)
|
||||
missing_episodes: dict = Field(
|
||||
...,
|
||||
description="Episode dictionary: {season: [episode_numbers]}"
|
||||
)
|
||||
link: Optional[str] = Field(
|
||||
default="",
|
||||
description="Link to the series page (for adding new series)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic model configuration."""
|
||||
@ -78,10 +115,52 @@ class AnimeSummary(BaseModel):
|
||||
|
||||
|
||||
class AnimeDetail(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
episodes: List[str]
|
||||
description: Optional[str] = None
|
||||
"""Detailed information about a specific anime series.
|
||||
|
||||
The `key` field is the unique provider-assigned identifier used for all
|
||||
lookups and operations (URL-safe, e.g., "attack-on-titan").
|
||||
|
||||
The `folder` field is metadata only for filesystem operations and display.
|
||||
|
||||
Attributes:
|
||||
key: Unique series identifier (primary key for all operations)
|
||||
title: Display name of the series
|
||||
folder: Filesystem folder name (metadata only)
|
||||
episodes: List of episode identifiers in "season-episode" format
|
||||
description: Optional description of the series
|
||||
"""
|
||||
key: str = Field(
|
||||
...,
|
||||
description="Unique series identifier (primary key for all operations)"
|
||||
)
|
||||
title: str = Field(
|
||||
...,
|
||||
description="Display name of the series"
|
||||
)
|
||||
folder: str = Field(
|
||||
default="",
|
||||
description="Filesystem folder name (metadata, not for lookups)"
|
||||
)
|
||||
episodes: List[str] = Field(
|
||||
...,
|
||||
description="List of episode identifiers in 'season-episode' format"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional description of the series"
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic model configuration."""
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"key": "attack-on-titan",
|
||||
"title": "Attack on Titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"episodes": ["1-1", "1-2", "1-3"],
|
||||
"description": "Humans fight against giant humanoid Titans."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AnimeSummary])
|
||||
@ -96,19 +175,30 @@ async def list_anime(
|
||||
) -> List[AnimeSummary]:
|
||||
"""List library series that still have missing episodes.
|
||||
|
||||
Returns AnimeSummary objects where `key` is the primary identifier
|
||||
used for all operations. The `folder` field is metadata only and
|
||||
should not be used for lookups.
|
||||
|
||||
Args:
|
||||
page: Page number for pagination (must be positive)
|
||||
per_page: Items per page (must be positive, max 1000)
|
||||
sort_by: Optional sorting parameter (validated for security)
|
||||
sort_by: Optional sorting parameter. Allowed: title, id, name,
|
||||
missing_episodes
|
||||
filter: Optional filter parameter (validated for security)
|
||||
_auth: Ensures the caller is authenticated (value unused)
|
||||
series_app: Core SeriesApp instance provided via dependency.
|
||||
|
||||
Returns:
|
||||
List[AnimeSummary]: Summary entries describing missing content.
|
||||
List[AnimeSummary]: Summary entries with `key` as primary identifier.
|
||||
Each entry includes:
|
||||
- key: Unique series identifier (use for all operations)
|
||||
- name: Display name
|
||||
- site: Provider site
|
||||
- folder: Filesystem folder name (metadata only)
|
||||
- missing_episodes: Dict mapping seasons to episode numbers
|
||||
|
||||
Raises:
|
||||
HTTPException: When the underlying lookup fails or params are invalid.
|
||||
HTTPException: When the underlying lookup fails or params invalid.
|
||||
"""
|
||||
# Validate pagination parameters
|
||||
if page is not None:
|
||||
@ -336,12 +426,15 @@ async def search_anime_get(
|
||||
) -> List[AnimeSummary]:
|
||||
"""Search the provider for additional series matching a query (GET).
|
||||
|
||||
Returns AnimeSummary objects where `key` is the primary identifier.
|
||||
Use the `key` field for subsequent operations (add, download, etc.).
|
||||
|
||||
Args:
|
||||
query: Search term passed as query parameter
|
||||
series_app: Optional SeriesApp instance provided via dependency.
|
||||
|
||||
Returns:
|
||||
List[AnimeSummary]: Discovered matches returned from the provider.
|
||||
List[AnimeSummary]: Discovered matches with `key` as identifier.
|
||||
|
||||
Raises:
|
||||
HTTPException: When provider communication fails or query is invalid.
|
||||
@ -359,12 +452,15 @@ async def search_anime_post(
|
||||
) -> List[AnimeSummary]:
|
||||
"""Search the provider for additional series matching a query (POST).
|
||||
|
||||
Returns AnimeSummary objects where `key` is the primary identifier.
|
||||
Use the `key` field for subsequent operations (add, download, etc.).
|
||||
|
||||
Args:
|
||||
request: Request containing the search query
|
||||
series_app: Optional SeriesApp instance provided via dependency.
|
||||
|
||||
Returns:
|
||||
List[AnimeSummary]: Discovered matches returned from the provider.
|
||||
List[AnimeSummary]: Discovered matches with `key` as identifier.
|
||||
|
||||
Raises:
|
||||
HTTPException: When provider communication fails or query is invalid.
|
||||
@ -376,14 +472,28 @@ async def _perform_search(
|
||||
query: str,
|
||||
series_app: Optional[Any],
|
||||
) -> List[AnimeSummary]:
|
||||
"""Internal function to perform the search logic.
|
||||
"""Search for anime series matching the given query.
|
||||
|
||||
This internal function performs the actual search logic, extracting
|
||||
results from the provider and converting them to AnimeSummary objects.
|
||||
|
||||
The returned summaries use `key` as the primary identifier. The `key`
|
||||
is extracted from the result's key field (preferred) or derived from
|
||||
the link URL if not available. The `folder` field is metadata only.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
series_app: Optional SeriesApp instance.
|
||||
query: Search term (will be validated and sanitized)
|
||||
series_app: Optional SeriesApp instance for search.
|
||||
|
||||
Returns:
|
||||
List[AnimeSummary]: Discovered matches returned from the provider.
|
||||
List[AnimeSummary]: Discovered matches with `key` as identifier
|
||||
and `folder` as metadata. Each summary includes:
|
||||
- key: Unique series identifier (primary)
|
||||
- name: Display name
|
||||
- site: Provider site
|
||||
- folder: Filesystem folder name (metadata)
|
||||
- link: URL to series page
|
||||
- missing_episodes: Episode dictionary
|
||||
|
||||
Raises:
|
||||
HTTPException: When provider communication fails or query is invalid.
|
||||
@ -406,7 +516,8 @@ async def _perform_search(
|
||||
summaries: List[AnimeSummary] = []
|
||||
for match in matches:
|
||||
if isinstance(match, dict):
|
||||
identifier = match.get("key") or match.get("id") or ""
|
||||
# Extract key (primary identifier)
|
||||
key = match.get("key") or match.get("id") or ""
|
||||
title = match.get("title") or match.get("name") or ""
|
||||
site = match.get("site") or ""
|
||||
folder = match.get("folder") or ""
|
||||
@ -416,17 +527,38 @@ async def _perform_search(
|
||||
or match.get("missing")
|
||||
or {}
|
||||
)
|
||||
|
||||
# If key is empty, try to extract from link
|
||||
if not key and link:
|
||||
if "/anime/stream/" in link:
|
||||
key = link.split("/anime/stream/")[-1].split("/")[0]
|
||||
elif link and "/" not in link:
|
||||
# Link is just a slug (e.g., "attack-on-titan")
|
||||
key = link
|
||||
else:
|
||||
identifier = getattr(match, "key", getattr(match, "id", ""))
|
||||
title = getattr(match, "title", getattr(match, "name", ""))
|
||||
# Extract key (primary identifier)
|
||||
key = getattr(match, "key", "") or getattr(match, "id", "")
|
||||
title = getattr(match, "title", "") or getattr(
|
||||
match, "name", ""
|
||||
)
|
||||
site = getattr(match, "site", "")
|
||||
folder = getattr(match, "folder", "")
|
||||
link = getattr(match, "link", getattr(match, "url", ""))
|
||||
link = getattr(match, "link", "") or getattr(
|
||||
match, "url", ""
|
||||
)
|
||||
missing = getattr(match, "missing_episodes", {})
|
||||
|
||||
# If key is empty, try to extract from link
|
||||
if not key and link:
|
||||
if "/anime/stream/" in link:
|
||||
key = link.split("/anime/stream/")[-1].split("/")[0]
|
||||
elif link and "/" not in link:
|
||||
# Link is just a slug (e.g., "attack-on-titan")
|
||||
key = link
|
||||
|
||||
summaries.append(
|
||||
AnimeSummary(
|
||||
key=identifier,
|
||||
key=key,
|
||||
name=title,
|
||||
site=site,
|
||||
folder=folder,
|
||||
@ -453,16 +585,23 @@ async def add_series(
|
||||
) -> dict:
|
||||
"""Add a new series to the library.
|
||||
|
||||
Extracts the series `key` from the provided link URL.
|
||||
The `key` is the URL-safe identifier used for all lookups.
|
||||
The `name` is stored as display metadata along with a
|
||||
filesystem-friendly `folder` name derived from the name.
|
||||
|
||||
Args:
|
||||
request: Request containing the series link and name
|
||||
request: Request containing the series link and name.
|
||||
- link: URL to the series (e.g., aniworld.to/anime/stream/key)
|
||||
- name: Display name for the series
|
||||
_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
|
||||
Dict[str, Any]: Status payload with success message and key
|
||||
|
||||
Raises:
|
||||
HTTPException: If adding the series fails
|
||||
HTTPException: If adding the series fails or link is invalid
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
@ -485,16 +624,39 @@ async def add_series(
|
||||
detail="Series list functionality not available",
|
||||
)
|
||||
|
||||
# Extract key from link URL
|
||||
# Expected format: https://aniworld.to/anime/stream/{key}
|
||||
link = request.link.strip()
|
||||
key = link
|
||||
|
||||
# Try to extract key from URL path
|
||||
if "/anime/stream/" in link:
|
||||
# Extract everything after /anime/stream/
|
||||
key = link.split("/anime/stream/")[-1].split("/")[0].strip()
|
||||
elif "/" in link:
|
||||
# Fallback: use last path segment
|
||||
key = link.rstrip("/").split("/")[-1].strip()
|
||||
|
||||
# Validate extracted key
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not extract series key from link",
|
||||
)
|
||||
|
||||
# Create folder from name (filesystem-friendly)
|
||||
folder = request.name.strip()
|
||||
|
||||
# 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
|
||||
# key: unique identifier extracted from link
|
||||
# name: display name from request
|
||||
# folder: filesystem folder name (derived from name)
|
||||
# episodeDict: empty for new series
|
||||
serie = Serie(
|
||||
key=request.link.strip(),
|
||||
key=key,
|
||||
name=request.name.strip(),
|
||||
site="aniworld.to",
|
||||
folder=request.name.strip(),
|
||||
folder=folder,
|
||||
episodeDict={}
|
||||
)
|
||||
|
||||
@ -507,7 +669,9 @@ async def add_series(
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully added series: {request.name}"
|
||||
"message": f"Successfully added series: {request.name}",
|
||||
"key": key,
|
||||
"folder": folder
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
@ -525,12 +689,18 @@ async def get_anime(
|
||||
) -> AnimeDetail:
|
||||
"""Return detailed information about a specific series.
|
||||
|
||||
The `anime_id` parameter should be the series `key` (primary identifier).
|
||||
For backward compatibility, lookups by `folder` are also supported but
|
||||
deprecated. The `key` is checked first, then `folder` as fallback.
|
||||
|
||||
Args:
|
||||
anime_id: Provider key or folder name of the requested series.
|
||||
anime_id: Series `key` (primary) or `folder` (deprecated fallback).
|
||||
series_app: Optional SeriesApp instance provided via dependency.
|
||||
|
||||
Returns:
|
||||
AnimeDetail: Detailed series metadata including episode list.
|
||||
Response includes `key` as the primary identifier and
|
||||
`folder` as metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the anime cannot be located or retrieval fails.
|
||||
@ -545,13 +715,35 @@ async def get_anime(
|
||||
|
||||
series = series_app.list.GetList()
|
||||
found = None
|
||||
|
||||
# Primary lookup: search by key first (preferred)
|
||||
for serie in series:
|
||||
matches_key = getattr(serie, "key", None) == anime_id
|
||||
matches_folder = getattr(serie, "folder", None) == anime_id
|
||||
if matches_key or matches_folder:
|
||||
if getattr(serie, "key", None) == anime_id:
|
||||
found = serie
|
||||
break
|
||||
|
||||
# Fallback lookup: search by folder (backward compatibility)
|
||||
if not found:
|
||||
for serie in series:
|
||||
if getattr(serie, "folder", None) == anime_id:
|
||||
found = serie
|
||||
# Log deprecation warning for folder-based lookup
|
||||
key = getattr(serie, "key", "unknown")
|
||||
logger.warning(
|
||||
"Folder-based lookup for '%s' is deprecated. "
|
||||
"Use series key '%s' instead. Folder-based lookups "
|
||||
"will be removed in v3.0.0.",
|
||||
anime_id,
|
||||
key
|
||||
)
|
||||
warnings.warn(
|
||||
f"Folder-based lookup for '{anime_id}' is deprecated. "
|
||||
f"Use series key '{key}' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@ -564,9 +756,11 @@ async def get_anime(
|
||||
for episode in episode_numbers:
|
||||
episodes.append(f"{season}-{episode}")
|
||||
|
||||
# Return AnimeDetail with key as the primary identifier
|
||||
return AnimeDetail(
|
||||
id=getattr(found, "key", getattr(found, "folder", "")),
|
||||
key=getattr(found, "key", ""),
|
||||
title=getattr(found, "name", ""),
|
||||
folder=getattr(found, "folder", ""),
|
||||
episodes=episodes,
|
||||
description=getattr(found, "description", None),
|
||||
)
|
||||
|
||||
@ -74,7 +74,12 @@ async def add_to_queue(
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
request: Download request with serie info, episodes, and priority
|
||||
request: Download request containing:
|
||||
- serie_id: Series key (primary identifier, 'attack-on-titan')
|
||||
- serie_folder: Filesystem folder name for storing downloads
|
||||
- serie_name: Display name for the series
|
||||
- episodes: List of episodes to download
|
||||
- priority: Queue priority level
|
||||
|
||||
Returns:
|
||||
DownloadResponse: Status and list of created download item IDs
|
||||
|
||||
@ -2,6 +2,14 @@
|
||||
|
||||
This module provides WebSocket endpoints for clients to connect and receive
|
||||
real-time updates about downloads, queue status, and system events.
|
||||
|
||||
Series Identifier Convention:
|
||||
- `key`: Primary identifier for series (provider-assigned, URL-safe)
|
||||
e.g., "attack-on-titan"
|
||||
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
|
||||
|
||||
All series-related WebSocket events include `key` as the primary identifier
|
||||
in their data payload. The `folder` field is optional for display purposes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -58,19 +66,25 @@ async def websocket_endpoint(
|
||||
}
|
||||
```
|
||||
|
||||
Server message format:
|
||||
Server message format (series-related events include 'key' identifier):
|
||||
```json
|
||||
{
|
||||
"type": "download_progress",
|
||||
"timestamp": "2025-10-17T10:30:00.000Z",
|
||||
"data": {
|
||||
"download_id": "abc123",
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"percent": 45.2,
|
||||
"speed_mbps": 2.5,
|
||||
"eta_seconds": 180
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note:
|
||||
- `key` is the primary series identifier (provider-assigned, URL-safe)
|
||||
- `folder` is optional display metadata
|
||||
"""
|
||||
connection_id = str(uuid.uuid4())
|
||||
user_id: Optional[str] = None
|
||||
|
||||
@ -38,20 +38,31 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
Represents an anime series with metadata, provider information,
|
||||
and links to episodes. Corresponds to the core Serie class.
|
||||
|
||||
Series Identifier Convention:
|
||||
- `key`: PRIMARY IDENTIFIER - Unique, provider-assigned, URL-safe
|
||||
(e.g., "attack-on-titan"). Used for all lookups and operations.
|
||||
- `folder`: METADATA ONLY - Filesystem folder name for display
|
||||
(e.g., "Attack on Titan (2013)"). Never used for identification.
|
||||
- `id`: Internal database primary key for relationships.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
key: Unique identifier used by provider
|
||||
name: Series name
|
||||
id: Database primary key (internal use for relationships)
|
||||
key: Unique provider key - PRIMARY IDENTIFIER for all lookups
|
||||
name: Display name of the series
|
||||
site: Provider site URL
|
||||
folder: Local filesystem path
|
||||
folder: Filesystem folder name (metadata only, not for lookups)
|
||||
description: Optional series description
|
||||
status: Current status (ongoing, completed, etc.)
|
||||
total_episodes: Total number of episodes
|
||||
cover_url: URL to series cover image
|
||||
episodes: Relationship to Episode models
|
||||
download_items: Relationship to DownloadQueueItem models
|
||||
episodes: Relationship to Episode models (via id foreign key)
|
||||
download_items: Relationship to DownloadQueueItem models (via id foreign key)
|
||||
created_at: Creation timestamp (from TimestampMixin)
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
|
||||
Note:
|
||||
All database relationships use `id` (primary key), not `key` or `folder`.
|
||||
Use `get_by_key()` in AnimeSeriesService for lookups.
|
||||
"""
|
||||
__tablename__ = "anime_series"
|
||||
|
||||
@ -63,7 +74,7 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
# Core identification
|
||||
key: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True,
|
||||
doc="Unique provider key"
|
||||
doc="Unique provider key - PRIMARY IDENTIFIER for all lookups"
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, index=True,
|
||||
@ -75,7 +86,7 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
)
|
||||
folder: Mapped[str] = mapped_column(
|
||||
String(1000), nullable=False,
|
||||
doc="Local filesystem path"
|
||||
doc="Filesystem folder name - METADATA ONLY, not for lookups"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
|
||||
@ -43,6 +43,11 @@ class AnimeSeriesService:
|
||||
|
||||
Provides methods for creating, reading, updating, and deleting anime series
|
||||
with support for both async and sync database sessions.
|
||||
|
||||
Series Identifier Convention:
|
||||
- Use `get_by_key()` for lookups by provider key (primary identifier)
|
||||
- Use `get_by_id()` for lookups by database primary key (internal)
|
||||
- Never use `folder` for identification - it's metadata only
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@ -115,12 +120,19 @@ class AnimeSeriesService:
|
||||
async def get_by_key(db: AsyncSession, key: str) -> Optional[AnimeSeries]:
|
||||
"""Get anime series by provider key.
|
||||
|
||||
This is the PRIMARY lookup method for series identification.
|
||||
Use this method instead of get_by_id() when looking up by
|
||||
the provider-assigned unique key.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
key: Unique provider key
|
||||
key: Unique provider key (e.g., "attack-on-titan")
|
||||
|
||||
Returns:
|
||||
AnimeSeries instance or None if not found
|
||||
|
||||
Note:
|
||||
Do NOT use folder for lookups - it's metadata only.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AnimeSeries).where(AnimeSeries.key == key)
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
"""Anime Pydantic models for the Aniworld web application.
|
||||
|
||||
This module defines request/response models used by the anime API
|
||||
and services. Models are focused on serialization, validation,
|
||||
and OpenAPI documentation.
|
||||
|
||||
Note on identifiers:
|
||||
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
|
||||
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
|
||||
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
|
||||
|
||||
|
||||
class EpisodeInfo(BaseModel):
|
||||
@ -31,10 +45,30 @@ class MissingEpisodeInfo(BaseModel):
|
||||
|
||||
|
||||
class AnimeSeriesResponse(BaseModel):
|
||||
"""Response model for a series with metadata and episodes."""
|
||||
"""Response model for a series with metadata and episodes.
|
||||
|
||||
id: str = Field(..., description="Unique series identifier")
|
||||
Note on identifiers:
|
||||
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||
This is the unique key used for all lookups and operations.
|
||||
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||
Used only for display and filesystem operations.
|
||||
"""
|
||||
|
||||
key: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||
)
|
||||
)
|
||||
title: str = Field(..., description="Series title")
|
||||
folder: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Series folder name on disk (metadata only) "
|
||||
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||
)
|
||||
)
|
||||
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
|
||||
description: Optional[str] = Field(None, description="Short series description")
|
||||
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
|
||||
@ -42,20 +76,56 @@ class AnimeSeriesResponse(BaseModel):
|
||||
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
||||
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
||||
|
||||
@field_validator('key', mode='before')
|
||||
@classmethod
|
||||
def normalize_key(cls, v: str) -> str:
|
||||
"""Normalize key to lowercase."""
|
||||
if isinstance(v, str):
|
||||
return v.lower().strip()
|
||||
return v
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Request payload for searching series."""
|
||||
|
||||
query: str = Field(..., min_length=1)
|
||||
limit: int = Field(10, ge=1, le=100)
|
||||
include_adult: bool = Field(False)
|
||||
query: str = Field(..., min_length=1, description="Search query string")
|
||||
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
|
||||
include_adult: bool = Field(False, description="Include adult content in results")
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Search result item for a series discovery endpoint."""
|
||||
"""Search result item for a series discovery endpoint.
|
||||
|
||||
id: str
|
||||
title: str
|
||||
snippet: Optional[str] = None
|
||||
thumbnail: Optional[HttpUrl] = None
|
||||
score: Optional[float] = None
|
||||
Note on identifiers:
|
||||
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||
This is the unique key used for all lookups and operations.
|
||||
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||
Used only for display and filesystem operations.
|
||||
"""
|
||||
|
||||
key: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||
)
|
||||
)
|
||||
title: str = Field(..., description="Series title")
|
||||
folder: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Series folder name on disk (metadata only) "
|
||||
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||
)
|
||||
)
|
||||
snippet: Optional[str] = Field(None, description="Short description or snippet")
|
||||
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
|
||||
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
|
||||
|
||||
@field_validator('key', mode='before')
|
||||
@classmethod
|
||||
def normalize_key(cls, v: str) -> str:
|
||||
"""Normalize key to lowercase."""
|
||||
if isinstance(v, str):
|
||||
return v.lower().strip()
|
||||
return v
|
||||
|
||||
@ -63,14 +63,33 @@ class DownloadProgress(BaseModel):
|
||||
|
||||
|
||||
class DownloadItem(BaseModel):
|
||||
"""Represents a single download item in the queue."""
|
||||
"""Represents a single download item in the queue.
|
||||
|
||||
Note on identifiers:
|
||||
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used for
|
||||
all lookups and identification. This is the primary identifier.
|
||||
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
|
||||
used only for filesystem operations. This is metadata, not an identifier.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Unique download item identifier")
|
||||
serie_id: str = Field(..., description="Series identifier (provider key)")
|
||||
serie_folder: Optional[str] = Field(
|
||||
None, description="Series folder name on disk"
|
||||
serie_id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||
)
|
||||
)
|
||||
serie_folder: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series folder name on disk (metadata only) "
|
||||
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
|
||||
)
|
||||
)
|
||||
serie_name: str = Field(
|
||||
..., min_length=1, description="Series display name"
|
||||
)
|
||||
serie_name: str = Field(..., min_length=1, description="Series name")
|
||||
episode: EpisodeIdentifier = Field(
|
||||
..., description="Episode identification"
|
||||
)
|
||||
@ -107,6 +126,14 @@ class DownloadItem(BaseModel):
|
||||
None, description="Source URL for download"
|
||||
)
|
||||
|
||||
@field_validator('serie_id', mode='before')
|
||||
@classmethod
|
||||
def normalize_serie_id(cls, v: str) -> str:
|
||||
"""Normalize serie_id (key) to lowercase and stripped."""
|
||||
if isinstance(v, str):
|
||||
return v.lower().strip()
|
||||
return v
|
||||
|
||||
|
||||
class QueueStatus(BaseModel):
|
||||
"""Overall status of the download queue system."""
|
||||
@ -158,14 +185,31 @@ class QueueStats(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 (provider key)")
|
||||
serie_folder: Optional[str] = Field(
|
||||
None, description="Series folder name on disk"
|
||||
Note on identifiers:
|
||||
- serie_id: The provider-assigned key (e.g., 'attack-on-titan') used as
|
||||
the primary identifier for all operations. This is the unique key.
|
||||
- serie_folder: The filesystem folder name (e.g., 'Attack on Titan (2013)')
|
||||
used only for storing downloaded files. This is metadata.
|
||||
"""
|
||||
|
||||
serie_id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||
)
|
||||
)
|
||||
serie_folder: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Series folder name on disk (metadata only) "
|
||||
"(e.g., 'Attack on Titan (2013)'). For filesystem ops only."
|
||||
)
|
||||
)
|
||||
serie_name: str = Field(
|
||||
..., min_length=1, description="Series name for display"
|
||||
..., min_length=1, description="Series display name"
|
||||
)
|
||||
episodes: List[EpisodeIdentifier] = Field(
|
||||
..., description="List of episodes to download"
|
||||
@ -182,6 +226,14 @@ class DownloadRequest(BaseModel):
|
||||
return v.upper()
|
||||
return v
|
||||
|
||||
@field_validator('serie_id', mode='before')
|
||||
@classmethod
|
||||
def normalize_serie_id(cls, v: str) -> str:
|
||||
"""Normalize serie_id (key) to lowercase and stripped."""
|
||||
if isinstance(v, str):
|
||||
return v.lower().strip()
|
||||
return v
|
||||
|
||||
|
||||
class DownloadResponse(BaseModel):
|
||||
"""Response after adding items to the download queue."""
|
||||
|
||||
@ -3,6 +3,15 @@
|
||||
This module defines message models for WebSocket communication between
|
||||
the server and clients. Models ensure type safety and provide validation
|
||||
for real-time updates.
|
||||
|
||||
Series Identifier Convention:
|
||||
- `key`: Primary identifier for series (provider-assigned, URL-safe)
|
||||
e.g., "attack-on-titan"
|
||||
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
|
||||
|
||||
All series-related WebSocket events should include `key` as the primary
|
||||
identifier in their data payload. The `folder` field is optional and
|
||||
used for display purposes only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -65,7 +74,16 @@ class WebSocketMessage(BaseModel):
|
||||
|
||||
|
||||
class DownloadProgressMessage(BaseModel):
|
||||
"""Download progress update message."""
|
||||
"""Download progress update message.
|
||||
|
||||
Data payload should include:
|
||||
- download_id: Unique download identifier
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- percent: Download progress percentage
|
||||
- speed_mbps: Download speed
|
||||
- eta_seconds: Estimated time remaining
|
||||
"""
|
||||
|
||||
type: WebSocketMessageType = Field(
|
||||
default=WebSocketMessageType.DOWNLOAD_PROGRESS,
|
||||
@ -77,12 +95,22 @@ class DownloadProgressMessage(BaseModel):
|
||||
)
|
||||
data: Dict[str, Any] = Field(
|
||||
...,
|
||||
description="Progress data including download_id, percent, speed, eta",
|
||||
description=(
|
||||
"Progress data including download_id, key (series identifier), "
|
||||
"folder (display), percent, speed_mbps, eta_seconds"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DownloadCompleteMessage(BaseModel):
|
||||
"""Download completion message."""
|
||||
"""Download completion message.
|
||||
|
||||
Data payload should include:
|
||||
- download_id: Unique download identifier
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- file_path: Path to downloaded file
|
||||
"""
|
||||
|
||||
type: WebSocketMessageType = Field(
|
||||
default=WebSocketMessageType.DOWNLOAD_COMPLETE,
|
||||
@ -93,12 +121,23 @@ class DownloadCompleteMessage(BaseModel):
|
||||
description="ISO 8601 timestamp",
|
||||
)
|
||||
data: Dict[str, Any] = Field(
|
||||
..., description="Completion data including download_id, file_path"
|
||||
...,
|
||||
description=(
|
||||
"Completion data including download_id, key (series identifier), "
|
||||
"folder (display), file_path"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DownloadFailedMessage(BaseModel):
|
||||
"""Download failure message."""
|
||||
"""Download failure message.
|
||||
|
||||
Data payload should include:
|
||||
- download_id: Unique download identifier
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- error_message: Description of the failure
|
||||
"""
|
||||
|
||||
type: WebSocketMessageType = Field(
|
||||
default=WebSocketMessageType.DOWNLOAD_FAILED,
|
||||
@ -109,7 +148,11 @@ class DownloadFailedMessage(BaseModel):
|
||||
description="ISO 8601 timestamp",
|
||||
)
|
||||
data: Dict[str, Any] = Field(
|
||||
..., description="Error data including download_id, error_message"
|
||||
...,
|
||||
description=(
|
||||
"Error data including download_id, key (series identifier), "
|
||||
"folder (display), error_message"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
@ -23,9 +23,13 @@ class AnimeServiceError(Exception):
|
||||
class AnimeService:
|
||||
"""Wraps SeriesApp for use in the FastAPI web layer.
|
||||
|
||||
This service provides a clean interface to anime operations, using 'key'
|
||||
as the primary series identifier (provider-assigned, URL-safe) and 'folder'
|
||||
as metadata only (filesystem folder name for display purposes).
|
||||
|
||||
- SeriesApp methods are now async, no need for threadpool
|
||||
- Subscribes to SeriesApp events for progress tracking
|
||||
- Exposes async methods
|
||||
- Exposes async methods using 'key' for all series identification
|
||||
- Adds simple in-memory caching for read operations
|
||||
"""
|
||||
|
||||
@ -51,8 +55,12 @@ class AnimeService:
|
||||
def _on_download_status(self, args) -> None:
|
||||
"""Handle download status events from SeriesApp.
|
||||
|
||||
Events include both 'key' (primary identifier) and 'serie_folder'
|
||||
(metadata for display and filesystem operations).
|
||||
|
||||
Args:
|
||||
args: DownloadStatusEventArgs from SeriesApp
|
||||
args: DownloadStatusEventArgs from SeriesApp containing key,
|
||||
serie_folder, season, episode, status, and progress info
|
||||
"""
|
||||
try:
|
||||
# Get event loop - try running loop first, then stored loop
|
||||
@ -74,7 +82,10 @@ class AnimeService:
|
||||
progress_id = (
|
||||
args.item_id
|
||||
if args.item_id
|
||||
else f"download_{args.serie_folder}_{args.season}_{args.episode}"
|
||||
else (
|
||||
f"download_{args.serie_folder}_"
|
||||
f"{args.season}_{args.episode}"
|
||||
)
|
||||
)
|
||||
|
||||
# Map SeriesApp download events to progress service
|
||||
@ -85,7 +96,11 @@ class AnimeService:
|
||||
progress_type=ProgressType.DOWNLOAD,
|
||||
title=f"Downloading {args.serie_folder}",
|
||||
message=f"S{args.season:02d}E{args.episode:02d}",
|
||||
metadata={"item_id": args.item_id} if args.item_id else None,
|
||||
metadata=(
|
||||
{"item_id": args.item_id}
|
||||
if args.item_id
|
||||
else None
|
||||
),
|
||||
),
|
||||
loop
|
||||
)
|
||||
@ -136,8 +151,12 @@ class AnimeService:
|
||||
def _on_scan_status(self, args) -> None:
|
||||
"""Handle scan status events from SeriesApp.
|
||||
|
||||
Events include both 'key' (primary identifier) and 'folder'
|
||||
(metadata for display purposes).
|
||||
|
||||
Args:
|
||||
args: ScanStatusEventArgs from SeriesApp
|
||||
args: ScanStatusEventArgs from SeriesApp containing key,
|
||||
folder, current, total, status, and progress info
|
||||
"""
|
||||
try:
|
||||
scan_id = "library_scan"
|
||||
@ -206,22 +225,33 @@ class AnimeService:
|
||||
logger.error("Error handling scan status event", error=str(exc))
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _cached_list_missing(self) -> List[dict]:
|
||||
def _cached_list_missing(self) -> list[dict]:
|
||||
# Synchronous cached call - SeriesApp.series_list is populated
|
||||
# during initialization
|
||||
try:
|
||||
series = self._app.series_list
|
||||
# normalize to simple dicts
|
||||
return [
|
||||
s.to_dict() if hasattr(s, "to_dict") else s
|
||||
for s in series
|
||||
]
|
||||
result: list[dict] = []
|
||||
for s in series:
|
||||
if hasattr(s, "to_dict"):
|
||||
result.append(s.to_dict())
|
||||
else:
|
||||
result.append(s) # type: ignore
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("Failed to get missing episodes list")
|
||||
raise
|
||||
|
||||
async def list_missing(self) -> List[dict]:
|
||||
"""Return list of series with missing episodes."""
|
||||
async def list_missing(self) -> list[dict]:
|
||||
"""Return list of series with missing episodes.
|
||||
|
||||
Each series dictionary includes 'key' as the primary identifier
|
||||
and 'folder' as metadata for display purposes.
|
||||
|
||||
Returns:
|
||||
List of series dictionaries with 'key', 'name', 'site',
|
||||
'folder', and 'episodeDict' fields
|
||||
"""
|
||||
try:
|
||||
# series_list is already populated, just access it
|
||||
return self._cached_list_missing()
|
||||
@ -231,14 +261,15 @@ class AnimeService:
|
||||
logger.exception("list_missing failed")
|
||||
raise AnimeServiceError("Failed to list missing series") from exc
|
||||
|
||||
async def search(self, query: str) -> List[dict]:
|
||||
"""Search for series using underlying loader.
|
||||
async def search(self, query: str) -> list[dict]:
|
||||
"""Search for series using underlying provider.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
|
||||
Returns:
|
||||
List of search results as dictionaries
|
||||
List of search results as dictionaries, each containing 'key'
|
||||
as the primary identifier and other metadata fields
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
@ -251,10 +282,14 @@ class AnimeService:
|
||||
raise AnimeServiceError("Search failed") from exc
|
||||
|
||||
async def rescan(self) -> None:
|
||||
"""Trigger a re-scan.
|
||||
"""Trigger a re-scan of the anime library directory.
|
||||
|
||||
The SeriesApp now handles progress tracking via events which are
|
||||
Scans the filesystem for anime series and updates the series list.
|
||||
The SeriesApp handles progress tracking via events which are
|
||||
forwarded to the ProgressService through event handlers.
|
||||
|
||||
All series are identified by their 'key' (provider identifier),
|
||||
with 'folder' stored as metadata.
|
||||
"""
|
||||
try:
|
||||
# Store event loop for event handlers
|
||||
@ -281,19 +316,30 @@ class AnimeService:
|
||||
key: str,
|
||||
item_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Start a download.
|
||||
"""Start a download for a specific episode.
|
||||
|
||||
The SeriesApp now handles progress tracking via events which are
|
||||
The SeriesApp handles progress tracking via events which are
|
||||
forwarded to the ProgressService through event handlers.
|
||||
|
||||
Args:
|
||||
serie_folder: Serie folder name
|
||||
serie_folder: Serie folder name (metadata only, used for
|
||||
filesystem operations and display)
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
key: Serie key
|
||||
key: Serie unique identifier (primary identifier for series
|
||||
lookup, provider-assigned)
|
||||
item_id: Optional download queue item ID for tracking
|
||||
|
||||
Returns True on success or raises AnimeServiceError on failure.
|
||||
Returns:
|
||||
True on success
|
||||
|
||||
Raises:
|
||||
AnimeServiceError: If download fails
|
||||
|
||||
Note:
|
||||
The 'key' parameter is the primary identifier used for all
|
||||
series lookups. The 'serie_folder' is only used for filesystem
|
||||
path construction and display purposes.
|
||||
"""
|
||||
try:
|
||||
# Store event loop for event handlers
|
||||
|
||||
@ -239,11 +239,16 @@ class DownloadService:
|
||||
"""Add episodes to the download queue (FIFO order).
|
||||
|
||||
Args:
|
||||
serie_id: Series identifier (provider key)
|
||||
serie_folder: Series folder name on disk
|
||||
serie_name: Series display name
|
||||
serie_id: Series identifier - provider key (e.g.,
|
||||
'attack-on-titan'). This is the unique identifier used
|
||||
for lookups and identification.
|
||||
serie_folder: Series folder name on disk (e.g.,
|
||||
'Attack on Titan (2013)'). Used for filesystem operations
|
||||
only.
|
||||
serie_name: Series display name for user interface
|
||||
episodes: List of episodes to download
|
||||
priority: Queue priority level (ignored, kept for compatibility)
|
||||
priority: Queue priority level (ignored, kept for
|
||||
compatibility)
|
||||
|
||||
Returns:
|
||||
List of created download item IDs
|
||||
@ -277,7 +282,8 @@ class DownloadService:
|
||||
logger.info(
|
||||
"Item added to queue",
|
||||
item_id=item.id,
|
||||
serie=serie_name,
|
||||
serie_key=serie_id,
|
||||
serie_name=serie_name,
|
||||
season=episode.season,
|
||||
episode=episode.episode,
|
||||
)
|
||||
@ -792,7 +798,8 @@ class DownloadService:
|
||||
logger.info(
|
||||
"Starting download",
|
||||
item_id=item.id,
|
||||
serie=item.serie_name,
|
||||
serie_key=item.serie_id,
|
||||
serie_name=item.serie_name,
|
||||
season=item.episode.season,
|
||||
episode=item.episode.episode,
|
||||
)
|
||||
@ -802,9 +809,15 @@ class DownloadService:
|
||||
# - 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
|
||||
# Use serie_folder for filesystem operations and serie_id (key) for identification
|
||||
if not item.serie_folder:
|
||||
raise DownloadServiceError(
|
||||
f"Missing serie_folder for download item {item.id}. "
|
||||
"serie_folder is required for filesystem operations."
|
||||
)
|
||||
|
||||
success = await self._anime_service.download(
|
||||
serie_folder=folder,
|
||||
serie_folder=item.serie_folder,
|
||||
season=item.episode.season,
|
||||
episode=item.episode.episode,
|
||||
key=item.serie_id,
|
||||
|
||||
@ -51,6 +51,10 @@ class ProgressUpdate:
|
||||
percent: Completion percentage (0-100)
|
||||
current: Current progress value
|
||||
total: Total progress value
|
||||
key: Optional series identifier (provider key, e.g., 'attack-on-titan')
|
||||
Used as the primary identifier for series-related operations
|
||||
folder: Optional series folder name (e.g., 'Attack on Titan (2013)')
|
||||
Used for display and filesystem operations only
|
||||
metadata: Additional metadata
|
||||
started_at: When operation started
|
||||
updated_at: When last updated
|
||||
@ -64,13 +68,20 @@ class ProgressUpdate:
|
||||
percent: float = 0.0
|
||||
current: int = 0
|
||||
total: int = 0
|
||||
key: Optional[str] = None
|
||||
folder: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert progress update to dictionary."""
|
||||
return {
|
||||
"""Convert progress update to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation with all fields including optional
|
||||
key (series identifier) and folder (display metadata).
|
||||
"""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"type": self.type.value,
|
||||
"status": self.status.value,
|
||||
@ -85,6 +96,14 @@ class ProgressUpdate:
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
# Include optional series identifier fields
|
||||
if self.key is not None:
|
||||
result["key"] = self.key
|
||||
if self.folder is not None:
|
||||
result["folder"] = self.folder
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressEvent:
|
||||
@ -220,6 +239,8 @@ class ProgressService:
|
||||
title: str,
|
||||
total: int = 0,
|
||||
message: str = "",
|
||||
key: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> ProgressUpdate:
|
||||
"""Start a new progress operation.
|
||||
@ -230,6 +251,10 @@ class ProgressService:
|
||||
title: Human-readable title
|
||||
total: Total items/bytes to process
|
||||
message: Initial message
|
||||
key: Optional series identifier (provider key)
|
||||
Used as primary identifier for series-related operations
|
||||
folder: Optional series folder name
|
||||
Used for display and filesystem operations only
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
@ -251,6 +276,8 @@ class ProgressService:
|
||||
title=title,
|
||||
message=message,
|
||||
total=total,
|
||||
key=key,
|
||||
folder=folder,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
@ -261,6 +288,8 @@ class ProgressService:
|
||||
progress_id=progress_id,
|
||||
type=progress_type.value,
|
||||
title=title,
|
||||
key=key,
|
||||
folder=folder,
|
||||
)
|
||||
|
||||
# Emit event to subscribers
|
||||
@ -281,6 +310,8 @@ class ProgressService:
|
||||
current: Optional[int] = None,
|
||||
total: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
force_broadcast: bool = False,
|
||||
) -> ProgressUpdate:
|
||||
@ -291,6 +322,8 @@ class ProgressService:
|
||||
current: Current progress value
|
||||
total: Updated total value
|
||||
message: Updated message
|
||||
key: Optional series identifier (provider key)
|
||||
folder: Optional series folder name
|
||||
metadata: Additional metadata to merge
|
||||
force_broadcast: Force broadcasting even for small changes
|
||||
|
||||
@ -316,6 +349,10 @@ class ProgressService:
|
||||
update.total = total
|
||||
if message is not None:
|
||||
update.message = message
|
||||
if key is not None:
|
||||
update.key = key
|
||||
if folder is not None:
|
||||
update.folder = folder
|
||||
if metadata:
|
||||
update.metadata.update(metadata)
|
||||
|
||||
|
||||
660
src/server/services/scan_service.py
Normal file
660
src/server/services/scan_service.py
Normal file
@ -0,0 +1,660 @@
|
||||
"""Scan service for managing anime library scan operations.
|
||||
|
||||
This module provides a service layer for scanning the anime library directory,
|
||||
identifying missing episodes, and broadcasting scan progress updates.
|
||||
All scan operations use 'key' as the primary series identifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.core.interfaces.callbacks import (
|
||||
CallbackManager,
|
||||
CompletionCallback,
|
||||
CompletionContext,
|
||||
ErrorCallback,
|
||||
ErrorContext,
|
||||
OperationType,
|
||||
ProgressCallback,
|
||||
ProgressContext,
|
||||
ProgressPhase,
|
||||
)
|
||||
from src.server.services.progress_service import (
|
||||
ProgressService,
|
||||
ProgressStatus,
|
||||
ProgressType,
|
||||
get_progress_service,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ScanServiceError(Exception):
|
||||
"""Service-level exception for scan operations."""
|
||||
|
||||
|
||||
class ScanProgress:
|
||||
"""Represents the current state of a scan operation.
|
||||
|
||||
Attributes:
|
||||
scan_id: Unique identifier for this scan operation
|
||||
status: Current status (started, in_progress, completed, failed)
|
||||
current: Number of folders processed
|
||||
total: Total number of folders to process
|
||||
percentage: Completion percentage
|
||||
message: Human-readable progress message
|
||||
key: Current series key being scanned (if applicable)
|
||||
folder: Current folder being scanned (metadata only)
|
||||
started_at: When the scan started
|
||||
updated_at: When the progress was last updated
|
||||
series_found: Number of series found with missing episodes
|
||||
errors: List of error messages encountered
|
||||
"""
|
||||
|
||||
def __init__(self, scan_id: str):
|
||||
"""Initialize scan progress.
|
||||
|
||||
Args:
|
||||
scan_id: Unique identifier for this scan
|
||||
"""
|
||||
self.scan_id = scan_id
|
||||
self.status = "started"
|
||||
self.current = 0
|
||||
self.total = 0
|
||||
self.percentage = 0.0
|
||||
self.message = "Initializing scan..."
|
||||
self.key: Optional[str] = None
|
||||
self.folder: Optional[str] = None
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
self.series_found = 0
|
||||
self.errors: List[str] = []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization.
|
||||
|
||||
Returns:
|
||||
Dictionary representation with 'key' as primary identifier
|
||||
and 'folder' as metadata only.
|
||||
"""
|
||||
result = {
|
||||
"scan_id": self.scan_id,
|
||||
"status": self.status,
|
||||
"current": self.current,
|
||||
"total": self.total,
|
||||
"percentage": round(self.percentage, 2),
|
||||
"message": self.message,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"series_found": self.series_found,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
# Include optional series identifiers
|
||||
if self.key is not None:
|
||||
result["key"] = self.key
|
||||
if self.folder is not None:
|
||||
result["folder"] = self.folder
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ScanServiceProgressCallback(ProgressCallback):
|
||||
"""Callback implementation for forwarding scan progress to ScanService.
|
||||
|
||||
This callback receives progress events from SerieScanner and forwards
|
||||
them to the ScanService for processing and broadcasting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: "ScanService",
|
||||
scan_progress: ScanProgress,
|
||||
):
|
||||
"""Initialize the callback.
|
||||
|
||||
Args:
|
||||
service: Parent ScanService instance
|
||||
scan_progress: ScanProgress to update
|
||||
"""
|
||||
self._service = service
|
||||
self._scan_progress = scan_progress
|
||||
|
||||
def on_progress(self, context: ProgressContext) -> None:
|
||||
"""Handle progress update from SerieScanner.
|
||||
|
||||
Args:
|
||||
context: Progress context with key and folder information
|
||||
"""
|
||||
self._scan_progress.current = context.current
|
||||
self._scan_progress.total = context.total
|
||||
self._scan_progress.percentage = context.percentage
|
||||
self._scan_progress.message = context.message
|
||||
self._scan_progress.key = context.key
|
||||
self._scan_progress.folder = context.folder
|
||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
if context.phase == ProgressPhase.STARTING:
|
||||
self._scan_progress.status = "started"
|
||||
elif context.phase == ProgressPhase.IN_PROGRESS:
|
||||
self._scan_progress.status = "in_progress"
|
||||
elif context.phase == ProgressPhase.COMPLETED:
|
||||
self._scan_progress.status = "completed"
|
||||
elif context.phase == ProgressPhase.FAILED:
|
||||
self._scan_progress.status = "failed"
|
||||
|
||||
# Forward to service for broadcasting
|
||||
# Use run_coroutine_threadsafe if event loop is available
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._service._handle_progress_update(self._scan_progress),
|
||||
loop
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running event loop - likely in test or sync context
|
||||
pass
|
||||
|
||||
|
||||
class ScanServiceErrorCallback(ErrorCallback):
|
||||
"""Callback implementation for handling scan errors.
|
||||
|
||||
This callback receives error events from SerieScanner and forwards
|
||||
them to the ScanService for processing and broadcasting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: "ScanService",
|
||||
scan_progress: ScanProgress,
|
||||
):
|
||||
"""Initialize the callback.
|
||||
|
||||
Args:
|
||||
service: Parent ScanService instance
|
||||
scan_progress: ScanProgress to update
|
||||
"""
|
||||
self._service = service
|
||||
self._scan_progress = scan_progress
|
||||
|
||||
def on_error(self, context: ErrorContext) -> None:
|
||||
"""Handle error from SerieScanner.
|
||||
|
||||
Args:
|
||||
context: Error context with key and folder information
|
||||
"""
|
||||
error_msg = context.message
|
||||
if context.folder:
|
||||
error_msg = f"[{context.folder}] {error_msg}"
|
||||
|
||||
self._scan_progress.errors.append(error_msg)
|
||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.warning(
|
||||
"Scan error",
|
||||
key=context.key,
|
||||
folder=context.folder,
|
||||
error=str(context.error),
|
||||
recoverable=context.recoverable,
|
||||
)
|
||||
|
||||
# Forward to service for broadcasting
|
||||
# Use run_coroutine_threadsafe if event loop is available
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._service._handle_scan_error(
|
||||
self._scan_progress,
|
||||
context,
|
||||
),
|
||||
loop
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running event loop - likely in test or sync context
|
||||
pass
|
||||
|
||||
|
||||
class ScanServiceCompletionCallback(CompletionCallback):
|
||||
"""Callback implementation for handling scan completion.
|
||||
|
||||
This callback receives completion events from SerieScanner and forwards
|
||||
them to the ScanService for processing and broadcasting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: "ScanService",
|
||||
scan_progress: ScanProgress,
|
||||
):
|
||||
"""Initialize the callback.
|
||||
|
||||
Args:
|
||||
service: Parent ScanService instance
|
||||
scan_progress: ScanProgress to update
|
||||
"""
|
||||
self._service = service
|
||||
self._scan_progress = scan_progress
|
||||
|
||||
def on_completion(self, context: CompletionContext) -> None:
|
||||
"""Handle completion from SerieScanner.
|
||||
|
||||
Args:
|
||||
context: Completion context with statistics
|
||||
"""
|
||||
self._scan_progress.status = "completed" if context.success else "failed"
|
||||
self._scan_progress.message = context.message
|
||||
self._scan_progress.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
if context.statistics:
|
||||
self._scan_progress.series_found = context.statistics.get(
|
||||
"series_found", 0
|
||||
)
|
||||
|
||||
# Forward to service for broadcasting
|
||||
# Use run_coroutine_threadsafe if event loop is available
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._service._handle_scan_completion(
|
||||
self._scan_progress,
|
||||
context,
|
||||
),
|
||||
loop
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running event loop - likely in test or sync context
|
||||
pass
|
||||
|
||||
|
||||
class ScanService:
|
||||
"""Manages anime library scan operations.
|
||||
|
||||
Features:
|
||||
- Trigger library scans
|
||||
- Track scan progress in real-time
|
||||
- Use 'key' as primary series identifier
|
||||
- Broadcast scan progress via WebSocket
|
||||
- Handle scan errors gracefully
|
||||
- Provide scan history and statistics
|
||||
|
||||
All operations use 'key' (provider-assigned, URL-safe identifier)
|
||||
as the primary series identifier. 'folder' is used only as metadata
|
||||
for display and filesystem operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
progress_service: Optional[ProgressService] = None,
|
||||
):
|
||||
"""Initialize the scan service.
|
||||
|
||||
Args:
|
||||
progress_service: Optional progress service for tracking
|
||||
"""
|
||||
self._progress_service = progress_service or get_progress_service()
|
||||
|
||||
# Current scan state
|
||||
self._current_scan: Optional[ScanProgress] = None
|
||||
self._is_scanning = False
|
||||
|
||||
# Scan history (limited size)
|
||||
self._scan_history: List[ScanProgress] = []
|
||||
self._max_history_size = 10
|
||||
|
||||
# Event handlers for scan events
|
||||
self._scan_event_handlers: List[
|
||||
Callable[[Dict[str, Any]], None]
|
||||
] = []
|
||||
|
||||
# Lock for thread-safe operations
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
logger.info("ScanService initialized")
|
||||
|
||||
def subscribe_to_scan_events(
|
||||
self,
|
||||
handler: Callable[[Dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Subscribe to scan events.
|
||||
|
||||
Args:
|
||||
handler: Function to call when scan events occur.
|
||||
Receives a dictionary with event data including
|
||||
'key' as the primary identifier.
|
||||
"""
|
||||
self._scan_event_handlers.append(handler)
|
||||
logger.debug("Scan event handler subscribed")
|
||||
|
||||
def unsubscribe_from_scan_events(
|
||||
self,
|
||||
handler: Callable[[Dict[str, Any]], None],
|
||||
) -> None:
|
||||
"""Unsubscribe from scan events.
|
||||
|
||||
Args:
|
||||
handler: Handler function to remove
|
||||
"""
|
||||
try:
|
||||
self._scan_event_handlers.remove(handler)
|
||||
logger.debug("Scan event handler unsubscribed")
|
||||
except ValueError:
|
||||
logger.warning("Handler not found for unsubscribe")
|
||||
|
||||
async def _emit_scan_event(self, event_data: Dict[str, Any]) -> None:
|
||||
"""Emit scan event to all subscribers.
|
||||
|
||||
Args:
|
||||
event_data: Event data to broadcast, includes 'key' as
|
||||
primary identifier and 'folder' as metadata
|
||||
"""
|
||||
for handler in self._scan_event_handlers:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(event_data)
|
||||
else:
|
||||
handler(event_data)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Scan event handler error",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
"""Check if a scan is currently in progress."""
|
||||
return self._is_scanning
|
||||
|
||||
@property
|
||||
def current_scan(self) -> Optional[ScanProgress]:
|
||||
"""Get the current scan progress."""
|
||||
return self._current_scan
|
||||
|
||||
async def start_scan(
|
||||
self,
|
||||
scanner_factory: Callable[..., Any],
|
||||
) -> str:
|
||||
"""Start a new library scan.
|
||||
|
||||
Args:
|
||||
scanner_factory: Factory function that creates a SerieScanner.
|
||||
The factory should accept a callback_manager parameter.
|
||||
|
||||
Returns:
|
||||
Scan ID for tracking
|
||||
|
||||
Raises:
|
||||
ScanServiceError: If a scan is already in progress
|
||||
|
||||
Note:
|
||||
The scan uses 'key' as the primary identifier for all series.
|
||||
The 'folder' field is included only as metadata for display.
|
||||
"""
|
||||
async with self._lock:
|
||||
if self._is_scanning:
|
||||
raise ScanServiceError("A scan is already in progress")
|
||||
|
||||
self._is_scanning = True
|
||||
|
||||
scan_id = str(uuid.uuid4())
|
||||
scan_progress = ScanProgress(scan_id)
|
||||
self._current_scan = scan_progress
|
||||
|
||||
logger.info("Starting library scan", scan_id=scan_id)
|
||||
|
||||
# Start progress tracking
|
||||
try:
|
||||
await self._progress_service.start_progress(
|
||||
progress_id=f"scan_{scan_id}",
|
||||
progress_type=ProgressType.SCAN,
|
||||
title="Library Scan",
|
||||
message="Initializing scan...",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start progress tracking", error=str(e))
|
||||
|
||||
# Emit scan started event
|
||||
await self._emit_scan_event({
|
||||
"type": "scan_started",
|
||||
"scan_id": scan_id,
|
||||
"message": "Library scan started",
|
||||
})
|
||||
|
||||
return scan_id
|
||||
|
||||
def create_callback_manager(
|
||||
self,
|
||||
scan_progress: Optional[ScanProgress] = None,
|
||||
) -> CallbackManager:
|
||||
"""Create a callback manager for scan operations.
|
||||
|
||||
Args:
|
||||
scan_progress: Optional scan progress to use. If None,
|
||||
uses current scan progress.
|
||||
|
||||
Returns:
|
||||
CallbackManager configured with scan callbacks
|
||||
"""
|
||||
progress = scan_progress or self._current_scan
|
||||
if not progress:
|
||||
progress = ScanProgress(str(uuid.uuid4()))
|
||||
self._current_scan = progress
|
||||
|
||||
callback_manager = CallbackManager()
|
||||
|
||||
# Register callbacks
|
||||
callback_manager.register_progress_callback(
|
||||
ScanServiceProgressCallback(self, progress)
|
||||
)
|
||||
callback_manager.register_error_callback(
|
||||
ScanServiceErrorCallback(self, progress)
|
||||
)
|
||||
callback_manager.register_completion_callback(
|
||||
ScanServiceCompletionCallback(self, progress)
|
||||
)
|
||||
|
||||
return callback_manager
|
||||
|
||||
async def _handle_progress_update(
|
||||
self,
|
||||
scan_progress: ScanProgress,
|
||||
) -> None:
|
||||
"""Handle a scan progress update.
|
||||
|
||||
Args:
|
||||
scan_progress: Updated scan progress with 'key' as identifier
|
||||
"""
|
||||
# Update progress service
|
||||
try:
|
||||
await self._progress_service.update_progress(
|
||||
progress_id=f"scan_{scan_progress.scan_id}",
|
||||
current=scan_progress.current,
|
||||
total=scan_progress.total,
|
||||
message=scan_progress.message,
|
||||
key=scan_progress.key,
|
||||
folder=scan_progress.folder,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Progress update skipped", error=str(e))
|
||||
|
||||
# Emit progress event with key as primary identifier
|
||||
await self._emit_scan_event({
|
||||
"type": "scan_progress",
|
||||
"data": scan_progress.to_dict(),
|
||||
})
|
||||
|
||||
async def _handle_scan_error(
|
||||
self,
|
||||
scan_progress: ScanProgress,
|
||||
error_context: ErrorContext,
|
||||
) -> None:
|
||||
"""Handle a scan error.
|
||||
|
||||
Args:
|
||||
scan_progress: Current scan progress
|
||||
error_context: Error context with key and folder metadata
|
||||
"""
|
||||
# Emit error event with key as primary identifier
|
||||
await self._emit_scan_event({
|
||||
"type": "scan_error",
|
||||
"scan_id": scan_progress.scan_id,
|
||||
"key": error_context.key,
|
||||
"folder": error_context.folder,
|
||||
"error": str(error_context.error),
|
||||
"message": error_context.message,
|
||||
"recoverable": error_context.recoverable,
|
||||
})
|
||||
|
||||
async def _handle_scan_completion(
|
||||
self,
|
||||
scan_progress: ScanProgress,
|
||||
completion_context: CompletionContext,
|
||||
) -> None:
|
||||
"""Handle scan completion.
|
||||
|
||||
Args:
|
||||
scan_progress: Final scan progress
|
||||
completion_context: Completion context with statistics
|
||||
"""
|
||||
async with self._lock:
|
||||
self._is_scanning = False
|
||||
|
||||
# Add to history
|
||||
self._scan_history.append(scan_progress)
|
||||
if len(self._scan_history) > self._max_history_size:
|
||||
self._scan_history.pop(0)
|
||||
|
||||
# Complete progress tracking
|
||||
try:
|
||||
if completion_context.success:
|
||||
await self._progress_service.complete_progress(
|
||||
progress_id=f"scan_{scan_progress.scan_id}",
|
||||
message=completion_context.message,
|
||||
)
|
||||
else:
|
||||
await self._progress_service.fail_progress(
|
||||
progress_id=f"scan_{scan_progress.scan_id}",
|
||||
error_message=completion_context.message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Progress completion skipped", error=str(e))
|
||||
|
||||
# Emit completion event
|
||||
await self._emit_scan_event({
|
||||
"type": "scan_completed" if completion_context.success else "scan_failed",
|
||||
"scan_id": scan_progress.scan_id,
|
||||
"success": completion_context.success,
|
||||
"message": completion_context.message,
|
||||
"statistics": completion_context.statistics,
|
||||
"data": scan_progress.to_dict(),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Scan completed",
|
||||
scan_id=scan_progress.scan_id,
|
||||
success=completion_context.success,
|
||||
series_found=scan_progress.series_found,
|
||||
errors_count=len(scan_progress.errors),
|
||||
)
|
||||
|
||||
async def cancel_scan(self) -> bool:
|
||||
"""Cancel the current scan if one is in progress.
|
||||
|
||||
Returns:
|
||||
True if scan was cancelled, False if no scan in progress
|
||||
"""
|
||||
async with self._lock:
|
||||
if not self._is_scanning:
|
||||
return False
|
||||
|
||||
self._is_scanning = False
|
||||
|
||||
if self._current_scan:
|
||||
self._current_scan.status = "cancelled"
|
||||
self._current_scan.message = "Scan cancelled by user"
|
||||
self._current_scan.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Add to history
|
||||
self._scan_history.append(self._current_scan)
|
||||
if len(self._scan_history) > self._max_history_size:
|
||||
self._scan_history.pop(0)
|
||||
|
||||
# Emit cancellation event
|
||||
if self._current_scan:
|
||||
await self._emit_scan_event({
|
||||
"type": "scan_cancelled",
|
||||
"scan_id": self._current_scan.scan_id,
|
||||
"message": "Scan cancelled by user",
|
||||
})
|
||||
|
||||
# Update progress service
|
||||
try:
|
||||
await self._progress_service.fail_progress(
|
||||
progress_id=f"scan_{self._current_scan.scan_id}",
|
||||
error_message="Scan cancelled by user",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Progress cancellation skipped", error=str(e))
|
||||
|
||||
logger.info("Scan cancelled")
|
||||
return True
|
||||
|
||||
async def get_scan_status(self) -> Dict[str, Any]:
|
||||
"""Get the current scan status.
|
||||
|
||||
Returns:
|
||||
Dictionary with scan status information, including 'key'
|
||||
as the primary series identifier for any current scan.
|
||||
"""
|
||||
return {
|
||||
"is_scanning": self._is_scanning,
|
||||
"current_scan": (
|
||||
self._current_scan.to_dict() if self._current_scan else None
|
||||
),
|
||||
}
|
||||
|
||||
async def get_scan_history(
|
||||
self,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get scan history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of history entries to return
|
||||
|
||||
Returns:
|
||||
List of scan history entries, newest first.
|
||||
Each entry includes 'key' as the primary identifier.
|
||||
"""
|
||||
history = self._scan_history[-limit:]
|
||||
history.reverse() # Newest first
|
||||
return [scan.to_dict() for scan in history]
|
||||
|
||||
|
||||
# Module-level singleton instance
|
||||
_scan_service: Optional[ScanService] = None
|
||||
|
||||
|
||||
def get_scan_service() -> ScanService:
|
||||
"""Get the singleton ScanService instance.
|
||||
|
||||
Returns:
|
||||
The ScanService singleton
|
||||
"""
|
||||
global _scan_service
|
||||
if _scan_service is None:
|
||||
_scan_service = ScanService()
|
||||
return _scan_service
|
||||
|
||||
|
||||
def reset_scan_service() -> None:
|
||||
"""Reset the singleton ScanService instance.
|
||||
|
||||
Primarily used for testing to ensure clean state.
|
||||
"""
|
||||
global _scan_service
|
||||
_scan_service = None
|
||||
@ -3,6 +3,15 @@
|
||||
This module provides a comprehensive WebSocket manager for handling
|
||||
real-time updates, connection management, room-based messaging, and
|
||||
broadcast functionality for the Aniworld web application.
|
||||
|
||||
Series Identifier Convention:
|
||||
- `key`: Primary identifier for series (provider-assigned, URL-safe)
|
||||
e.g., "attack-on-titan"
|
||||
- `folder`: Display metadata only (e.g., "Attack on Titan (2013)")
|
||||
|
||||
All broadcast methods that handle series-related data should include `key`
|
||||
as the primary identifier in the message payload. The `folder` field is
|
||||
optional and used for display purposes only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -363,6 +372,16 @@ class WebSocketService:
|
||||
Args:
|
||||
download_id: The download item identifier
|
||||
progress_data: Progress information (percent, speed, etc.)
|
||||
Should include 'key' (series identifier) and
|
||||
optionally 'folder' (display name)
|
||||
|
||||
Note:
|
||||
The progress_data should include:
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- percent: Download progress percentage
|
||||
- speed_mbps: Download speed
|
||||
- eta_seconds: Estimated time remaining
|
||||
"""
|
||||
message = {
|
||||
"type": "download_progress",
|
||||
@ -382,6 +401,14 @@ class WebSocketService:
|
||||
Args:
|
||||
download_id: The download item identifier
|
||||
result_data: Download result information
|
||||
Should include 'key' (series identifier) and
|
||||
optionally 'folder' (display name)
|
||||
|
||||
Note:
|
||||
The result_data should include:
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- file_path: Path to the downloaded file
|
||||
"""
|
||||
message = {
|
||||
"type": "download_complete",
|
||||
@ -401,6 +428,14 @@ class WebSocketService:
|
||||
Args:
|
||||
download_id: The download item identifier
|
||||
error_data: Error information
|
||||
Should include 'key' (series identifier) and
|
||||
optionally 'folder' (display name)
|
||||
|
||||
Note:
|
||||
The error_data should include:
|
||||
- key: Series identifier (primary, e.g., 'attack-on-titan')
|
||||
- folder: Series folder name (optional, display only)
|
||||
- error_message: Description of the failure
|
||||
"""
|
||||
message = {
|
||||
"type": "download_failed",
|
||||
@ -412,7 +447,9 @@ class WebSocketService:
|
||||
}
|
||||
await self._manager.broadcast_to_room(message, "downloads")
|
||||
|
||||
async def broadcast_queue_status(self, status_data: Dict[str, Any]) -> None:
|
||||
async def broadcast_queue_status(
|
||||
self, status_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Broadcast queue status update to all clients.
|
||||
|
||||
Args:
|
||||
|
||||
@ -3,13 +3,25 @@ Template integration utilities for FastAPI application.
|
||||
|
||||
This module provides utilities for template rendering with common context
|
||||
and helper functions.
|
||||
|
||||
Series Identifier Convention:
|
||||
- `key`: Primary identifier for all series operations
|
||||
(URL-safe, e.g., "attack-on-titan")
|
||||
- `folder`: Metadata only for filesystem operations and display
|
||||
(e.g., "Attack on Titan (2013)")
|
||||
|
||||
All template helpers that handle series data use `key` for identification and
|
||||
provide `folder` as display metadata only.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure templates directory
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
@ -94,3 +106,112 @@ def list_available_templates() -> list[str]:
|
||||
for f in TEMPLATES_DIR.glob("*.html")
|
||||
if f.is_file()
|
||||
]
|
||||
|
||||
|
||||
def prepare_series_context(
|
||||
series_data: List[Dict[str, Any]],
|
||||
sort_by: str = "name"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Prepare series data for template rendering.
|
||||
|
||||
This function ensures series data follows the identifier convention:
|
||||
- `key` is used as the primary identifier for all operations
|
||||
- `folder` is included as metadata for display purposes
|
||||
|
||||
Args:
|
||||
series_data: List of series dictionaries from the API
|
||||
sort_by: Field to sort by ("name", "key", or "folder")
|
||||
|
||||
Returns:
|
||||
List of series dictionaries prepared for template use
|
||||
|
||||
Raises:
|
||||
ValueError: If series_data contains items without required 'key' field
|
||||
|
||||
Example:
|
||||
>>> series = [
|
||||
... {"key": "attack-on-titan", "name": "Attack on Titan",
|
||||
... "folder": "Attack on Titan (2013)"},
|
||||
... {"key": "one-piece", "name": "One Piece",
|
||||
... "folder": "One Piece (1999)"}
|
||||
... ]
|
||||
>>> prepared = prepare_series_context(series, sort_by="name")
|
||||
"""
|
||||
if not series_data:
|
||||
return []
|
||||
|
||||
prepared = []
|
||||
for series in series_data:
|
||||
if "key" not in series:
|
||||
logger.warning(
|
||||
"Series data missing 'key' field: %s",
|
||||
series.get("name", "unknown")
|
||||
)
|
||||
continue
|
||||
|
||||
prepared_item = {
|
||||
"key": series["key"],
|
||||
"name": series.get("name", series["key"]),
|
||||
"folder": series.get("folder", ""),
|
||||
**{k: v for k, v in series.items()
|
||||
if k not in ("key", "name", "folder")}
|
||||
}
|
||||
prepared.append(prepared_item)
|
||||
|
||||
# Sort by specified field
|
||||
if sort_by in ("name", "key", "folder"):
|
||||
prepared.sort(key=lambda x: x.get(sort_by, "").lower())
|
||||
|
||||
return prepared
|
||||
|
||||
|
||||
def get_series_by_key(
|
||||
series_data: List[Dict[str, Any]],
|
||||
key: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find a series in the data by its key.
|
||||
|
||||
Uses `key` as the identifier (not `folder`) following the project
|
||||
identifier convention.
|
||||
|
||||
Args:
|
||||
series_data: List of series dictionaries
|
||||
key: The unique series key to search for
|
||||
|
||||
Returns:
|
||||
The series dictionary if found, None otherwise
|
||||
|
||||
Example:
|
||||
>>> series = [{"key": "attack-on-titan", "name": "Attack on Titan"}]
|
||||
>>> result = get_series_by_key(series, "attack-on-titan")
|
||||
>>> result["name"]
|
||||
'Attack on Titan'
|
||||
"""
|
||||
for series in series_data:
|
||||
if series.get("key") == key:
|
||||
return series
|
||||
return None
|
||||
|
||||
|
||||
def filter_series_by_missing_episodes(
|
||||
series_data: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter series to only include those with missing episodes.
|
||||
|
||||
Args:
|
||||
series_data: List of series dictionaries with 'missing_episodes' field
|
||||
|
||||
Returns:
|
||||
Filtered list containing only series with missing episodes
|
||||
|
||||
Note:
|
||||
Identification uses `key`, not `folder`.
|
||||
"""
|
||||
return [
|
||||
series for series in series_data
|
||||
if series.get("missing_episodes")
|
||||
and any(episodes for episodes in series["missing_episodes"].values())
|
||||
]
|
||||
|
||||
@ -6,6 +6,7 @@ utilities for ensuring data integrity across the application.
|
||||
"""
|
||||
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@ -438,6 +439,120 @@ def validate_series_name(name: str) -> str:
|
||||
return name.strip()
|
||||
|
||||
|
||||
def validate_series_key(key: str) -> str:
|
||||
"""
|
||||
Validate series key format.
|
||||
|
||||
Series keys are unique, provider-assigned, URL-safe identifiers.
|
||||
They should be lowercase, use hyphens for word separation, and contain
|
||||
only alphanumeric characters and hyphens.
|
||||
|
||||
Valid examples:
|
||||
- "attack-on-titan"
|
||||
- "one-piece"
|
||||
- "naruto"
|
||||
|
||||
Invalid examples:
|
||||
- "Attack On Titan" (uppercase, spaces)
|
||||
- "attack_on_titan" (underscores)
|
||||
- "attack on titan" (spaces)
|
||||
- "" (empty)
|
||||
|
||||
Args:
|
||||
key: Series key to validate
|
||||
|
||||
Returns:
|
||||
Validated key (trimmed)
|
||||
|
||||
Raises:
|
||||
ValueError: If key is invalid
|
||||
"""
|
||||
if not key or not isinstance(key, str):
|
||||
raise ValueError("Series key must be a non-empty string")
|
||||
|
||||
key = key.strip()
|
||||
|
||||
if not key:
|
||||
raise ValueError("Series key cannot be empty")
|
||||
|
||||
if len(key) > 255:
|
||||
raise ValueError("Series key must be 255 characters or less")
|
||||
|
||||
# Key must be lowercase, alphanumeric with hyphens only
|
||||
# Pattern: starts with letter/number, can contain letters, numbers, hyphens
|
||||
# Cannot start or end with hyphen, no consecutive hyphens
|
||||
if not re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', key):
|
||||
raise ValueError(
|
||||
"Series key must be lowercase, URL-safe, and use hyphens "
|
||||
"for word separation (e.g., 'attack-on-titan'). "
|
||||
"No spaces, underscores, or uppercase letters allowed."
|
||||
)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def validate_series_key_or_folder(
|
||||
identifier: str, allow_folder: bool = True
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
Validate an identifier that could be either a series key or folder.
|
||||
|
||||
.. deprecated:: 2.0.0
|
||||
Folder-based identification is deprecated. Use series `key` only.
|
||||
This function will require key format only in v3.0.0.
|
||||
|
||||
This function provides backward compatibility during the transition
|
||||
from folder-based to key-based identification.
|
||||
|
||||
Args:
|
||||
identifier: The identifier to validate (key or folder)
|
||||
allow_folder: Whether to allow folder-style identifiers (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple of (validated_identifier, is_key) where is_key indicates
|
||||
whether the identifier is a valid key format.
|
||||
|
||||
Raises:
|
||||
ValueError: If identifier is empty or invalid
|
||||
"""
|
||||
if not identifier or not isinstance(identifier, str):
|
||||
raise ValueError("Identifier must be a non-empty string")
|
||||
|
||||
identifier = identifier.strip()
|
||||
|
||||
if not identifier:
|
||||
raise ValueError("Identifier cannot be empty")
|
||||
|
||||
# Try to validate as key first
|
||||
try:
|
||||
validate_series_key(identifier)
|
||||
return identifier, True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If not a valid key, check if folder format is allowed
|
||||
if not allow_folder:
|
||||
raise ValueError(
|
||||
f"Invalid series key format: '{identifier}'. "
|
||||
"Keys must be lowercase with hyphens (e.g., 'attack-on-titan')."
|
||||
)
|
||||
|
||||
# Emit deprecation warning for folder-based identification
|
||||
warnings.warn(
|
||||
f"Folder-based identification for '{identifier}' is deprecated. "
|
||||
"Use series key (lowercase with hyphens) instead. "
|
||||
"Folder-based identification will be removed in v3.0.0.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Validate as folder (more permissive)
|
||||
if len(identifier) > 1000:
|
||||
raise ValueError("Identifier too long (max 1000 characters)")
|
||||
|
||||
return identifier, False
|
||||
|
||||
|
||||
def validate_backup_name(name: str) -> str:
|
||||
"""
|
||||
Validate backup file name.
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
class AniWorldApp {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.selectedSeries = new Set();
|
||||
this.seriesData = [];
|
||||
this.selectedSeries = new Set(); // Uses 'key' as identifier
|
||||
this.seriesData = []; // Series objects with 'key' as primary identifier
|
||||
this.filteredSeriesData = [];
|
||||
this.isConnected = false;
|
||||
this.isDownloading = false;
|
||||
@ -674,26 +674,27 @@ class AniWorldApp {
|
||||
|
||||
grid.innerHTML = dataToRender.map(serie => this.createSerieCard(serie)).join('');
|
||||
|
||||
// Bind checkbox events
|
||||
// Bind checkbox events - uses 'key' as identifier
|
||||
grid.querySelectorAll('.series-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
this.toggleSerieSelection(e.target.dataset.folder, e.target.checked);
|
||||
this.toggleSerieSelection(e.target.dataset.key, e.target.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createSerieCard(serie) {
|
||||
const isSelected = this.selectedSeries.has(serie.folder);
|
||||
// Use 'key' as the primary identifier for selection and data operations
|
||||
const isSelected = this.selectedSeries.has(serie.key);
|
||||
const hasMissingEpisodes = serie.missing_episodes > 0;
|
||||
const canBeSelected = hasMissingEpisodes; // Only allow selection if has missing episodes
|
||||
|
||||
return `
|
||||
<div class="series-card ${isSelected ? 'selected' : ''} ${hasMissingEpisodes ? 'has-missing' : 'complete'}"
|
||||
data-folder="${serie.folder}">
|
||||
data-key="${serie.key}" data-folder="${serie.folder}">
|
||||
<div class="series-card-header">
|
||||
<input type="checkbox"
|
||||
class="series-checkbox"
|
||||
data-folder="${serie.folder}"
|
||||
data-key="${serie.key}"
|
||||
${isSelected ? 'checked' : ''}
|
||||
${!canBeSelected ? 'disabled' : ''}>
|
||||
<div class="series-info">
|
||||
@ -718,20 +719,21 @@ class AniWorldApp {
|
||||
`;
|
||||
}
|
||||
|
||||
toggleSerieSelection(folder, selected) {
|
||||
toggleSerieSelection(key, selected) {
|
||||
// Only allow selection of series with missing episodes
|
||||
const serie = this.seriesData.find(s => s.folder === folder);
|
||||
// Use 'key' as the primary identifier for lookup and selection
|
||||
const serie = this.seriesData.find(s => s.key === key);
|
||||
if (!serie || serie.missing_episodes === 0) {
|
||||
// Uncheck the checkbox if it was checked for a complete series
|
||||
const checkbox = document.querySelector(`input[data-folder="${folder}"]`);
|
||||
const checkbox = document.querySelector(`input[data-key="${key}"]`);
|
||||
if (checkbox) checkbox.checked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
this.selectedSeries.add(folder);
|
||||
this.selectedSeries.add(key);
|
||||
} else {
|
||||
this.selectedSeries.delete(folder);
|
||||
this.selectedSeries.delete(key);
|
||||
}
|
||||
|
||||
this.updateSelectionUI();
|
||||
@ -742,45 +744,47 @@ class AniWorldApp {
|
||||
const selectAllBtn = document.getElementById('select-all');
|
||||
|
||||
// Get series that can be selected (have missing episodes)
|
||||
// Use 'key' as the primary identifier for selection tracking
|
||||
const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
|
||||
const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
|
||||
const selectableFolders = selectableSeries.map(serie => serie.folder);
|
||||
const selectableKeys = selectableSeries.map(serie => serie.key);
|
||||
|
||||
downloadBtn.disabled = this.selectedSeries.size === 0;
|
||||
|
||||
const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder));
|
||||
const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
|
||||
|
||||
if (this.selectedSeries.size === 0) {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||
} else if (allSelectableSelected && selectableFolders.length > 0) {
|
||||
} else if (allSelectableSelected && selectableKeys.length > 0) {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-times"></i><span>Deselect All</span>';
|
||||
} else {
|
||||
selectAllBtn.innerHTML = '<i class="fas fa-check-double"></i><span>Select All</span>';
|
||||
}
|
||||
|
||||
// Update card appearances
|
||||
// Update card appearances using 'key' as identifier
|
||||
document.querySelectorAll('.series-card').forEach(card => {
|
||||
const folder = card.dataset.folder;
|
||||
const isSelected = this.selectedSeries.has(folder);
|
||||
const key = card.dataset.key;
|
||||
const isSelected = this.selectedSeries.has(key);
|
||||
card.classList.toggle('selected', isSelected);
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
// Get series that can be selected (have missing episodes)
|
||||
// Use 'key' as the primary identifier for selection
|
||||
const selectableSeriesData = this.filteredSeriesData.length > 0 ? this.filteredSeriesData : this.seriesData;
|
||||
const selectableSeries = selectableSeriesData.filter(serie => serie.missing_episodes > 0);
|
||||
const selectableFolders = selectableSeries.map(serie => serie.folder);
|
||||
const selectableKeys = selectableSeries.map(serie => serie.key);
|
||||
|
||||
const allSelectableSelected = selectableFolders.every(folder => this.selectedSeries.has(folder));
|
||||
const allSelectableSelected = selectableKeys.every(key => this.selectedSeries.has(key));
|
||||
|
||||
if (allSelectableSelected && this.selectedSeries.size > 0) {
|
||||
// Deselect all selectable series
|
||||
selectableFolders.forEach(folder => this.selectedSeries.delete(folder));
|
||||
selectableKeys.forEach(key => this.selectedSeries.delete(key));
|
||||
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = false);
|
||||
} else {
|
||||
// Select all selectable series
|
||||
selectableFolders.forEach(folder => this.selectedSeries.add(folder));
|
||||
selectableKeys.forEach(key => this.selectedSeries.add(key));
|
||||
document.querySelectorAll('.series-checkbox:not([disabled])').forEach(cb => cb.checked = true);
|
||||
}
|
||||
|
||||
@ -887,33 +891,35 @@ class AniWorldApp {
|
||||
}
|
||||
|
||||
async downloadSelected() {
|
||||
console.log('=== downloadSelected v1.1 - DEBUG VERSION ===');
|
||||
console.log('=== downloadSelected v1.2 - Using key as primary identifier ===');
|
||||
if (this.selectedSeries.size === 0) {
|
||||
this.showToast('No series selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = Array.from(this.selectedSeries);
|
||||
// selectedSeries now contains 'key' values (not folder)
|
||||
const selectedKeys = Array.from(this.selectedSeries);
|
||||
console.log('=== Starting download for selected series ===');
|
||||
console.log('Selected folders:', folders);
|
||||
console.log('Selected keys:', selectedKeys);
|
||||
console.log('seriesData:', this.seriesData);
|
||||
let totalEpisodesAdded = 0;
|
||||
let failedSeries = [];
|
||||
|
||||
// For each selected series, get its missing episodes and add to queue
|
||||
for (const folder of folders) {
|
||||
const serie = this.seriesData.find(s => s.folder === folder);
|
||||
// Use 'key' to find the series in seriesData
|
||||
for (const key of selectedKeys) {
|
||||
const serie = this.seriesData.find(s => s.key === key);
|
||||
if (!serie || !serie.episodeDict) {
|
||||
console.error('Serie not found or has no episodeDict:', folder, serie);
|
||||
failedSeries.push(folder);
|
||||
console.error('Serie not found or has no episodeDict for key:', key, serie);
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!serie.key) {
|
||||
console.error('Serie missing key:', serie);
|
||||
failedSeries.push(folder);
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -957,7 +963,7 @@ class AniWorldApp {
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
failedSeries.push(folder);
|
||||
failedSeries.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -973,14 +979,14 @@ class AniWorldApp {
|
||||
totalEpisodesAdded += episodes.length;
|
||||
} else {
|
||||
console.error('Failed to add to queue:', data);
|
||||
failedSeries.push(folder);
|
||||
failedSeries.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Show result message
|
||||
console.log('=== Download request complete ===');
|
||||
console.log('Total episodes added:', totalEpisodesAdded);
|
||||
console.log('Failed series:', failedSeries);
|
||||
console.log('Failed series (keys):', failedSeries);
|
||||
|
||||
if (totalEpisodesAdded > 0) {
|
||||
const message = failedSeries.length > 0
|
||||
@ -989,7 +995,7 @@ class AniWorldApp {
|
||||
this.showToast(message, 'success');
|
||||
} else {
|
||||
const errorDetails = failedSeries.length > 0
|
||||
? `Failed series: ${failedSeries.join(', ')}`
|
||||
? `Failed series (keys): ${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');
|
||||
|
||||
@ -10,10 +10,25 @@ from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
class FakeSerie:
|
||||
"""Mock Serie object for testing."""
|
||||
"""Mock Serie object for testing.
|
||||
|
||||
Note on identifiers:
|
||||
- key: Provider-assigned URL-safe identifier (e.g., 'attack-on-titan')
|
||||
- folder: Filesystem folder name for metadata only (e.g., 'Attack on Titan (2013)')
|
||||
|
||||
The 'key' is the primary identifier used for all lookups and operations.
|
||||
The 'folder' is metadata only, not used for identification.
|
||||
"""
|
||||
|
||||
def __init__(self, key, name, folder, episodeDict=None):
|
||||
"""Initialize fake serie."""
|
||||
"""Initialize fake serie.
|
||||
|
||||
Args:
|
||||
key: Provider-assigned URL-safe key (primary identifier)
|
||||
name: Display name for the series
|
||||
folder: Filesystem folder name (metadata only)
|
||||
episodeDict: Dictionary of missing episodes
|
||||
"""
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.folder = folder
|
||||
@ -28,8 +43,9 @@ class FakeSeriesApp:
|
||||
"""Initialize fake series app."""
|
||||
self.list = self # Changed from self.List to self.list
|
||||
self._items = [
|
||||
FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}),
|
||||
FakeSerie("2", "Complete Show", "complete_show", {}),
|
||||
# Using realistic key values (URL-safe, lowercase, hyphenated)
|
||||
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
|
||||
FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}),
|
||||
]
|
||||
|
||||
def GetMissingEpisode(self):
|
||||
@ -120,9 +136,15 @@ def test_list_anime_direct_call():
|
||||
|
||||
|
||||
def test_get_anime_detail_direct_call():
|
||||
"""Test get_anime function directly."""
|
||||
"""Test get_anime function directly.
|
||||
|
||||
Uses the series key (test-show-key) for lookup, not the folder name.
|
||||
"""
|
||||
fake = FakeSeriesApp()
|
||||
result = asyncio.run(anime_module.get_anime("1", series_app=fake))
|
||||
# Use the series key (primary identifier) for lookup
|
||||
result = asyncio.run(
|
||||
anime_module.get_anime("test-show-key", series_app=fake)
|
||||
)
|
||||
assert result.title == "Test Show"
|
||||
assert "1-1" in result.episodes
|
||||
|
||||
|
||||
@ -146,7 +146,8 @@ async def test_get_queue_status_unauthorized(mock_download_service):
|
||||
async def test_add_to_queue(authenticated_client, mock_download_service):
|
||||
"""Test POST /api/queue/add endpoint."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_id": "test-anime",
|
||||
"serie_folder": "Test Anime (2024)",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [
|
||||
{"season": 1, "episode": 1},
|
||||
@ -175,7 +176,8 @@ async def test_add_to_queue_with_high_priority(
|
||||
):
|
||||
"""Test adding items with HIGH priority."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_id": "test-anime",
|
||||
"serie_folder": "Test Anime (2024)",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "high",
|
||||
@ -198,7 +200,8 @@ async def test_add_to_queue_empty_episodes(
|
||||
):
|
||||
"""Test adding empty episodes list returns 400."""
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_id": "test-anime",
|
||||
"serie_folder": "Test Anime (2024)",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [],
|
||||
"priority": "normal",
|
||||
@ -221,7 +224,8 @@ async def test_add_to_queue_service_error(
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"serie_id": "series-1",
|
||||
"serie_id": "test-anime",
|
||||
"serie_folder": "Test Anime (2024)",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal",
|
||||
|
||||
@ -45,9 +45,14 @@ async def auth_headers(client: AsyncClient):
|
||||
|
||||
@pytest.fixture
|
||||
def sample_download_request():
|
||||
"""Sample download request for testing."""
|
||||
"""Sample download request for testing.
|
||||
|
||||
Note: serie_id is the primary identifier (key) used for all lookups.
|
||||
serie_folder is metadata only used for filesystem operations.
|
||||
"""
|
||||
return {
|
||||
"serie_id": "test-series",
|
||||
"serie_id": "test-series-key", # Provider key (primary identifier)
|
||||
"serie_folder": "Test Series (2024)", # Filesystem folder (metadata)
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [
|
||||
{"season": 1, "episode": 1},
|
||||
@ -100,6 +105,11 @@ class TestQueueDisplay:
|
||||
)
|
||||
assert add_response.status_code == 201
|
||||
|
||||
# Get the added item IDs from response
|
||||
add_data = add_response.json()
|
||||
added_ids = add_data.get("added_items", [])
|
||||
assert len(added_ids) > 0, "No items were added"
|
||||
|
||||
# Get queue status
|
||||
response = await client.get(
|
||||
"/api/queue/status",
|
||||
@ -112,20 +122,83 @@ class TestQueueDisplay:
|
||||
pending = data["status"]["pending_queue"]
|
||||
|
||||
assert len(pending) > 0
|
||||
item = pending[0]
|
||||
|
||||
# Find the item we just added by ID
|
||||
item = next((i for i in pending if i["id"] in added_ids), None)
|
||||
assert item is not None, f"Could not find added item in queue. Added IDs: {added_ids}"
|
||||
|
||||
# Verify required fields for display
|
||||
assert "id" in item
|
||||
assert "serie_id" in item # Key - primary identifier
|
||||
assert "serie_folder" in item # Metadata for filesystem ops
|
||||
assert "serie_name" in item
|
||||
assert "episode" in item
|
||||
assert "priority" in item
|
||||
assert "added_at" in item
|
||||
|
||||
# Verify serie_id (key) matches what we sent
|
||||
assert item["serie_id"] == sample_download_request["serie_id"]
|
||||
|
||||
# Verify episode structure
|
||||
episode = item["episode"]
|
||||
assert "season" in episode
|
||||
assert "episode" in episode
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_item_uses_key_as_identifier(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Test that queue items use serie_id (key) as primary identifier.
|
||||
|
||||
Verifies that:
|
||||
- serie_id is the provider-assigned key (URL-safe identifier)
|
||||
- serie_folder is metadata only (not used for identification)
|
||||
- Both fields are present in queue item responses
|
||||
"""
|
||||
# Add an item with explicit key and folder
|
||||
request = {
|
||||
"serie_id": "my-test-anime-key", # Provider key (primary ID)
|
||||
"serie_folder": "My Test Anime (2024)", # Display name/folder
|
||||
"serie_name": "My Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
|
||||
add_response = await client.post(
|
||||
"/api/queue/add",
|
||||
json=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_queue"]
|
||||
|
||||
# Find our item by key
|
||||
matching_items = [
|
||||
item for item in pending
|
||||
if item["serie_id"] == "my-test-anime-key"
|
||||
]
|
||||
assert len(matching_items) >= 1, "Item should be findable by key"
|
||||
|
||||
item = matching_items[0]
|
||||
|
||||
# Verify key is used as identifier
|
||||
assert item["serie_id"] == "my-test-anime-key"
|
||||
|
||||
# Verify folder is preserved as metadata
|
||||
assert item["serie_folder"] == "My Test Anime (2024)"
|
||||
|
||||
# Verify serie_name is also present
|
||||
assert item["serie_name"] == "My Test Anime"
|
||||
|
||||
|
||||
class TestQueueReordering:
|
||||
"""Test queue reordering functionality."""
|
||||
@ -158,8 +231,9 @@ class TestQueueReordering:
|
||||
response = await client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": f"test-{i}",
|
||||
"serie_name": f"Test Series {i}",
|
||||
"serie_id": f"reorder-test-key-{i}", # Key (primary ID)
|
||||
"serie_folder": f"Reorder Test {i} (2024)", # Metadata
|
||||
"serie_name": f"Reorder Test {i}",
|
||||
"episodes": [{"season": 1, "episode": i+1}],
|
||||
"priority": "normal"
|
||||
},
|
||||
@ -412,7 +486,8 @@ class TestBulkOperations:
|
||||
add_response = await client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": f"bulk-test-{i}",
|
||||
"serie_id": f"bulk-test-key-{i}", # Key (primary ID)
|
||||
"serie_folder": f"Bulk Test {i} (2024)", # Metadata
|
||||
"serie_name": f"Bulk Test {i}",
|
||||
"episodes": [{"season": 1, "episode": i+1}],
|
||||
"priority": "normal"
|
||||
|
||||
@ -252,7 +252,8 @@ class TestFrontendDownloadAPI:
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test_anime",
|
||||
"serie_id": "test-anime",
|
||||
"serie_folder": "Test Anime (2024)",
|
||||
"serie_name": "Test Anime",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
|
||||
@ -133,6 +133,7 @@ class TestDownloadFlowEndToEnd:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series-1",
|
||||
"serie_folder": "Test Anime Series (2024)",
|
||||
"serie_name": "Test Anime Series",
|
||||
"episodes": [
|
||||
{"season": 1, "episode": 1, "title": "Episode 1"},
|
||||
@ -158,6 +159,7 @@ class TestDownloadFlowEndToEnd:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series-2",
|
||||
"serie_folder": "Another Series (2024)",
|
||||
"serie_name": "Another Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "high"
|
||||
@ -194,6 +196,7 @@ class TestDownloadFlowEndToEnd:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": f"series-{priority}",
|
||||
"serie_folder": f"Series {priority.title()} (2024)",
|
||||
"serie_name": f"Series {priority.title()}",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": priority
|
||||
@ -208,6 +211,7 @@ class TestDownloadFlowEndToEnd:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [],
|
||||
"priority": "normal"
|
||||
@ -224,6 +228,7 @@ class TestDownloadFlowEndToEnd:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "invalid"
|
||||
@ -267,6 +272,7 @@ class TestQueueItemOperations:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -301,6 +307,7 @@ class TestDownloadProgressTracking:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -358,6 +365,7 @@ class TestErrorHandlingAndRetries:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "invalid-series",
|
||||
"serie_folder": "Invalid Series (2024)",
|
||||
"serie_name": "Invalid Series",
|
||||
"episodes": [{"season": 99, "episode": 99}],
|
||||
"priority": "normal"
|
||||
@ -374,6 +382,7 @@ class TestErrorHandlingAndRetries:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -412,6 +421,7 @@ class TestAuthenticationRequirements:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "test-series",
|
||||
"serie_folder": "Test Series (2024)",
|
||||
"serie_name": "Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -444,6 +454,7 @@ class TestConcurrentOperations:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": f"series-{i}",
|
||||
"serie_folder": f"Series {i} (2024)",
|
||||
"serie_name": f"Series {i}",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -488,6 +499,7 @@ class TestQueuePersistence:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "persistent-series",
|
||||
"serie_folder": "Persistent Series (2024)",
|
||||
"serie_name": "Persistent Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -524,6 +536,7 @@ class TestWebSocketIntegrationWithDownloads:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "ws-series",
|
||||
"serie_folder": "WebSocket Series (2024)",
|
||||
"serie_name": "WebSocket Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
@ -546,6 +559,7 @@ class TestCompleteDownloadWorkflow:
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": "workflow-series",
|
||||
"serie_folder": "Workflow Test Series (2024)",
|
||||
"serie_name": "Workflow Test Series",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "high"
|
||||
|
||||
@ -124,9 +124,10 @@ class TestDownloadProgressIntegration:
|
||||
)
|
||||
|
||||
# Add download to queue
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="integration_test",
|
||||
serie_folder="test_folder",
|
||||
serie_id="integration-test-key",
|
||||
serie_folder="Integration Test Anime (2024)",
|
||||
serie_name="Integration Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -197,9 +198,10 @@ class TestDownloadProgressIntegration:
|
||||
)
|
||||
|
||||
# Add and start download
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="client_test",
|
||||
serie_folder="test_folder",
|
||||
serie_id="client-test-key",
|
||||
serie_folder="Client Test Anime (2024)",
|
||||
serie_name="Client Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -273,9 +275,10 @@ class TestDownloadProgressIntegration:
|
||||
)
|
||||
|
||||
# Start download
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="multi_client_test",
|
||||
serie_folder="test_folder",
|
||||
serie_id="multi-client-test-key",
|
||||
serie_folder="Multi Client Test (2024)",
|
||||
serie_name="Multi Client Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -320,9 +323,10 @@ class TestDownloadProgressIntegration:
|
||||
|
||||
progress_service.subscribe("progress_updated", capture_broadcast)
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="structure_test",
|
||||
serie_folder="test_folder",
|
||||
serie_id="structure-test-key",
|
||||
serie_folder="Structure Test (2024)",
|
||||
serie_name="Structure Test",
|
||||
episodes=[EpisodeIdentifier(season=2, episode=3)],
|
||||
)
|
||||
@ -382,9 +386,10 @@ class TestDownloadProgressIntegration:
|
||||
)
|
||||
|
||||
# Start download after disconnect
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="disconnect_test",
|
||||
serie_folder="test_folder",
|
||||
serie_id="disconnect-test-key",
|
||||
serie_folder="Disconnect Test (2024)",
|
||||
serie_name="Disconnect Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
523
tests/integration/test_identifier_consistency.py
Normal file
523
tests/integration/test_identifier_consistency.py
Normal file
@ -0,0 +1,523 @@
|
||||
"""Integration tests for series identifier consistency.
|
||||
|
||||
This module verifies that the 'key' identifier is used consistently
|
||||
across all layers of the application (API, services, database, WebSocket).
|
||||
|
||||
The identifier standardization ensures:
|
||||
- 'key' is the primary identifier (provider-assigned, URL-safe)
|
||||
- 'folder' is metadata only (not used for lookups)
|
||||
- Consistent identifier usage throughout the codebase
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.models.download import (
|
||||
DownloadItem,
|
||||
DownloadPriority,
|
||||
DownloadStatus,
|
||||
EpisodeIdentifier,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.download_service import DownloadService
|
||||
from src.server.services.progress_service import ProgressService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create an authenticated test client with token."""
|
||||
# Setup master password
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
)
|
||||
|
||||
# Login to get token
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Add token to default headers
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Mock SeriesApp for testing."""
|
||||
app_mock = Mock()
|
||||
app_mock.series_list = []
|
||||
app_mock.search = Mock(return_value=[])
|
||||
app_mock.ReScan = Mock()
|
||||
app_mock.download = Mock(return_value=True)
|
||||
return app_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def progress_service():
|
||||
"""Create a ProgressService instance for testing."""
|
||||
return ProgressService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def download_service(mock_series_app, progress_service, tmp_path):
|
||||
"""Create a DownloadService with dependencies."""
|
||||
import uuid
|
||||
persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json"
|
||||
|
||||
anime_service = AnimeService(
|
||||
series_app=mock_series_app,
|
||||
progress_service=progress_service,
|
||||
)
|
||||
anime_service.download = AsyncMock(return_value=True)
|
||||
|
||||
service = DownloadService(
|
||||
anime_service=anime_service,
|
||||
progress_service=progress_service,
|
||||
persistence_path=str(persistence_path),
|
||||
)
|
||||
yield service
|
||||
await service.stop()
|
||||
|
||||
|
||||
class TestAPIIdentifierConsistency:
|
||||
"""Test that API endpoints use 'key' as the primary identifier."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_add_returns_key_in_response(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Test that adding to queue uses key as identifier.
|
||||
|
||||
Verifies:
|
||||
- Request accepts serie_id (key) as primary identifier
|
||||
- serie_folder is accepted as metadata
|
||||
- Response reflects correct identifiers
|
||||
"""
|
||||
request_data = {
|
||||
"serie_id": "attack-on-titan", # Key (primary identifier)
|
||||
"serie_folder": "Attack on Titan (2013)", # Metadata only
|
||||
"serie_name": "Attack on Titan",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert data["status"] == "success"
|
||||
assert len(data.get("added_items", [])) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_status_contains_key_identifier(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Test that queue status returns key as identifier.
|
||||
|
||||
Verifies:
|
||||
- Queue items have serie_id (key) as identifier
|
||||
- Queue items have serie_folder as metadata
|
||||
- Both fields are present and distinct
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# Add an item first with unique key
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
unique_key = f"one-piece-{unique_suffix}"
|
||||
unique_folder = f"One Piece ({unique_suffix})"
|
||||
|
||||
await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": unique_key,
|
||||
"serie_folder": unique_folder,
|
||||
"serie_name": "One Piece",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
)
|
||||
|
||||
# Get queue status
|
||||
response = await authenticated_client.get("/api/queue/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Navigate to pending queue
|
||||
pending = data["status"]["pending_queue"]
|
||||
assert len(pending) > 0
|
||||
|
||||
# Find the item we just added by key
|
||||
matching_items = [
|
||||
item for item in pending if item["serie_id"] == unique_key
|
||||
]
|
||||
assert len(matching_items) == 1, (
|
||||
f"Expected to find item with key {unique_key}"
|
||||
)
|
||||
|
||||
item = matching_items[0]
|
||||
|
||||
# Verify identifier structure in queue item
|
||||
assert "serie_id" in item, "Queue item must have serie_id (key)"
|
||||
assert "serie_folder" in item, "Queue item must have serie_folder"
|
||||
|
||||
# Verify key format (lowercase, hyphenated)
|
||||
assert item["serie_id"] == unique_key
|
||||
|
||||
# Verify folder is preserved as metadata
|
||||
assert item["serie_folder"] == unique_folder
|
||||
|
||||
# Verify both are present but different
|
||||
assert item["serie_id"] != item["serie_folder"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_used_for_lookup_not_folder(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Test that lookups use key, not folder.
|
||||
|
||||
Verifies:
|
||||
- Items can be identified by serie_id (key)
|
||||
- Multiple items with same folder but different keys are distinct
|
||||
"""
|
||||
import uuid
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
|
||||
# Add two items with different keys but similar folders
|
||||
key1 = f"naruto-original-{unique_suffix}"
|
||||
key2 = f"naruto-shippuden-{unique_suffix}"
|
||||
shared_folder = f"Naruto Series ({unique_suffix})"
|
||||
|
||||
await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": key1,
|
||||
"serie_folder": shared_folder,
|
||||
"serie_name": "Naruto",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
)
|
||||
|
||||
await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": key2,
|
||||
"serie_folder": shared_folder, # Same folder
|
||||
"serie_name": "Naruto Shippuden",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
)
|
||||
|
||||
# Get queue status
|
||||
response = await authenticated_client.get("/api/queue/status")
|
||||
data = response.json()
|
||||
|
||||
pending = data["status"]["pending_queue"]
|
||||
|
||||
# Both items should be present (same folder doesn't cause collision)
|
||||
serie_ids = [item["serie_id"] for item in pending]
|
||||
assert key1 in serie_ids
|
||||
assert key2 in serie_ids
|
||||
|
||||
|
||||
class TestServiceIdentifierConsistency:
|
||||
"""Test that services use 'key' as the primary identifier."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_service_uses_key(self, download_service):
|
||||
"""Test that DownloadService uses key as identifier.
|
||||
|
||||
Verifies:
|
||||
- Items are stored with serie_id (key)
|
||||
- Items can be retrieved by key
|
||||
- Queue operations use key consistently
|
||||
"""
|
||||
# Add item to queue
|
||||
item_ids = await download_service.add_to_queue(
|
||||
serie_id="my-hero-academia",
|
||||
serie_folder="My Hero Academia (2016)",
|
||||
serie_name="My Hero Academia",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
assert len(item_ids) == 1
|
||||
|
||||
# Verify item is stored correctly
|
||||
pending = download_service._pending_queue
|
||||
assert len(pending) == 1
|
||||
|
||||
item = pending[0]
|
||||
assert item.serie_id == "my-hero-academia"
|
||||
assert item.serie_folder == "My Hero Academia (2016)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_item_normalizes_key(self, download_service):
|
||||
"""Test that serie_id is normalized (lowercase, stripped).
|
||||
|
||||
Verifies:
|
||||
- Key is converted to lowercase
|
||||
- Whitespace is stripped
|
||||
"""
|
||||
# Add item with uppercase key
|
||||
item_ids = await download_service.add_to_queue(
|
||||
serie_id=" DEMON-SLAYER ",
|
||||
serie_folder="Demon Slayer (2019)",
|
||||
serie_name="Demon Slayer",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
assert len(item_ids) == 1
|
||||
|
||||
# Verify key is normalized
|
||||
item = download_service._pending_queue[0]
|
||||
assert item.serie_id == "demon-slayer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_persistence_uses_key(
|
||||
self, download_service, tmp_path
|
||||
):
|
||||
"""Test that persisted queue data uses key as identifier.
|
||||
|
||||
Verifies:
|
||||
- Persisted data contains serie_id (key)
|
||||
- Data can be restored with correct identifiers
|
||||
"""
|
||||
import json
|
||||
|
||||
# Add item to queue
|
||||
await download_service.add_to_queue(
|
||||
serie_id="jujutsu-kaisen",
|
||||
serie_folder="Jujutsu Kaisen (2020)",
|
||||
serie_name="Jujutsu Kaisen",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Read persisted data
|
||||
persistence_path = download_service._persistence_path
|
||||
with open(persistence_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify persisted data structure
|
||||
assert "pending" in data
|
||||
assert len(data["pending"]) == 1
|
||||
|
||||
persisted_item = data["pending"][0]
|
||||
assert persisted_item["serie_id"] == "jujutsu-kaisen"
|
||||
assert persisted_item["serie_folder"] == "Jujutsu Kaisen (2020)"
|
||||
|
||||
|
||||
class TestWebSocketIdentifierConsistency:
|
||||
"""Test that WebSocket events use 'key' in their payloads."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_events_include_key(
|
||||
self, download_service, progress_service
|
||||
):
|
||||
"""Test that progress events include key identifier.
|
||||
|
||||
Verifies:
|
||||
- Progress events contain key information
|
||||
- Events use consistent identifier structure
|
||||
"""
|
||||
broadcasts: List[Dict[str, Any]] = []
|
||||
|
||||
async def mock_event_handler(event):
|
||||
broadcasts.append({
|
||||
"type": event.event_type,
|
||||
"data": event.progress.to_dict(),
|
||||
"room": event.room,
|
||||
})
|
||||
|
||||
progress_service.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add item to trigger events
|
||||
await download_service.add_to_queue(
|
||||
serie_id="spy-x-family",
|
||||
serie_folder="Spy x Family (2022)",
|
||||
serie_name="Spy x Family",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Verify events were emitted
|
||||
assert len(broadcasts) >= 1
|
||||
|
||||
# Check queue progress events for metadata
|
||||
queue_events = [
|
||||
b for b in broadcasts if b["type"] == "queue_progress"
|
||||
]
|
||||
|
||||
# Verify metadata structure includes identifier info
|
||||
for event in queue_events:
|
||||
metadata = event["data"].get("metadata", {})
|
||||
# Queue events should track items by their identifiers
|
||||
if "added_ids" in metadata:
|
||||
assert len(metadata["added_ids"]) > 0
|
||||
|
||||
|
||||
class TestIdentifierValidation:
|
||||
"""Test identifier validation and edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_format_validation(self, authenticated_client):
|
||||
"""Test that key format is validated correctly.
|
||||
|
||||
Verifies:
|
||||
- Valid keys are accepted (lowercase, hyphenated)
|
||||
- Keys are normalized on input
|
||||
"""
|
||||
import uuid
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
|
||||
# Valid key format
|
||||
response = await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": f"valid-key-format-{unique_suffix}",
|
||||
"serie_folder": f"Valid Key ({unique_suffix})",
|
||||
"serie_name": "Valid Key",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "normal"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_not_used_for_identification(
|
||||
self, download_service
|
||||
):
|
||||
"""Test that folder changes don't affect identification.
|
||||
|
||||
Verifies:
|
||||
- Same key with different folder is same series
|
||||
- Folder is metadata only, not identity
|
||||
"""
|
||||
# Add item
|
||||
await download_service.add_to_queue(
|
||||
serie_id="chainsaw-man",
|
||||
serie_folder="Chainsaw Man (2022)",
|
||||
serie_name="Chainsaw Man",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Add another episode for same key, different folder
|
||||
await download_service.add_to_queue(
|
||||
serie_id="chainsaw-man",
|
||||
serie_folder="Chainsaw Man Updated (2022)", # Different folder
|
||||
serie_name="Chainsaw Man",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=2)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Both should be added (same key, different episodes)
|
||||
assert len(download_service._pending_queue) == 2
|
||||
|
||||
# Verify both use the same key
|
||||
keys = [item.serie_id for item in download_service._pending_queue]
|
||||
assert all(k == "chainsaw-man" for k in keys)
|
||||
|
||||
|
||||
class TestEndToEndIdentifierFlow:
|
||||
"""End-to-end tests for identifier consistency across layers."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_flow_with_key(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""Test complete flow uses key consistently.
|
||||
|
||||
Verifies:
|
||||
- API -> Service -> Storage uses key
|
||||
- All responses contain correct identifiers
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# Use unique key to avoid conflicts with other tests
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
unique_key = f"bleach-tybw-{unique_suffix}"
|
||||
unique_folder = f"Bleach: TYBW ({unique_suffix})"
|
||||
|
||||
# 1. Add to queue via API
|
||||
add_response = await authenticated_client.post(
|
||||
"/api/queue/add",
|
||||
json={
|
||||
"serie_id": unique_key,
|
||||
"serie_folder": unique_folder,
|
||||
"serie_name": "Bleach: TYBW",
|
||||
"episodes": [{"season": 1, "episode": 1}],
|
||||
"priority": "high"
|
||||
}
|
||||
)
|
||||
|
||||
assert add_response.status_code == 201
|
||||
|
||||
# 2. Verify in queue status
|
||||
status_response = await authenticated_client.get("/api/queue/status")
|
||||
assert status_response.status_code == 200
|
||||
|
||||
status_data = status_response.json()
|
||||
pending = status_data["status"]["pending_queue"]
|
||||
|
||||
# Find our item by key
|
||||
items = [
|
||||
i for i in pending
|
||||
if i["serie_id"] == unique_key
|
||||
]
|
||||
assert len(items) == 1, (
|
||||
f"Expected exactly 1 item with key {unique_key}, "
|
||||
f"found {len(items)}"
|
||||
)
|
||||
|
||||
item = items[0]
|
||||
|
||||
# 3. Verify identifier consistency
|
||||
assert item["serie_id"] == unique_key
|
||||
assert item["serie_folder"] == unique_folder
|
||||
assert item["serie_name"] == "Bleach: TYBW"
|
||||
|
||||
# 4. Verify key and folder are different
|
||||
assert item["serie_id"] != item["serie_folder"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@ -106,9 +106,10 @@ class TestWebSocketDownloadIntegration:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add item to queue
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
|
||||
item_ids = await download_svc.add_to_queue(
|
||||
serie_id="test_serie",
|
||||
serie_folder="test_serie",
|
||||
serie_id="test-serie-key",
|
||||
serie_folder="Test Anime (2024)",
|
||||
serie_name="Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.HIGH,
|
||||
@ -142,9 +143,10 @@ class TestWebSocketDownloadIntegration:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add items
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
|
||||
item_ids = await download_svc.add_to_queue(
|
||||
serie_id="test",
|
||||
serie_folder="test",
|
||||
serie_id="test-queue-ops-key",
|
||||
serie_folder="Test Queue Ops (2024)",
|
||||
serie_name="Test",
|
||||
episodes=[
|
||||
EpisodeIdentifier(season=1, episode=i)
|
||||
@ -193,9 +195,10 @@ class TestWebSocketDownloadIntegration:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add an item to initialize the queue progress
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase, hyphenated)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test",
|
||||
serie_folder="test",
|
||||
serie_id="test-start-stop-key",
|
||||
serie_folder="Test Start Stop (2024)",
|
||||
serie_name="Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -226,9 +229,10 @@ class TestWebSocketDownloadIntegration:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Initialize the download queue progress by adding an item
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test",
|
||||
serie_folder="test",
|
||||
serie_id="test-init-key",
|
||||
serie_folder="Test Init (2024)",
|
||||
serie_name="Test Init",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -240,9 +244,9 @@ class TestWebSocketDownloadIntegration:
|
||||
|
||||
completed_item = DownloadItem(
|
||||
id="test_completed",
|
||||
serie_id="test",
|
||||
serie_id="test-completed-key",
|
||||
serie_name="Test",
|
||||
serie_folder="Test",
|
||||
serie_folder="Test (2024)",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.COMPLETED,
|
||||
priority=DownloadPriority.NORMAL,
|
||||
@ -463,9 +467,10 @@ class TestWebSocketEndToEnd:
|
||||
progress_service.subscribe("progress_updated", capture_event)
|
||||
|
||||
# Add items to queue
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item_ids = await download_svc.add_to_queue(
|
||||
serie_id="test",
|
||||
serie_folder="test",
|
||||
serie_id="test-e2e-key",
|
||||
serie_folder="Test Anime (2024)",
|
||||
serie_name="Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.HIGH,
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
"""Unit tests for anime Pydantic models.
|
||||
|
||||
This module tests all anime-related models including validation,
|
||||
serialization, and field constraints.
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.server.models.anime import (
|
||||
@ -9,101 +15,139 @@ from src.server.models.anime import (
|
||||
)
|
||||
|
||||
|
||||
def test_episode_info_basic():
|
||||
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
||||
assert ep.episode_number == 1
|
||||
assert ep.title == "Pilot"
|
||||
assert ep.duration_seconds == 1500
|
||||
assert ep.available is True
|
||||
class TestEpisodeInfo:
|
||||
"""Tests for EpisodeInfo model."""
|
||||
|
||||
def test_episode_info_basic(self):
|
||||
"""Test creating a basic episode info."""
|
||||
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
||||
assert ep.episode_number == 1
|
||||
assert ep.title == "Pilot"
|
||||
assert ep.duration_seconds == 1500
|
||||
assert ep.available is True
|
||||
|
||||
def test_episode_info_without_optional_fields(self):
|
||||
"""Test episode info with only required fields."""
|
||||
ep = EpisodeInfo(episode_number=5)
|
||||
assert ep.episode_number == 5
|
||||
assert ep.title is None
|
||||
assert ep.duration_seconds is None
|
||||
assert ep.available is True
|
||||
|
||||
def test_invalid_episode_number(self):
|
||||
"""Test that episode number must be positive."""
|
||||
with pytest.raises(ValidationError):
|
||||
EpisodeInfo(episode_number=0)
|
||||
|
||||
|
||||
def test_missing_episode_count():
|
||||
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
||||
assert m.count == 3
|
||||
class TestMissingEpisodeInfo:
|
||||
"""Tests for MissingEpisodeInfo model."""
|
||||
|
||||
def test_missing_episode_count(self):
|
||||
"""Test count property calculation."""
|
||||
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
||||
assert m.count == 3
|
||||
|
||||
def test_single_missing_episode(self):
|
||||
"""Test count for single missing episode."""
|
||||
m = MissingEpisodeInfo(from_episode=5, to_episode=5)
|
||||
assert m.count == 1
|
||||
|
||||
|
||||
def test_anime_series_response():
|
||||
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
||||
series = AnimeSeriesResponse(
|
||||
id="series-123",
|
||||
title="My Anime",
|
||||
episodes=[ep],
|
||||
total_episodes=12,
|
||||
)
|
||||
class TestAnimeSeriesResponse:
|
||||
"""Tests for AnimeSeriesResponse model."""
|
||||
|
||||
assert series.id == "series-123"
|
||||
assert series.episodes[0].title == "Ep1"
|
||||
def test_anime_series_response_with_key(self):
|
||||
"""Test creating series response with key as identifier."""
|
||||
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
||||
series = AnimeSeriesResponse(
|
||||
key="attack-on-titan",
|
||||
title="Attack on Titan",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodes=[ep],
|
||||
total_episodes=12,
|
||||
)
|
||||
|
||||
assert series.key == "attack-on-titan"
|
||||
assert series.title == "Attack on Titan"
|
||||
assert series.folder == "Attack on Titan (2013)"
|
||||
assert series.episodes[0].title == "Ep1"
|
||||
|
||||
def test_key_normalization(self):
|
||||
"""Test that key is normalized to lowercase."""
|
||||
series = AnimeSeriesResponse(
|
||||
key="ATTACK-ON-TITAN",
|
||||
title="Attack on Titan"
|
||||
)
|
||||
assert series.key == "attack-on-titan"
|
||||
|
||||
def test_key_whitespace_stripped(self):
|
||||
"""Test that key whitespace is stripped."""
|
||||
series = AnimeSeriesResponse(
|
||||
key=" attack-on-titan ",
|
||||
title="Attack on Titan"
|
||||
)
|
||||
assert series.key == "attack-on-titan"
|
||||
|
||||
def test_folder_is_optional(self):
|
||||
"""Test that folder is optional metadata."""
|
||||
series = AnimeSeriesResponse(
|
||||
key="my-anime",
|
||||
title="My Anime"
|
||||
)
|
||||
assert series.folder is None
|
||||
|
||||
|
||||
def test_search_request_validation():
|
||||
# valid
|
||||
req = SearchRequest(query="naruto", limit=5)
|
||||
assert req.query == "naruto"
|
||||
class TestSearchRequest:
|
||||
"""Tests for SearchRequest model."""
|
||||
|
||||
# invalid: empty query
|
||||
try:
|
||||
SearchRequest(query="", limit=5)
|
||||
raised = False
|
||||
except ValidationError:
|
||||
raised = True
|
||||
assert raised
|
||||
def test_search_request_validation(self):
|
||||
"""Test valid search request."""
|
||||
req = SearchRequest(query="naruto", limit=5)
|
||||
assert req.query == "naruto"
|
||||
assert req.limit == 5
|
||||
|
||||
def test_search_request_empty_query_rejected(self):
|
||||
"""Test that empty query is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(query="", limit=5)
|
||||
|
||||
def test_search_request_defaults(self):
|
||||
"""Test default values."""
|
||||
req = SearchRequest(query="test")
|
||||
assert req.limit == 10
|
||||
assert req.include_adult is False
|
||||
|
||||
|
||||
def test_search_result_optional_fields():
|
||||
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
|
||||
assert res.score == 0.9
|
||||
class TestSearchResult:
|
||||
"""Tests for SearchResult model."""
|
||||
|
||||
from pydantic import ValidationError
|
||||
def test_search_result_with_key(self):
|
||||
"""Test search result with key as identifier."""
|
||||
res = SearchResult(
|
||||
key="naruto",
|
||||
title="Naruto",
|
||||
folder="Naruto (2002)",
|
||||
snippet="A ninja story",
|
||||
score=0.9
|
||||
)
|
||||
assert res.key == "naruto"
|
||||
assert res.title == "Naruto"
|
||||
assert res.folder == "Naruto (2002)"
|
||||
assert res.score == 0.9
|
||||
|
||||
from src.server.models.anime import (
|
||||
AnimeSeriesResponse,
|
||||
EpisodeInfo,
|
||||
MissingEpisodeInfo,
|
||||
SearchRequest,
|
||||
SearchResult,
|
||||
)
|
||||
def test_key_normalization(self):
|
||||
"""Test that key is normalized to lowercase."""
|
||||
res = SearchResult(key="NARUTO", title="Naruto")
|
||||
assert res.key == "naruto"
|
||||
|
||||
def test_folder_is_optional(self):
|
||||
"""Test that folder is optional metadata."""
|
||||
res = SearchResult(key="test", title="Test")
|
||||
assert res.folder is None
|
||||
|
||||
def test_episode_info_basic():
|
||||
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
||||
assert ep.episode_number == 1
|
||||
assert ep.title == "Pilot"
|
||||
assert ep.duration_seconds == 1500
|
||||
assert ep.available is True
|
||||
|
||||
|
||||
def test_missing_episode_count():
|
||||
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
||||
assert m.count == 3
|
||||
|
||||
|
||||
def test_anime_series_response():
|
||||
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
||||
series = AnimeSeriesResponse(
|
||||
id="series-123",
|
||||
title="My Anime",
|
||||
episodes=[ep],
|
||||
total_episodes=12,
|
||||
)
|
||||
|
||||
assert series.id == "series-123"
|
||||
assert series.episodes[0].title == "Ep1"
|
||||
|
||||
|
||||
def test_search_request_validation():
|
||||
# valid
|
||||
req = SearchRequest(query="naruto", limit=5)
|
||||
assert req.query == "naruto"
|
||||
|
||||
# invalid: empty query
|
||||
try:
|
||||
SearchRequest(query="", limit=5)
|
||||
raised = False
|
||||
except ValidationError:
|
||||
raised = True
|
||||
assert raised
|
||||
|
||||
|
||||
def test_search_result_optional_fields():
|
||||
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
|
||||
assert res.score == 0.9
|
||||
def test_optional_fields(self):
|
||||
"""Test optional fields."""
|
||||
res = SearchResult(key="s1", title="T1", snippet="snip", score=0.9)
|
||||
assert res.score == 0.9
|
||||
assert res.snippet == "snip"
|
||||
|
||||
@ -34,6 +34,8 @@ class TestProgressContext(unittest.TestCase):
|
||||
percentage=50.0,
|
||||
message="Downloading...",
|
||||
details="Episode 5",
|
||||
key="attack-on-titan",
|
||||
folder="Attack on Titan (2013)",
|
||||
metadata={"series": "Test"}
|
||||
)
|
||||
|
||||
@ -45,6 +47,8 @@ class TestProgressContext(unittest.TestCase):
|
||||
self.assertEqual(context.percentage, 50.0)
|
||||
self.assertEqual(context.message, "Downloading...")
|
||||
self.assertEqual(context.details, "Episode 5")
|
||||
self.assertEqual(context.key, "attack-on-titan")
|
||||
self.assertEqual(context.folder, "Attack on Titan (2013)")
|
||||
self.assertEqual(context.metadata, {"series": "Test"})
|
||||
|
||||
def test_progress_context_to_dict(self):
|
||||
@ -69,6 +73,8 @@ class TestProgressContext(unittest.TestCase):
|
||||
self.assertEqual(result["percentage"], 100.0)
|
||||
self.assertEqual(result["message"], "Scan complete")
|
||||
self.assertIsNone(result["details"])
|
||||
self.assertIsNone(result["key"])
|
||||
self.assertIsNone(result["folder"])
|
||||
self.assertEqual(result["metadata"], {})
|
||||
|
||||
def test_progress_context_default_metadata(self):
|
||||
@ -100,6 +106,8 @@ class TestErrorContext(unittest.TestCase):
|
||||
message="Download failed",
|
||||
recoverable=True,
|
||||
retry_count=2,
|
||||
key="jujutsu-kaisen",
|
||||
folder="Jujutsu Kaisen",
|
||||
metadata={"attempt": 3}
|
||||
)
|
||||
|
||||
@ -109,6 +117,8 @@ class TestErrorContext(unittest.TestCase):
|
||||
self.assertEqual(context.message, "Download failed")
|
||||
self.assertTrue(context.recoverable)
|
||||
self.assertEqual(context.retry_count, 2)
|
||||
self.assertEqual(context.key, "jujutsu-kaisen")
|
||||
self.assertEqual(context.folder, "Jujutsu Kaisen")
|
||||
self.assertEqual(context.metadata, {"attempt": 3})
|
||||
|
||||
def test_error_context_to_dict(self):
|
||||
@ -131,6 +141,8 @@ class TestErrorContext(unittest.TestCase):
|
||||
self.assertEqual(result["message"], "Scan error occurred")
|
||||
self.assertFalse(result["recoverable"])
|
||||
self.assertEqual(result["retry_count"], 0)
|
||||
self.assertIsNone(result["key"])
|
||||
self.assertIsNone(result["folder"])
|
||||
self.assertEqual(result["metadata"], {})
|
||||
|
||||
|
||||
@ -146,6 +158,8 @@ class TestCompletionContext(unittest.TestCase):
|
||||
message="Download completed successfully",
|
||||
result_data={"file": "episode.mp4"},
|
||||
statistics={"size": 1024, "time": 60},
|
||||
key="bleach",
|
||||
folder="Bleach (2004)",
|
||||
metadata={"quality": "HD"}
|
||||
)
|
||||
|
||||
@ -155,6 +169,8 @@ class TestCompletionContext(unittest.TestCase):
|
||||
self.assertEqual(context.message, "Download completed successfully")
|
||||
self.assertEqual(context.result_data, {"file": "episode.mp4"})
|
||||
self.assertEqual(context.statistics, {"size": 1024, "time": 60})
|
||||
self.assertEqual(context.key, "bleach")
|
||||
self.assertEqual(context.folder, "Bleach (2004)")
|
||||
self.assertEqual(context.metadata, {"quality": "HD"})
|
||||
|
||||
def test_completion_context_to_dict(self):
|
||||
@ -173,6 +189,8 @@ class TestCompletionContext(unittest.TestCase):
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["message"], "Scan failed")
|
||||
self.assertEqual(result["statistics"], {})
|
||||
self.assertIsNone(result["key"])
|
||||
self.assertIsNone(result["folder"])
|
||||
self.assertEqual(result["metadata"], {})
|
||||
|
||||
|
||||
|
||||
@ -171,27 +171,55 @@ class TestDownloadItem:
|
||||
def test_valid_download_item(self):
|
||||
"""Test creating a valid download item."""
|
||||
episode = EpisodeIdentifier(season=1, episode=5)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="download_123",
|
||||
serie_id="serie_456",
|
||||
serie_id="test-serie-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episode=episode,
|
||||
status=DownloadStatus.PENDING,
|
||||
priority=DownloadPriority.HIGH
|
||||
)
|
||||
assert item.id == "download_123"
|
||||
assert item.serie_id == "serie_456"
|
||||
assert item.serie_id == "test-serie-key"
|
||||
assert item.serie_name == "Test Series"
|
||||
assert item.episode == episode
|
||||
assert item.status == DownloadStatus.PENDING
|
||||
assert item.priority == DownloadPriority.HIGH
|
||||
|
||||
def test_download_item_defaults(self):
|
||||
"""Test default values for download item."""
|
||||
def test_serie_id_normalized_to_lowercase(self):
|
||||
"""Test that serie_id (key) is normalized to lowercase."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="ATTACK-ON-TITAN",
|
||||
serie_folder="Test Folder",
|
||||
serie_name="Test",
|
||||
episode=episode
|
||||
)
|
||||
assert item.serie_id == "attack-on-titan"
|
||||
|
||||
def test_serie_id_whitespace_stripped(self):
|
||||
"""Test that serie_id whitespace is stripped."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id=" attack-on-titan ",
|
||||
serie_folder="Test Folder",
|
||||
serie_name="Test",
|
||||
episode=episode
|
||||
)
|
||||
assert item.serie_id == "attack-on-titan"
|
||||
|
||||
def test_download_item_defaults(self):
|
||||
"""Test default values for download item."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="default-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode
|
||||
)
|
||||
@ -208,9 +236,11 @@ class TestDownloadItem:
|
||||
"""Test download item with progress information."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
progress = DownloadProgress(percent=50.0, downloaded_mb=100.0)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="progress-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode,
|
||||
progress=progress
|
||||
@ -222,9 +252,11 @@ class TestDownloadItem:
|
||||
"""Test download item with timestamp fields."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
now = datetime.now(timezone.utc)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="timestamp-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode,
|
||||
started_at=now,
|
||||
@ -239,7 +271,8 @@ class TestDownloadItem:
|
||||
with pytest.raises(ValidationError):
|
||||
DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="empty-name-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="",
|
||||
episode=episode
|
||||
)
|
||||
@ -250,7 +283,8 @@ class TestDownloadItem:
|
||||
with pytest.raises(ValidationError):
|
||||
DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="retry-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode,
|
||||
retry_count=-1
|
||||
@ -260,9 +294,11 @@ class TestDownloadItem:
|
||||
"""Test that added_at is automatically set."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
before = datetime.now(timezone.utc)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="auto-added-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode
|
||||
)
|
||||
@ -276,9 +312,11 @@ class TestQueueStatus:
|
||||
def test_valid_queue_status(self):
|
||||
"""Test creating valid queue status."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="queue-status-test-key",
|
||||
serie_folder="Test Folder (2024)",
|
||||
serie_name="Test",
|
||||
episode=episode
|
||||
)
|
||||
@ -373,32 +411,63 @@ class TestDownloadRequest:
|
||||
"""Test creating a valid download request."""
|
||||
episode1 = EpisodeIdentifier(season=1, episode=1)
|
||||
episode2 = EpisodeIdentifier(season=1, episode=2)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
request = DownloadRequest(
|
||||
serie_id="serie_123",
|
||||
serie_id="test-series-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episodes=[episode1, episode2],
|
||||
priority=DownloadPriority.HIGH
|
||||
)
|
||||
assert request.serie_id == "serie_123"
|
||||
assert request.serie_id == "test-series-key"
|
||||
assert request.serie_name == "Test Series"
|
||||
assert len(request.episodes) == 2
|
||||
assert request.priority == DownloadPriority.HIGH
|
||||
|
||||
def test_serie_id_normalized_to_lowercase(self):
|
||||
"""Test that serie_id (key) is normalized to lowercase."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
request = DownloadRequest(
|
||||
serie_id="ATTACK-ON-TITAN",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episodes=[episode]
|
||||
)
|
||||
assert request.serie_id == "attack-on-titan"
|
||||
|
||||
def test_serie_id_whitespace_stripped(self):
|
||||
"""Test that serie_id whitespace is stripped."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
request = DownloadRequest(
|
||||
serie_id=" attack-on-titan ",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episodes=[episode]
|
||||
)
|
||||
assert request.serie_id == "attack-on-titan"
|
||||
|
||||
def test_download_request_default_priority(self):
|
||||
"""Test default priority for download request."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
request = DownloadRequest(
|
||||
serie_id="serie_123",
|
||||
serie_id="default-priority-test-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episodes=[episode]
|
||||
)
|
||||
assert request.priority == DownloadPriority.NORMAL
|
||||
|
||||
def test_empty_episodes_list_allowed(self):
|
||||
"""Test that empty episodes list is allowed at model level (endpoint validates)."""
|
||||
"""Test that empty episodes list is allowed at model level.
|
||||
|
||||
(endpoint validates)
|
||||
"""
|
||||
# Empty list is now allowed at model level; endpoint validates
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
request = DownloadRequest(
|
||||
serie_id="serie_123",
|
||||
serie_id="empty-episodes-test-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episodes=[]
|
||||
)
|
||||
@ -408,8 +477,10 @@ class TestDownloadRequest:
|
||||
"""Test that empty serie name is rejected."""
|
||||
episode = EpisodeIdentifier(season=1, episode=1)
|
||||
with pytest.raises(ValidationError):
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
DownloadRequest(
|
||||
serie_id="serie_123",
|
||||
serie_id="empty-name-request-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="",
|
||||
episodes=[episode]
|
||||
)
|
||||
@ -453,7 +524,10 @@ class TestQueueOperationRequest:
|
||||
assert "item1" in request.item_ids
|
||||
|
||||
def test_empty_item_ids_allowed(self):
|
||||
"""Test that empty item_ids list is allowed at model level (endpoint validates)."""
|
||||
"""Test that empty item_ids list is allowed at model level.
|
||||
|
||||
(endpoint validates)
|
||||
"""
|
||||
# Empty list is now allowed at model level; endpoint validates
|
||||
request = QueueOperationRequest(item_ids=[])
|
||||
assert request.item_ids == []
|
||||
@ -509,23 +583,28 @@ class TestModelSerialization:
|
||||
def test_download_item_to_dict(self):
|
||||
"""Test serializing download item to dict."""
|
||||
episode = EpisodeIdentifier(season=1, episode=5, title="Test")
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item = DownloadItem(
|
||||
id="test_id",
|
||||
serie_id="serie_id",
|
||||
serie_id="serialization-test-key",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episode=episode
|
||||
)
|
||||
data = item.model_dump()
|
||||
assert data["id"] == "test_id"
|
||||
assert data["serie_id"] == "serialization-test-key"
|
||||
assert data["serie_name"] == "Test Series"
|
||||
assert data["episode"]["season"] == 1
|
||||
assert data["episode"]["episode"] == 5
|
||||
|
||||
def test_download_item_from_dict(self):
|
||||
"""Test deserializing download item from dict."""
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
data = {
|
||||
"id": "test_id",
|
||||
"serie_id": "serie_id",
|
||||
"serie_id": "deserialize-test-key",
|
||||
"serie_folder": "Test Series (2023)",
|
||||
"serie_name": "Test Series",
|
||||
"episode": {
|
||||
"season": 1,
|
||||
@ -535,6 +614,7 @@ class TestModelSerialization:
|
||||
}
|
||||
item = DownloadItem(**data)
|
||||
assert item.id == "test_id"
|
||||
assert item.serie_id == "deserialize-test-key"
|
||||
assert item.serie_name == "Test Series"
|
||||
assert item.episode.season == 1
|
||||
|
||||
|
||||
@ -39,20 +39,23 @@ def mock_series_app():
|
||||
class MockDownloadArgs:
|
||||
def __init__(
|
||||
self, status, serie_folder, season, episode,
|
||||
progress=None, message=None, error=None
|
||||
key=None, progress=None, message=None, error=None,
|
||||
item_id=None
|
||||
):
|
||||
self.status = status
|
||||
self.serie_folder = serie_folder
|
||||
self.key = key
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.progress = progress
|
||||
self.message = message
|
||||
self.error = error
|
||||
self.item_id = item_id
|
||||
|
||||
# Trigger started event
|
||||
if app.download_status:
|
||||
app.download_status(MockDownloadArgs(
|
||||
"started", serie_folder, season, episode
|
||||
"started", serie_folder, season, episode, key=key
|
||||
))
|
||||
|
||||
# Simulate progress updates
|
||||
@ -62,6 +65,7 @@ def mock_series_app():
|
||||
await asyncio.sleep(0.01) # Small delay
|
||||
app.download_status(MockDownloadArgs(
|
||||
"progress", serie_folder, season, episode,
|
||||
key=key,
|
||||
progress=progress,
|
||||
message=f"Downloading... {progress}%"
|
||||
))
|
||||
@ -69,11 +73,13 @@ def mock_series_app():
|
||||
# Trigger completed event
|
||||
if app.download_status:
|
||||
app.download_status(MockDownloadArgs(
|
||||
"completed", serie_folder, season, episode
|
||||
"completed", serie_folder, season, episode, key=key
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
app.download = Mock(side_effect=mock_download)
|
||||
return app
|
||||
|
||||
@ -141,9 +147,10 @@ class TestDownloadProgressWebSocket:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add item to queue
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item_ids = await download_svc.add_to_queue(
|
||||
serie_id="test_serie_1",
|
||||
serie_folder="test_serie_1",
|
||||
serie_id="test-serie-1-key",
|
||||
serie_folder="Test Anime (2024)",
|
||||
serie_name="Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
@ -191,9 +198,10 @@ class TestDownloadProgressWebSocket:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add item with specific episode info
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_2",
|
||||
serie_folder="test_serie_2",
|
||||
serie_id="test-serie-2-key",
|
||||
serie_folder="My Test Anime (2024)",
|
||||
serie_name="My Test Anime",
|
||||
episodes=[EpisodeIdentifier(season=2, episode=5)],
|
||||
priority=DownloadPriority.HIGH,
|
||||
@ -213,8 +221,9 @@ class TestDownloadProgressWebSocket:
|
||||
# Verify progress info is included
|
||||
data = progress_broadcasts[0]["data"]
|
||||
assert "id" in data
|
||||
# ID should contain folder name: download_test_serie_2_2_5
|
||||
assert "test_serie_2" in data["id"]
|
||||
# ID contains folder name: download_My Test Anime (2024)_2_5
|
||||
# Check for folder name substring (case-insensitive)
|
||||
assert "my test anime" in data["id"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_percent_increases(self, download_service):
|
||||
@ -230,9 +239,10 @@ class TestDownloadProgressWebSocket:
|
||||
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_3",
|
||||
serie_folder="test_serie_3",
|
||||
serie_id="test-serie-3-key",
|
||||
serie_folder="Progress Test (2024)",
|
||||
serie_name="Progress Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -271,9 +281,10 @@ class TestDownloadProgressWebSocket:
|
||||
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_4",
|
||||
serie_folder="test_serie_4",
|
||||
serie_id="test-serie-4-key",
|
||||
serie_folder="Speed Test (2024)",
|
||||
serie_name="Speed Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -299,9 +310,10 @@ class TestDownloadProgressWebSocket:
|
||||
download_svc, progress_svc = download_service
|
||||
# Don't subscribe to any events
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_5",
|
||||
serie_folder="test_serie_5",
|
||||
serie_id="test-serie-5-key",
|
||||
serie_folder="No Broadcast Test (2024)",
|
||||
serie_name="No Broadcast Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -328,9 +340,10 @@ class TestDownloadProgressWebSocket:
|
||||
|
||||
progress_svc.subscribe("progress_updated", failing_handler)
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_6",
|
||||
serie_folder="test_serie_6",
|
||||
serie_id="test-serie-6-key",
|
||||
serie_folder="Error Handling Test (2024)",
|
||||
serie_name="Error Handling Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
@ -363,9 +376,10 @@ class TestDownloadProgressWebSocket:
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Add multiple episodes
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
item_ids = await download_svc.add_to_queue(
|
||||
serie_id="test_serie_7",
|
||||
serie_folder="test_serie_7",
|
||||
serie_id="test-serie-7-key",
|
||||
serie_folder="Multi Episode Test (2024)",
|
||||
serie_name="Multi Episode Test",
|
||||
episodes=[
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
@ -412,9 +426,10 @@ class TestDownloadProgressWebSocket:
|
||||
|
||||
progress_svc.subscribe("progress_updated", mock_event_handler)
|
||||
|
||||
# Note: serie_id uses provider key format (URL-safe, lowercase)
|
||||
await download_svc.add_to_queue(
|
||||
serie_id="test_serie_8",
|
||||
serie_folder="test_serie_8",
|
||||
serie_id="test-serie-8-key",
|
||||
serie_folder="Model Test (2024)",
|
||||
serie_name="Model Test",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
@ -346,6 +346,7 @@ class TestQueueControl:
|
||||
completed_item = DownloadItem(
|
||||
id="completed-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.COMPLETED,
|
||||
@ -454,6 +455,7 @@ class TestRetryLogic:
|
||||
failed_item = DownloadItem(
|
||||
id="failed-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
@ -476,6 +478,7 @@ class TestRetryLogic:
|
||||
failed_item = DownloadItem(
|
||||
id="failed-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="Test Series (2023)",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
|
||||
@ -508,3 +508,94 @@ class TestProgressService:
|
||||
assert progress.metadata["initial"] == "value"
|
||||
assert progress.metadata["additional"] == "data"
|
||||
assert progress.metadata["speed"] == 1.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_with_key_and_folder(self, service):
|
||||
"""Test progress tracking with series key and folder."""
|
||||
# Start progress with key and folder
|
||||
update = await service.start_progress(
|
||||
progress_id="download-series-1",
|
||||
progress_type=ProgressType.DOWNLOAD,
|
||||
title="Downloading Attack on Titan",
|
||||
key="attack-on-titan",
|
||||
folder="Attack on Titan (2013)",
|
||||
total=100,
|
||||
)
|
||||
|
||||
assert update.key == "attack-on-titan"
|
||||
assert update.folder == "Attack on Titan (2013)"
|
||||
|
||||
# Verify to_dict includes key and folder
|
||||
dict_repr = update.to_dict()
|
||||
assert dict_repr["key"] == "attack-on-titan"
|
||||
assert dict_repr["folder"] == "Attack on Titan (2013)"
|
||||
|
||||
# Update progress and verify key/folder are preserved
|
||||
updated = await service.update_progress(
|
||||
progress_id="download-series-1",
|
||||
current=50,
|
||||
)
|
||||
|
||||
assert updated.key == "attack-on-titan"
|
||||
assert updated.folder == "Attack on Titan (2013)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_update_key_and_folder(self, service):
|
||||
"""Test updating key and folder in existing progress."""
|
||||
# Start without key/folder
|
||||
await service.start_progress(
|
||||
progress_id="test-1",
|
||||
progress_type=ProgressType.SCAN,
|
||||
title="Test Scan",
|
||||
)
|
||||
|
||||
# Update with key and folder
|
||||
updated = await service.update_progress(
|
||||
progress_id="test-1",
|
||||
key="one-piece",
|
||||
folder="One Piece (1999)",
|
||||
current=10,
|
||||
)
|
||||
|
||||
assert updated.key == "one-piece"
|
||||
assert updated.folder == "One Piece (1999)"
|
||||
|
||||
# Verify to_dict includes the fields
|
||||
dict_repr = updated.to_dict()
|
||||
assert dict_repr["key"] == "one-piece"
|
||||
assert dict_repr["folder"] == "One Piece (1999)"
|
||||
|
||||
def test_progress_update_to_dict_without_key_folder(self):
|
||||
"""Test to_dict doesn't include key/folder if not set."""
|
||||
update = ProgressUpdate(
|
||||
id="test-1",
|
||||
type=ProgressType.SYSTEM,
|
||||
status=ProgressStatus.STARTED,
|
||||
title="System Task",
|
||||
)
|
||||
|
||||
result = update.to_dict()
|
||||
|
||||
# key and folder should not be in dict if not set
|
||||
assert "key" not in result
|
||||
assert "folder" not in result
|
||||
|
||||
def test_progress_update_creation_with_key_folder(self):
|
||||
"""Test creating progress update with key and folder."""
|
||||
update = ProgressUpdate(
|
||||
id="test-1",
|
||||
type=ProgressType.DOWNLOAD,
|
||||
status=ProgressStatus.STARTED,
|
||||
title="Test Download",
|
||||
key="naruto",
|
||||
folder="Naruto (2002)",
|
||||
total=100,
|
||||
)
|
||||
|
||||
assert update.key == "naruto"
|
||||
assert update.folder == "Naruto (2002)"
|
||||
|
||||
# Verify to_dict includes them
|
||||
result = update.to_dict()
|
||||
assert result["key"] == "naruto"
|
||||
assert result["folder"] == "Naruto (2002)"
|
||||
|
||||
739
tests/unit/test_scan_service.py
Normal file
739
tests/unit/test_scan_service.py
Normal file
@ -0,0 +1,739 @@
|
||||
"""Unit tests for ScanService.
|
||||
|
||||
This module contains comprehensive tests for the scan service,
|
||||
including scan lifecycle, progress callbacks, event handling,
|
||||
and key-based identification.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.interfaces.callbacks import (
|
||||
CallbackManager,
|
||||
CompletionContext,
|
||||
ErrorContext,
|
||||
OperationType,
|
||||
ProgressContext,
|
||||
ProgressPhase,
|
||||
)
|
||||
from src.server.services.scan_service import (
|
||||
ScanProgress,
|
||||
ScanService,
|
||||
ScanServiceCompletionCallback,
|
||||
ScanServiceError,
|
||||
ScanServiceErrorCallback,
|
||||
ScanServiceProgressCallback,
|
||||
get_scan_service,
|
||||
reset_scan_service,
|
||||
)
|
||||
|
||||
|
||||
class TestScanProgress:
|
||||
"""Test ScanProgress class."""
|
||||
|
||||
def test_scan_progress_creation(self):
|
||||
"""Test creating a scan progress instance."""
|
||||
progress = ScanProgress("scan-123")
|
||||
|
||||
assert progress.scan_id == "scan-123"
|
||||
assert progress.status == "started"
|
||||
assert progress.current == 0
|
||||
assert progress.total == 0
|
||||
assert progress.percentage == 0.0
|
||||
assert progress.message == "Initializing scan..."
|
||||
assert progress.key is None
|
||||
assert progress.folder is None
|
||||
assert progress.series_found == 0
|
||||
assert progress.errors == []
|
||||
assert isinstance(progress.started_at, datetime)
|
||||
assert isinstance(progress.updated_at, datetime)
|
||||
|
||||
def test_scan_progress_to_dict_basic(self):
|
||||
"""Test converting scan progress to dictionary without key/folder."""
|
||||
progress = ScanProgress("scan-123")
|
||||
progress.current = 5
|
||||
progress.total = 10
|
||||
progress.percentage = 50.0
|
||||
progress.status = "in_progress"
|
||||
progress.message = "Scanning..."
|
||||
|
||||
result = progress.to_dict()
|
||||
|
||||
assert result["scan_id"] == "scan-123"
|
||||
assert result["status"] == "in_progress"
|
||||
assert result["current"] == 5
|
||||
assert result["total"] == 10
|
||||
assert result["percentage"] == 50.0
|
||||
assert result["message"] == "Scanning..."
|
||||
assert result["series_found"] == 0
|
||||
assert result["errors"] == []
|
||||
assert "started_at" in result
|
||||
assert "updated_at" in result
|
||||
# key and folder should not be present when None
|
||||
assert "key" not in result
|
||||
assert "folder" not in result
|
||||
|
||||
def test_scan_progress_to_dict_with_key_and_folder(self):
|
||||
"""Test converting scan progress to dictionary with key and folder."""
|
||||
progress = ScanProgress("scan-123")
|
||||
progress.key = "attack-on-titan"
|
||||
progress.folder = "Attack on Titan (2013)"
|
||||
progress.series_found = 5
|
||||
|
||||
result = progress.to_dict()
|
||||
|
||||
assert result["key"] == "attack-on-titan"
|
||||
assert result["folder"] == "Attack on Titan (2013)"
|
||||
assert result["series_found"] == 5
|
||||
|
||||
def test_scan_progress_to_dict_with_errors(self):
|
||||
"""Test scan progress with error messages."""
|
||||
progress = ScanProgress("scan-123")
|
||||
progress.errors = ["Error 1", "Error 2"]
|
||||
|
||||
result = progress.to_dict()
|
||||
|
||||
assert result["errors"] == ["Error 1", "Error 2"]
|
||||
|
||||
|
||||
class TestScanServiceProgressCallback:
|
||||
"""Test ScanServiceProgressCallback class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service(self):
|
||||
"""Create a mock ScanService."""
|
||||
service = MagicMock(spec=ScanService)
|
||||
service._handle_progress_update = AsyncMock()
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def scan_progress(self):
|
||||
"""Create a scan progress instance."""
|
||||
return ScanProgress("scan-123")
|
||||
|
||||
def test_on_progress_updates_progress(self, mock_service, scan_progress):
|
||||
"""Test that on_progress updates scan progress correctly."""
|
||||
callback = ScanServiceProgressCallback(mock_service, scan_progress)
|
||||
|
||||
context = ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
phase=ProgressPhase.IN_PROGRESS,
|
||||
current=5,
|
||||
total=10,
|
||||
percentage=50.0,
|
||||
message="Scanning: Test Folder",
|
||||
key="test-series",
|
||||
folder="Test Folder",
|
||||
)
|
||||
|
||||
# Call directly - no event loop needed since we handle RuntimeError
|
||||
callback.on_progress(context)
|
||||
|
||||
assert scan_progress.current == 5
|
||||
assert scan_progress.total == 10
|
||||
assert scan_progress.percentage == 50.0
|
||||
assert scan_progress.message == "Scanning: Test Folder"
|
||||
assert scan_progress.key == "test-series"
|
||||
assert scan_progress.folder == "Test Folder"
|
||||
assert scan_progress.status == "in_progress"
|
||||
|
||||
def test_on_progress_starting_phase(self, mock_service, scan_progress):
|
||||
"""Test progress callback with STARTING phase."""
|
||||
callback = ScanServiceProgressCallback(mock_service, scan_progress)
|
||||
|
||||
context = ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
phase=ProgressPhase.STARTING,
|
||||
current=0,
|
||||
total=0,
|
||||
percentage=0.0,
|
||||
message="Initializing...",
|
||||
)
|
||||
|
||||
callback.on_progress(context)
|
||||
|
||||
assert scan_progress.status == "started"
|
||||
|
||||
def test_on_progress_completed_phase(self, mock_service, scan_progress):
|
||||
"""Test progress callback with COMPLETED phase."""
|
||||
callback = ScanServiceProgressCallback(mock_service, scan_progress)
|
||||
|
||||
context = ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
phase=ProgressPhase.COMPLETED,
|
||||
current=10,
|
||||
total=10,
|
||||
percentage=100.0,
|
||||
message="Scan completed",
|
||||
)
|
||||
|
||||
callback.on_progress(context)
|
||||
|
||||
assert scan_progress.status == "completed"
|
||||
|
||||
|
||||
class TestScanServiceErrorCallback:
|
||||
"""Test ScanServiceErrorCallback class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service(self):
|
||||
"""Create a mock ScanService."""
|
||||
service = MagicMock(spec=ScanService)
|
||||
service._handle_scan_error = AsyncMock()
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def scan_progress(self):
|
||||
"""Create a scan progress instance."""
|
||||
return ScanProgress("scan-123")
|
||||
|
||||
def test_on_error_adds_error_message(self, mock_service, scan_progress):
|
||||
"""Test that on_error adds error to scan progress."""
|
||||
callback = ScanServiceErrorCallback(mock_service, scan_progress)
|
||||
|
||||
error = ValueError("Test error")
|
||||
context = ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
error=error,
|
||||
message="Failed to process folder",
|
||||
recoverable=True,
|
||||
key="test-series",
|
||||
folder="Test Folder",
|
||||
)
|
||||
|
||||
callback.on_error(context)
|
||||
|
||||
assert len(scan_progress.errors) == 1
|
||||
assert "[Test Folder]" in scan_progress.errors[0]
|
||||
assert "Failed to process folder" in scan_progress.errors[0]
|
||||
|
||||
def test_on_error_without_folder(self, mock_service, scan_progress):
|
||||
"""Test error callback without folder information."""
|
||||
callback = ScanServiceErrorCallback(mock_service, scan_progress)
|
||||
|
||||
error = ValueError("Test error")
|
||||
context = ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
error=error,
|
||||
message="Generic error",
|
||||
recoverable=False,
|
||||
)
|
||||
|
||||
callback.on_error(context)
|
||||
|
||||
assert len(scan_progress.errors) == 1
|
||||
assert scan_progress.errors[0] == "Generic error"
|
||||
|
||||
|
||||
class TestScanServiceCompletionCallback:
|
||||
"""Test ScanServiceCompletionCallback class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service(self):
|
||||
"""Create a mock ScanService."""
|
||||
service = MagicMock(spec=ScanService)
|
||||
service._handle_scan_completion = AsyncMock()
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def scan_progress(self):
|
||||
"""Create a scan progress instance."""
|
||||
return ScanProgress("scan-123")
|
||||
|
||||
def test_on_completion_success(self, mock_service, scan_progress):
|
||||
"""Test completion callback with success."""
|
||||
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
|
||||
|
||||
context = CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
success=True,
|
||||
message="Scan completed successfully",
|
||||
statistics={"series_found": 10, "total_folders": 15},
|
||||
)
|
||||
|
||||
callback.on_completion(context)
|
||||
|
||||
assert scan_progress.status == "completed"
|
||||
assert scan_progress.message == "Scan completed successfully"
|
||||
assert scan_progress.series_found == 10
|
||||
|
||||
def test_on_completion_failure(self, mock_service, scan_progress):
|
||||
"""Test completion callback with failure."""
|
||||
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
|
||||
|
||||
context = CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id="scan-123",
|
||||
success=False,
|
||||
message="Scan failed: critical error",
|
||||
)
|
||||
|
||||
callback.on_completion(context)
|
||||
|
||||
assert scan_progress.status == "failed"
|
||||
assert scan_progress.message == "Scan failed: critical error"
|
||||
|
||||
|
||||
class TestScanService:
|
||||
"""Test ScanService class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_progress_service(self):
|
||||
"""Create a mock progress service."""
|
||||
service = MagicMock()
|
||||
service.start_progress = AsyncMock()
|
||||
service.update_progress = AsyncMock()
|
||||
service.complete_progress = AsyncMock()
|
||||
service.fail_progress = AsyncMock()
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, mock_progress_service):
|
||||
"""Create a ScanService instance for each test."""
|
||||
return ScanService(progress_service=mock_progress_service)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_initialization(self, service):
|
||||
"""Test ScanService initialization."""
|
||||
assert service.is_scanning is False
|
||||
assert service.current_scan is None
|
||||
assert service._scan_history == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_scan(self, service):
|
||||
"""Test starting a new scan."""
|
||||
scanner_factory = MagicMock()
|
||||
|
||||
scan_id = await service.start_scan(scanner_factory)
|
||||
|
||||
assert scan_id is not None
|
||||
assert len(scan_id) > 0
|
||||
assert service.is_scanning is True
|
||||
assert service.current_scan is not None
|
||||
assert service.current_scan.scan_id == scan_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_scan_while_scanning(self, service):
|
||||
"""Test starting scan while another is in progress raises error."""
|
||||
scanner_factory = MagicMock()
|
||||
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
with pytest.raises(ScanServiceError, match="already in progress"):
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_scan(self, service):
|
||||
"""Test cancelling a scan in progress."""
|
||||
scanner_factory = MagicMock()
|
||||
scan_id = await service.start_scan(scanner_factory)
|
||||
|
||||
result = await service.cancel_scan()
|
||||
|
||||
assert result is True
|
||||
assert service.is_scanning is False
|
||||
assert service.current_scan.status == "cancelled"
|
||||
assert len(service._scan_history) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_scan_no_scan_in_progress(self, service):
|
||||
"""Test cancelling when no scan is in progress."""
|
||||
result = await service.cancel_scan()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_status(self, service):
|
||||
"""Test getting scan status."""
|
||||
status = await service.get_scan_status()
|
||||
|
||||
assert status["is_scanning"] is False
|
||||
assert status["current_scan"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_status_while_scanning(self, service):
|
||||
"""Test getting scan status while scanning."""
|
||||
scanner_factory = MagicMock()
|
||||
scan_id = await service.start_scan(scanner_factory)
|
||||
|
||||
status = await service.get_scan_status()
|
||||
|
||||
assert status["is_scanning"] is True
|
||||
assert status["current_scan"] is not None
|
||||
assert status["current_scan"]["scan_id"] == scan_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_history_empty(self, service):
|
||||
"""Test getting scan history when empty."""
|
||||
history = await service.get_scan_history()
|
||||
|
||||
assert history == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_history_with_entries(self, service):
|
||||
"""Test getting scan history with entries."""
|
||||
# Start and cancel multiple scans to populate history
|
||||
scanner_factory = MagicMock()
|
||||
|
||||
await service.start_scan(scanner_factory)
|
||||
await service.cancel_scan()
|
||||
|
||||
await service.start_scan(scanner_factory)
|
||||
await service.cancel_scan()
|
||||
|
||||
history = await service.get_scan_history()
|
||||
|
||||
assert len(history) == 2
|
||||
# Should be newest first
|
||||
assert history[0]["status"] == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_history_limit(self, service):
|
||||
"""Test scan history respects limit."""
|
||||
scanner_factory = MagicMock()
|
||||
|
||||
# Create 3 history entries
|
||||
for _ in range(3):
|
||||
await service.start_scan(scanner_factory)
|
||||
await service.cancel_scan()
|
||||
|
||||
history = await service.get_scan_history(limit=2)
|
||||
|
||||
assert len(history) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_to_scan_events(self, service):
|
||||
"""Test subscribing to scan events."""
|
||||
handler = MagicMock()
|
||||
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
assert handler in service._scan_event_handlers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_from_scan_events(self, service):
|
||||
"""Test unsubscribing from scan events."""
|
||||
handler = MagicMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
service.unsubscribe_from_scan_events(handler)
|
||||
|
||||
assert handler not in service._scan_event_handlers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_scan_event(self, service):
|
||||
"""Test emitting scan events to handlers."""
|
||||
handler = AsyncMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
await service._emit_scan_event({
|
||||
"type": "scan_progress",
|
||||
"key": "test-series",
|
||||
"folder": "Test Folder",
|
||||
})
|
||||
|
||||
handler.assert_called_once_with({
|
||||
"type": "scan_progress",
|
||||
"key": "test-series",
|
||||
"folder": "Test Folder",
|
||||
})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_scan_event_sync_handler(self, service):
|
||||
"""Test emitting scan events to sync handlers."""
|
||||
handler = MagicMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
await service._emit_scan_event({
|
||||
"type": "scan_progress",
|
||||
"data": {"key": "test-series"},
|
||||
})
|
||||
|
||||
handler.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_callback_manager(self, service):
|
||||
"""Test creating a callback manager."""
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
callback_manager = service.create_callback_manager()
|
||||
|
||||
assert callback_manager is not None
|
||||
assert isinstance(callback_manager, CallbackManager)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_callback_manager_no_current_scan(self, service):
|
||||
"""Test creating callback manager without current scan."""
|
||||
callback_manager = service.create_callback_manager()
|
||||
|
||||
assert callback_manager is not None
|
||||
assert service.current_scan is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_progress_update(
|
||||
self, service, mock_progress_service
|
||||
):
|
||||
"""Test handling progress update."""
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
scan_progress.current = 5
|
||||
scan_progress.total = 10
|
||||
scan_progress.percentage = 50.0
|
||||
scan_progress.message = "Processing..."
|
||||
scan_progress.key = "test-series"
|
||||
scan_progress.folder = "Test Folder"
|
||||
|
||||
await service._handle_progress_update(scan_progress)
|
||||
|
||||
mock_progress_service.update_progress.assert_called_once()
|
||||
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
|
||||
assert call_kwargs["key"] == "test-series"
|
||||
assert call_kwargs["folder"] == "Test Folder"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_scan_error(self, service):
|
||||
"""Test handling scan error."""
|
||||
handler = AsyncMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
error_context = ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=scan_progress.scan_id,
|
||||
error=ValueError("Test error"),
|
||||
message="Test error message",
|
||||
recoverable=True,
|
||||
key="test-series",
|
||||
folder="Test Folder",
|
||||
)
|
||||
|
||||
await service._handle_scan_error(scan_progress, error_context)
|
||||
|
||||
# Handler is called twice: once for start, once for error
|
||||
assert handler.call_count == 2
|
||||
# Get the error event (second call)
|
||||
error_event = handler.call_args_list[1][0][0]
|
||||
assert error_event["type"] == "scan_error"
|
||||
assert error_event["key"] == "test-series"
|
||||
assert error_event["folder"] == "Test Folder"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_scan_completion_success(
|
||||
self, service, mock_progress_service
|
||||
):
|
||||
"""Test handling successful scan completion."""
|
||||
handler = AsyncMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
scanner_factory = MagicMock()
|
||||
scan_id = await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
completion_context = CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=scan_id,
|
||||
success=True,
|
||||
message="Scan completed",
|
||||
statistics={"series_found": 5, "total_folders": 10},
|
||||
)
|
||||
|
||||
await service._handle_scan_completion(
|
||||
scan_progress, completion_context
|
||||
)
|
||||
|
||||
assert service.is_scanning is False
|
||||
assert len(service._scan_history) == 1
|
||||
mock_progress_service.complete_progress.assert_called_once()
|
||||
# Handler is called twice: once for start, once for completion
|
||||
assert handler.call_count == 2
|
||||
# Get the completion event (second call)
|
||||
completion_event = handler.call_args_list[1][0][0]
|
||||
assert completion_event["type"] == "scan_completed"
|
||||
assert completion_event["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_scan_completion_failure(
|
||||
self, service, mock_progress_service
|
||||
):
|
||||
"""Test handling failed scan completion."""
|
||||
handler = AsyncMock()
|
||||
service.subscribe_to_scan_events(handler)
|
||||
|
||||
scanner_factory = MagicMock()
|
||||
scan_id = await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
completion_context = CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=scan_id,
|
||||
success=False,
|
||||
message="Scan failed: critical error",
|
||||
)
|
||||
|
||||
await service._handle_scan_completion(
|
||||
scan_progress, completion_context
|
||||
)
|
||||
|
||||
assert service.is_scanning is False
|
||||
mock_progress_service.fail_progress.assert_called_once()
|
||||
# Handler is called twice: once for start, once for failure
|
||||
assert handler.call_count == 2
|
||||
# Get the failure event (second call)
|
||||
failure_event = handler.call_args_list[1][0][0]
|
||||
assert failure_event["type"] == "scan_failed"
|
||||
assert failure_event["success"] is False
|
||||
|
||||
|
||||
class TestScanServiceSingleton:
|
||||
"""Test ScanService singleton functions."""
|
||||
|
||||
def test_get_scan_service_returns_singleton(self):
|
||||
"""Test that get_scan_service returns a singleton."""
|
||||
reset_scan_service()
|
||||
|
||||
service1 = get_scan_service()
|
||||
service2 = get_scan_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
def test_reset_scan_service(self):
|
||||
"""Test that reset_scan_service clears the singleton."""
|
||||
reset_scan_service()
|
||||
service1 = get_scan_service()
|
||||
|
||||
reset_scan_service()
|
||||
service2 = get_scan_service()
|
||||
|
||||
assert service1 is not service2
|
||||
|
||||
|
||||
class TestScanServiceKeyIdentification:
|
||||
"""Test that ScanService uses key as primary identifier."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_progress_service(self):
|
||||
"""Create a mock progress service."""
|
||||
service = MagicMock()
|
||||
service.start_progress = AsyncMock()
|
||||
service.update_progress = AsyncMock()
|
||||
service.complete_progress = AsyncMock()
|
||||
service.fail_progress = AsyncMock()
|
||||
return service
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, mock_progress_service):
|
||||
"""Create a ScanService instance."""
|
||||
return ScanService(progress_service=mock_progress_service)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_update_includes_key(
|
||||
self, service, mock_progress_service
|
||||
):
|
||||
"""Test that progress updates include key as primary identifier."""
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
scan_progress.key = "attack-on-titan"
|
||||
scan_progress.folder = "Attack on Titan (2013)"
|
||||
|
||||
await service._handle_progress_update(scan_progress)
|
||||
|
||||
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
|
||||
assert call_kwargs["key"] == "attack-on-titan"
|
||||
assert call_kwargs["folder"] == "Attack on Titan (2013)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_event_includes_key(self, service):
|
||||
"""Test that scan events include key as primary identifier."""
|
||||
events_received = []
|
||||
|
||||
async def capture_event(event):
|
||||
events_received.append(event)
|
||||
|
||||
service.subscribe_to_scan_events(capture_event)
|
||||
|
||||
await service._emit_scan_event({
|
||||
"type": "scan_progress",
|
||||
"key": "my-hero-academia",
|
||||
"folder": "My Hero Academia (2016)",
|
||||
"data": {"status": "in_progress"},
|
||||
})
|
||||
|
||||
assert len(events_received) == 1
|
||||
assert events_received[0]["key"] == "my-hero-academia"
|
||||
assert events_received[0]["folder"] == "My Hero Academia (2016)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_includes_key(self, service):
|
||||
"""Test that error events include key as primary identifier."""
|
||||
events_received = []
|
||||
|
||||
async def capture_event(event):
|
||||
events_received.append(event)
|
||||
|
||||
service.subscribe_to_scan_events(capture_event)
|
||||
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
scan_progress = service.current_scan
|
||||
error_context = ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=scan_progress.scan_id,
|
||||
error=ValueError("Test"),
|
||||
message="Error message",
|
||||
key="demon-slayer",
|
||||
folder="Demon Slayer (2019)",
|
||||
)
|
||||
|
||||
await service._handle_scan_error(scan_progress, error_context)
|
||||
|
||||
assert len(events_received) == 2 # Started + error
|
||||
error_event = events_received[1]
|
||||
assert error_event["type"] == "scan_error"
|
||||
assert error_event["key"] == "demon-slayer"
|
||||
assert error_event["folder"] == "Demon Slayer (2019)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_status_includes_key(self, service):
|
||||
"""Test that scan status includes key in current scan."""
|
||||
scanner_factory = MagicMock()
|
||||
await service.start_scan(scanner_factory)
|
||||
|
||||
service.current_scan.key = "one-piece"
|
||||
service.current_scan.folder = "One Piece (1999)"
|
||||
|
||||
status = await service.get_scan_status()
|
||||
|
||||
assert status["current_scan"]["key"] == "one-piece"
|
||||
assert status["current_scan"]["folder"] == "One Piece (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_history_includes_key(self, service):
|
||||
"""Test that scan history includes key in entries."""
|
||||
scanner_factory = MagicMock()
|
||||
|
||||
await service.start_scan(scanner_factory)
|
||||
service.current_scan.key = "naruto"
|
||||
service.current_scan.folder = "Naruto (2002)"
|
||||
await service.cancel_scan()
|
||||
|
||||
history = await service.get_scan_history()
|
||||
|
||||
assert len(history) == 1
|
||||
assert history[0]["key"] == "naruto"
|
||||
assert history[0]["folder"] == "Naruto (2002)"
|
||||
244
tests/unit/test_serie_class.py
Normal file
244
tests/unit/test_serie_class.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""
|
||||
Unit tests for Serie class to verify key validation and identifier usage.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
|
||||
class TestSerieValidation:
|
||||
"""Test Serie class validation logic."""
|
||||
|
||||
def test_serie_creation_with_valid_key(self):
|
||||
"""Test creating Serie with valid key."""
|
||||
serie = Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="https://aniworld.to/anime/stream/attack-on-titan",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]}
|
||||
)
|
||||
|
||||
assert serie.key == "attack-on-titan"
|
||||
assert serie.name == "Attack on Titan"
|
||||
assert serie.site == "https://aniworld.to/anime/stream/attack-on-titan"
|
||||
assert serie.folder == "Attack on Titan (2013)"
|
||||
assert serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
|
||||
|
||||
def test_serie_creation_with_empty_key_raises_error(self):
|
||||
"""Test that creating Serie with empty key raises ValueError."""
|
||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
||||
Serie(
|
||||
key="",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
def test_serie_creation_with_whitespace_key_raises_error(self):
|
||||
"""Test that creating Serie with whitespace-only key raises error."""
|
||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
||||
Serie(
|
||||
key=" ",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
def test_serie_key_is_stripped(self):
|
||||
"""Test that Serie key is stripped of whitespace."""
|
||||
serie = Serie(
|
||||
key=" attack-on-titan ",
|
||||
name="Attack on Titan",
|
||||
site="https://example.com",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
assert serie.key == "attack-on-titan"
|
||||
|
||||
def test_serie_key_setter_with_valid_value(self):
|
||||
"""Test setting key property with valid value."""
|
||||
serie = Serie(
|
||||
key="initial-key",
|
||||
name="Test",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
serie.key = "new-key"
|
||||
assert serie.key == "new-key"
|
||||
|
||||
def test_serie_key_setter_with_empty_value_raises_error(self):
|
||||
"""Test that setting key to empty string raises ValueError."""
|
||||
serie = Serie(
|
||||
key="initial-key",
|
||||
name="Test",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
||||
serie.key = ""
|
||||
|
||||
def test_serie_key_setter_with_whitespace_raises_error(self):
|
||||
"""Test that setting key to whitespace raises ValueError."""
|
||||
serie = Serie(
|
||||
key="initial-key",
|
||||
name="Test",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="key cannot be None or empty"):
|
||||
serie.key = " "
|
||||
|
||||
def test_serie_key_setter_strips_whitespace(self):
|
||||
"""Test that key setter strips whitespace."""
|
||||
serie = Serie(
|
||||
key="initial-key",
|
||||
name="Test",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
serie.key = " new-key "
|
||||
assert serie.key == "new-key"
|
||||
|
||||
|
||||
class TestSerieProperties:
|
||||
"""Test Serie class properties and methods."""
|
||||
|
||||
def test_serie_str_representation(self):
|
||||
"""Test string representation of Serie."""
|
||||
serie = Serie(
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1, 2]}
|
||||
)
|
||||
|
||||
str_repr = str(serie)
|
||||
assert "key='test-key'" in str_repr
|
||||
assert "name='Test Series'" in str_repr
|
||||
assert "folder='Test Folder'" in str_repr
|
||||
|
||||
def test_serie_to_dict(self):
|
||||
"""Test conversion of Serie to dictionary."""
|
||||
serie = Serie(
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1, 2], 2: [1, 2, 3]}
|
||||
)
|
||||
|
||||
data = serie.to_dict()
|
||||
|
||||
assert data["key"] == "test-key"
|
||||
assert data["name"] == "Test Series"
|
||||
assert data["site"] == "https://example.com"
|
||||
assert data["folder"] == "Test Folder"
|
||||
assert "1" in data["episodeDict"]
|
||||
assert data["episodeDict"]["1"] == [1, 2]
|
||||
|
||||
def test_serie_from_dict(self):
|
||||
"""Test creating Serie from dictionary."""
|
||||
data = {
|
||||
"key": "test-key",
|
||||
"name": "Test Series",
|
||||
"site": "https://example.com",
|
||||
"folder": "Test Folder",
|
||||
"episodeDict": {"1": [1, 2], "2": [1, 2, 3]}
|
||||
}
|
||||
|
||||
serie = Serie.from_dict(data)
|
||||
|
||||
assert serie.key == "test-key"
|
||||
assert serie.name == "Test Series"
|
||||
assert serie.folder == "Test Folder"
|
||||
assert serie.episodeDict == {1: [1, 2], 2: [1, 2, 3]}
|
||||
|
||||
def test_serie_save_and_load_from_file(self):
|
||||
"""Test saving and loading Serie from file."""
|
||||
serie = Serie(
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Folder",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
|
||||
# Create temporary file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
delete=False,
|
||||
suffix='.json'
|
||||
) as f:
|
||||
temp_filename = f.name
|
||||
|
||||
try:
|
||||
# Save to file
|
||||
serie.save_to_file(temp_filename)
|
||||
|
||||
# Load from file
|
||||
loaded_serie = Serie.load_from_file(temp_filename)
|
||||
|
||||
# Verify all properties match
|
||||
assert loaded_serie.key == serie.key
|
||||
assert loaded_serie.name == serie.name
|
||||
assert loaded_serie.site == serie.site
|
||||
assert loaded_serie.folder == serie.folder
|
||||
assert loaded_serie.episodeDict == serie.episodeDict
|
||||
finally:
|
||||
# Cleanup
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
|
||||
def test_serie_folder_is_mutable(self):
|
||||
"""Test that folder property can be changed (it's metadata only)."""
|
||||
serie = Serie(
|
||||
key="test-key",
|
||||
name="Test",
|
||||
site="https://example.com",
|
||||
folder="Old Folder",
|
||||
episodeDict={1: [1]}
|
||||
)
|
||||
|
||||
serie.folder = "New Folder"
|
||||
assert serie.folder == "New Folder"
|
||||
# Key should remain unchanged
|
||||
assert serie.key == "test-key"
|
||||
|
||||
|
||||
class TestSerieDocumentation:
|
||||
"""Test that Serie class has proper documentation."""
|
||||
|
||||
def test_serie_class_has_docstring(self):
|
||||
"""Test that Serie class has a docstring."""
|
||||
assert Serie.__doc__ is not None
|
||||
assert "unique identifier" in Serie.__doc__.lower()
|
||||
|
||||
def test_key_property_has_docstring(self):
|
||||
"""Test that key property has descriptive docstring."""
|
||||
assert Serie.key.fget.__doc__ is not None
|
||||
assert "unique" in Serie.key.fget.__doc__.lower()
|
||||
assert "identifier" in Serie.key.fget.__doc__.lower()
|
||||
|
||||
def test_folder_property_has_docstring(self):
|
||||
"""Test that folder property documents it's metadata only."""
|
||||
assert Serie.folder.fget.__doc__ is not None
|
||||
assert "metadata" in Serie.folder.fget.__doc__.lower()
|
||||
assert "not used for lookups" in Serie.folder.fget.__doc__.lower()
|
||||
203
tests/unit/test_serie_list.py
Normal file
203
tests/unit/test_serie_list.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""Tests for SerieList class - identifier standardization."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.SerieList import SerieList
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_directory():
|
||||
"""Create a temporary directory for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie():
|
||||
"""Create a sample Serie for testing."""
|
||||
return Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="https://aniworld.to/anime/stream/attack-on-titan",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
|
||||
|
||||
class TestSerieListKeyBasedStorage:
|
||||
"""Test SerieList uses key for internal storage."""
|
||||
|
||||
def test_init_creates_empty_keydict(self, temp_directory):
|
||||
"""Test initialization creates keyDict."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
assert hasattr(serie_list, 'keyDict')
|
||||
assert isinstance(serie_list.keyDict, dict)
|
||||
|
||||
def test_add_stores_by_key(self, temp_directory, sample_serie):
|
||||
"""Test add() stores series by key."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
# Verify stored by key, not folder
|
||||
assert sample_serie.key in serie_list.keyDict
|
||||
assert serie_list.keyDict[sample_serie.key] == sample_serie
|
||||
|
||||
def test_contains_checks_by_key(self, temp_directory, sample_serie):
|
||||
"""Test contains() checks by key."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
assert serie_list.contains(sample_serie.key)
|
||||
assert not serie_list.contains("nonexistent-key")
|
||||
|
||||
def test_add_prevents_duplicates_by_key(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
"""Test add() prevents duplicates based on key."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
# Add same serie twice
|
||||
serie_list.add(sample_serie)
|
||||
initial_count = len(serie_list.keyDict)
|
||||
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
# Should still have only one entry
|
||||
assert len(serie_list.keyDict) == initial_count
|
||||
assert len(serie_list.keyDict) == 1
|
||||
|
||||
def test_get_by_key_returns_correct_serie(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
"""Test get_by_key() retrieves series correctly."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
result = serie_list.get_by_key(sample_serie.key)
|
||||
assert result is not None
|
||||
assert result.key == sample_serie.key
|
||||
assert result.name == sample_serie.name
|
||||
|
||||
def test_get_by_key_returns_none_for_missing(self, temp_directory):
|
||||
"""Test get_by_key() returns None for nonexistent key."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
result = serie_list.get_by_key("nonexistent-key")
|
||||
assert result is None
|
||||
|
||||
def test_get_by_folder_backward_compatibility(
|
||||
self, temp_directory, sample_serie
|
||||
):
|
||||
"""Test get_by_folder() provides backward compatibility."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
result = serie_list.get_by_folder(sample_serie.folder)
|
||||
assert result is not None
|
||||
assert result.key == sample_serie.key
|
||||
assert result.folder == sample_serie.folder
|
||||
|
||||
def test_get_by_folder_returns_none_for_missing(self, temp_directory):
|
||||
"""Test get_by_folder() returns None for nonexistent folder."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
result = serie_list.get_by_folder("Nonexistent Folder")
|
||||
assert result is None
|
||||
|
||||
def test_get_all_returns_all_series(self, temp_directory, sample_serie):
|
||||
"""Test get_all() returns all series from keyDict."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
serie2 = Serie(
|
||||
key="naruto",
|
||||
name="Naruto",
|
||||
site="https://aniworld.to/anime/stream/naruto",
|
||||
folder="Naruto (2002)",
|
||||
episodeDict={1: [1, 2]}
|
||||
)
|
||||
serie_list.add(serie2)
|
||||
|
||||
all_series = serie_list.get_all()
|
||||
assert len(all_series) == 2
|
||||
assert sample_serie in all_series
|
||||
assert serie2 in all_series
|
||||
|
||||
def test_get_missing_episodes_filters_by_episode_dict(
|
||||
self, temp_directory
|
||||
):
|
||||
"""Test get_missing_episodes() returns only series with episodes."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
# Serie with missing episodes
|
||||
serie_with_episodes = Serie(
|
||||
key="serie-with-episodes",
|
||||
name="Serie With Episodes",
|
||||
site="https://aniworld.to/anime/stream/serie-with-episodes",
|
||||
folder="Serie With Episodes (2020)",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
|
||||
# Serie without missing episodes
|
||||
serie_without_episodes = Serie(
|
||||
key="serie-without-episodes",
|
||||
name="Serie Without Episodes",
|
||||
site="https://aniworld.to/anime/stream/serie-without-episodes",
|
||||
folder="Serie Without Episodes (2021)",
|
||||
episodeDict={}
|
||||
)
|
||||
|
||||
serie_list.add(serie_with_episodes)
|
||||
serie_list.add(serie_without_episodes)
|
||||
|
||||
missing = serie_list.get_missing_episodes()
|
||||
assert len(missing) == 1
|
||||
assert serie_with_episodes in missing
|
||||
assert serie_without_episodes not in missing
|
||||
|
||||
def test_load_series_stores_by_key(self, temp_directory, sample_serie):
|
||||
"""Test load_series() stores series by key when loading from disk."""
|
||||
# Create directory structure and save serie
|
||||
folder_path = os.path.join(temp_directory, sample_serie.folder)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
data_path = os.path.join(folder_path, "data")
|
||||
sample_serie.save_to_file(data_path)
|
||||
|
||||
# Create new SerieList (triggers load_series in __init__)
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
# Verify loaded by key
|
||||
assert sample_serie.key in serie_list.keyDict
|
||||
loaded_serie = serie_list.keyDict[sample_serie.key]
|
||||
assert loaded_serie.key == sample_serie.key
|
||||
assert loaded_serie.name == sample_serie.name
|
||||
|
||||
|
||||
class TestSerieListPublicAPI:
|
||||
"""Test that public API still works correctly."""
|
||||
|
||||
def test_public_methods_work(self, temp_directory, sample_serie):
|
||||
"""Test that all public methods work correctly after refactoring."""
|
||||
serie_list = SerieList(temp_directory)
|
||||
|
||||
# Test add
|
||||
serie_list.add(sample_serie)
|
||||
|
||||
# Test contains
|
||||
assert serie_list.contains(sample_serie.key)
|
||||
|
||||
# Test GetList/get_all
|
||||
assert len(serie_list.GetList()) == 1
|
||||
assert len(serie_list.get_all()) == 1
|
||||
|
||||
# Test GetMissingEpisode/get_missing_episodes
|
||||
assert len(serie_list.GetMissingEpisode()) == 1
|
||||
assert len(serie_list.get_missing_episodes()) == 1
|
||||
|
||||
# Test new helper methods
|
||||
assert serie_list.get_by_key(sample_serie.key) is not None
|
||||
assert serie_list.get_by_folder(sample_serie.folder) is not None
|
||||
@ -1,15 +1,19 @@
|
||||
"""
|
||||
Tests for template helper utilities.
|
||||
|
||||
This module tests the template helper functions.
|
||||
This module tests the template helper functions including series context
|
||||
preparation using `key` as the primary identifier.
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.utils.template_helpers import (
|
||||
filter_series_by_missing_episodes,
|
||||
get_base_context,
|
||||
get_series_by_key,
|
||||
list_available_templates,
|
||||
prepare_series_context,
|
||||
validate_template_exists,
|
||||
)
|
||||
|
||||
@ -84,3 +88,156 @@ class TestTemplateHelpers:
|
||||
"""Test that all required templates exist."""
|
||||
assert validate_template_exists(template_name), \
|
||||
f"Required template {template_name} does not exist"
|
||||
|
||||
|
||||
class TestSeriesContextHelpers:
|
||||
"""Test series context helper functions.
|
||||
|
||||
These tests verify that series helpers use `key` as the primary
|
||||
identifier following the project's identifier convention.
|
||||
"""
|
||||
|
||||
def test_prepare_series_context_uses_key(self):
|
||||
"""Test that prepare_series_context uses key as primary identifier."""
|
||||
series_data = [
|
||||
{
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
},
|
||||
{
|
||||
"key": "one-piece",
|
||||
"name": "One Piece",
|
||||
"folder": "One Piece (1999)",
|
||||
},
|
||||
]
|
||||
prepared = prepare_series_context(series_data)
|
||||
|
||||
assert len(prepared) == 2
|
||||
# Verify key is present and used
|
||||
assert prepared[0]["key"] in ("attack-on-titan", "one-piece")
|
||||
assert all("key" in item for item in prepared)
|
||||
assert all("folder" in item for item in prepared)
|
||||
|
||||
def test_prepare_series_context_sorts_by_name(self):
|
||||
"""Test that series are sorted by name by default."""
|
||||
series_data = [
|
||||
{"key": "z-series", "name": "Zebra Anime", "folder": "z"},
|
||||
{"key": "a-series", "name": "Alpha Anime", "folder": "a"},
|
||||
]
|
||||
prepared = prepare_series_context(series_data, sort_by="name")
|
||||
|
||||
assert prepared[0]["name"] == "Alpha Anime"
|
||||
assert prepared[1]["name"] == "Zebra Anime"
|
||||
|
||||
def test_prepare_series_context_sorts_by_key(self):
|
||||
"""Test that series can be sorted by key."""
|
||||
series_data = [
|
||||
{"key": "z-series", "name": "Zebra", "folder": "z"},
|
||||
{"key": "a-series", "name": "Alpha", "folder": "a"},
|
||||
]
|
||||
prepared = prepare_series_context(series_data, sort_by="key")
|
||||
|
||||
assert prepared[0]["key"] == "a-series"
|
||||
assert prepared[1]["key"] == "z-series"
|
||||
|
||||
def test_prepare_series_context_empty_list(self):
|
||||
"""Test prepare_series_context with empty list."""
|
||||
prepared = prepare_series_context([])
|
||||
assert prepared == []
|
||||
|
||||
def test_prepare_series_context_skips_missing_key(self):
|
||||
"""Test that items without key are skipped with warning."""
|
||||
series_data = [
|
||||
{"key": "valid-series", "name": "Valid", "folder": "valid"},
|
||||
{"name": "No Key", "folder": "nokey"}, # Missing key
|
||||
]
|
||||
prepared = prepare_series_context(series_data)
|
||||
|
||||
assert len(prepared) == 1
|
||||
assert prepared[0]["key"] == "valid-series"
|
||||
|
||||
def test_prepare_series_context_preserves_extra_fields(self):
|
||||
"""Test that extra fields are preserved."""
|
||||
series_data = [
|
||||
{
|
||||
"key": "test",
|
||||
"name": "Test",
|
||||
"folder": "test",
|
||||
"missing_episodes": {"1": [1, 2]},
|
||||
"site": "aniworld.to",
|
||||
}
|
||||
]
|
||||
prepared = prepare_series_context(series_data)
|
||||
|
||||
assert prepared[0]["missing_episodes"] == {"1": [1, 2]}
|
||||
assert prepared[0]["site"] == "aniworld.to"
|
||||
|
||||
def test_get_series_by_key_found(self):
|
||||
"""Test finding a series by key."""
|
||||
series_data = [
|
||||
{"key": "attack-on-titan", "name": "Attack on Titan"},
|
||||
{"key": "one-piece", "name": "One Piece"},
|
||||
]
|
||||
result = get_series_by_key(series_data, "attack-on-titan")
|
||||
|
||||
assert result is not None
|
||||
assert result["name"] == "Attack on Titan"
|
||||
|
||||
def test_get_series_by_key_not_found(self):
|
||||
"""Test that None is returned when key not found."""
|
||||
series_data = [
|
||||
{"key": "attack-on-titan", "name": "Attack on Titan"},
|
||||
]
|
||||
result = get_series_by_key(series_data, "non-existent")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_series_by_key_empty_list(self):
|
||||
"""Test get_series_by_key with empty list."""
|
||||
result = get_series_by_key([], "any-key")
|
||||
assert result is None
|
||||
|
||||
def test_filter_series_by_missing_episodes(self):
|
||||
"""Test filtering series with missing episodes."""
|
||||
series_data = [
|
||||
{
|
||||
"key": "has-missing",
|
||||
"name": "Has Missing",
|
||||
"missing_episodes": {"1": [1, 2, 3]},
|
||||
},
|
||||
{
|
||||
"key": "no-missing",
|
||||
"name": "No Missing",
|
||||
"missing_episodes": {},
|
||||
},
|
||||
{
|
||||
"key": "empty-seasons",
|
||||
"name": "Empty Seasons",
|
||||
"missing_episodes": {"1": [], "2": []},
|
||||
},
|
||||
]
|
||||
filtered = filter_series_by_missing_episodes(series_data)
|
||||
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["key"] == "has-missing"
|
||||
|
||||
def test_filter_series_by_missing_episodes_empty(self):
|
||||
"""Test filter with empty list."""
|
||||
filtered = filter_series_by_missing_episodes([])
|
||||
assert filtered == []
|
||||
|
||||
def test_filter_preserves_key_identifier(self):
|
||||
"""Test that filter preserves key as identifier."""
|
||||
series_data = [
|
||||
{
|
||||
"key": "test-series",
|
||||
"folder": "Test Series (2020)",
|
||||
"name": "Test",
|
||||
"missing_episodes": {"1": [1]},
|
||||
}
|
||||
]
|
||||
filtered = filter_series_by_missing_episodes(series_data)
|
||||
|
||||
assert filtered[0]["key"] == "test-series"
|
||||
assert filtered[0]["folder"] == "Test Series (2020)"
|
||||
|
||||
533
tests/unit/test_validators.py
Normal file
533
tests/unit/test_validators.py
Normal file
@ -0,0 +1,533 @@
|
||||
"""
|
||||
Unit tests for data validation utilities.
|
||||
|
||||
Tests the validators module in src/server/utils/validators.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.utils.validators import (
|
||||
ValidatorMixin,
|
||||
sanitize_filename,
|
||||
validate_anime_url,
|
||||
validate_backup_name,
|
||||
validate_config_data,
|
||||
validate_download_priority,
|
||||
validate_download_quality,
|
||||
validate_episode_range,
|
||||
validate_ip_address,
|
||||
validate_jwt_token,
|
||||
validate_language,
|
||||
validate_series_key,
|
||||
validate_series_key_or_folder,
|
||||
validate_series_name,
|
||||
validate_websocket_message,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateSeriesKey:
|
||||
"""Tests for validate_series_key function."""
|
||||
|
||||
def test_valid_simple_key(self):
|
||||
"""Test valid simple key."""
|
||||
assert validate_series_key("naruto") == "naruto"
|
||||
|
||||
def test_valid_hyphenated_key(self):
|
||||
"""Test valid hyphenated key."""
|
||||
assert validate_series_key("attack-on-titan") == "attack-on-titan"
|
||||
|
||||
def test_valid_numeric_key(self):
|
||||
"""Test valid key with numbers."""
|
||||
assert validate_series_key("one-piece-2024") == "one-piece-2024"
|
||||
|
||||
def test_valid_key_starting_with_number(self):
|
||||
"""Test valid key starting with number."""
|
||||
assert validate_series_key("86-eighty-six") == "86-eighty-six"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""Test that whitespace is stripped."""
|
||||
assert validate_series_key(" naruto ") == "naruto"
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
"""Test empty string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_series_key("")
|
||||
|
||||
def test_none_raises(self):
|
||||
"""Test None raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_series_key(None)
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
"""Test whitespace-only string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_series_key(" ")
|
||||
|
||||
def test_uppercase_raises(self):
|
||||
"""Test uppercase letters raise ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("Attack-On-Titan")
|
||||
|
||||
def test_spaces_raises(self):
|
||||
"""Test spaces raise ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("attack on titan")
|
||||
|
||||
def test_underscores_raises(self):
|
||||
"""Test underscores raise ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("attack_on_titan")
|
||||
|
||||
def test_special_characters_raises(self):
|
||||
"""Test special characters raise ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("attack@titan")
|
||||
|
||||
def test_leading_hyphen_raises(self):
|
||||
"""Test leading hyphen raises ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("-attack-on-titan")
|
||||
|
||||
def test_trailing_hyphen_raises(self):
|
||||
"""Test trailing hyphen raises ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("attack-on-titan-")
|
||||
|
||||
def test_consecutive_hyphens_raises(self):
|
||||
"""Test consecutive hyphens raise ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
validate_series_key("attack--on--titan")
|
||||
|
||||
def test_key_too_long_raises(self):
|
||||
"""Test key exceeding 255 chars raises ValueError."""
|
||||
long_key = "a" * 256
|
||||
with pytest.raises(ValueError, match="255 characters"):
|
||||
validate_series_key(long_key)
|
||||
|
||||
def test_max_length_key(self):
|
||||
"""Test key at exactly 255 chars is valid."""
|
||||
max_key = "a" * 255
|
||||
assert validate_series_key(max_key) == max_key
|
||||
|
||||
|
||||
class TestValidateSeriesKeyOrFolder:
|
||||
"""Tests for validate_series_key_or_folder function."""
|
||||
|
||||
def test_valid_key_returns_key_true(self):
|
||||
"""Test valid key returns (key, True)."""
|
||||
result = validate_series_key_or_folder("attack-on-titan")
|
||||
assert result == ("attack-on-titan", True)
|
||||
|
||||
def test_valid_folder_returns_folder_false(self):
|
||||
"""Test valid folder returns (folder, False)."""
|
||||
result = validate_series_key_or_folder("Attack on Titan (2013)")
|
||||
assert result == ("Attack on Titan (2013)", False)
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
"""Test empty string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_series_key_or_folder("")
|
||||
|
||||
def test_none_raises(self):
|
||||
"""Test None raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_series_key_or_folder(None)
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
"""Test whitespace-only string raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_series_key_or_folder(" ")
|
||||
|
||||
def test_folder_not_allowed_raises(self):
|
||||
"""Test folder format raises when not allowed."""
|
||||
with pytest.raises(ValueError, match="Invalid series key format"):
|
||||
validate_series_key_or_folder(
|
||||
"Attack on Titan (2013)", allow_folder=False
|
||||
)
|
||||
|
||||
def test_key_allowed_when_folder_disabled(self):
|
||||
"""Test valid key works when folder is disabled."""
|
||||
result = validate_series_key_or_folder(
|
||||
"attack-on-titan", allow_folder=False
|
||||
)
|
||||
assert result == ("attack-on-titan", True)
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""Test that whitespace is stripped."""
|
||||
result = validate_series_key_or_folder(" attack-on-titan ")
|
||||
assert result == ("attack-on-titan", True)
|
||||
|
||||
def test_folder_too_long_raises(self):
|
||||
"""Test folder exceeding 1000 chars raises ValueError."""
|
||||
long_folder = "A" * 1001
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
validate_series_key_or_folder(long_folder)
|
||||
|
||||
|
||||
class TestValidateSeriesName:
|
||||
"""Tests for validate_series_name function."""
|
||||
|
||||
def test_valid_name(self):
|
||||
"""Test valid series name."""
|
||||
assert validate_series_name("Attack on Titan") == "Attack on Titan"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""Test whitespace is stripped."""
|
||||
assert validate_series_name(" Naruto ") == "Naruto"
|
||||
|
||||
def test_empty_raises(self):
|
||||
"""Test empty name raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_series_name("")
|
||||
|
||||
def test_too_long_raises(self):
|
||||
"""Test name over 200 chars raises ValueError."""
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
validate_series_name("A" * 201)
|
||||
|
||||
def test_invalid_chars_raises(self):
|
||||
"""Test invalid characters raise ValueError."""
|
||||
with pytest.raises(ValueError, match="invalid character"):
|
||||
validate_series_name("Attack: Titan")
|
||||
|
||||
|
||||
class TestValidateEpisodeRange:
|
||||
"""Tests for validate_episode_range function."""
|
||||
|
||||
def test_valid_range(self):
|
||||
"""Test valid episode range."""
|
||||
assert validate_episode_range(1, 10) == (1, 10)
|
||||
|
||||
def test_same_start_end(self):
|
||||
"""Test start equals end is valid."""
|
||||
assert validate_episode_range(5, 5) == (5, 5)
|
||||
|
||||
def test_start_less_than_one_raises(self):
|
||||
"""Test start less than 1 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="at least 1"):
|
||||
validate_episode_range(0, 10)
|
||||
|
||||
def test_end_less_than_start_raises(self):
|
||||
"""Test end less than start raises ValueError."""
|
||||
with pytest.raises(ValueError, match="greater than or equal"):
|
||||
validate_episode_range(10, 5)
|
||||
|
||||
def test_range_too_large_raises(self):
|
||||
"""Test range over 1000 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="too large"):
|
||||
validate_episode_range(1, 1002)
|
||||
|
||||
|
||||
class TestValidateDownloadQuality:
|
||||
"""Tests for validate_download_quality function."""
|
||||
|
||||
@pytest.mark.parametrize("quality", [
|
||||
"360p", "480p", "720p", "1080p", "best", "worst"
|
||||
])
|
||||
def test_valid_qualities(self, quality):
|
||||
"""Test all valid quality values."""
|
||||
assert validate_download_quality(quality) == quality
|
||||
|
||||
def test_invalid_quality_raises(self):
|
||||
"""Test invalid quality raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid quality"):
|
||||
validate_download_quality("4k")
|
||||
|
||||
|
||||
class TestValidateLanguage:
|
||||
"""Tests for validate_language function."""
|
||||
|
||||
@pytest.mark.parametrize("language", [
|
||||
"ger-sub", "ger-dub", "eng-sub", "eng-dub", "jpn"
|
||||
])
|
||||
def test_valid_languages(self, language):
|
||||
"""Test all valid language values."""
|
||||
assert validate_language(language) == language
|
||||
|
||||
def test_invalid_language_raises(self):
|
||||
"""Test invalid language raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid language"):
|
||||
validate_language("spanish")
|
||||
|
||||
|
||||
class TestValidateDownloadPriority:
|
||||
"""Tests for validate_download_priority function."""
|
||||
|
||||
def test_valid_priority_min(self):
|
||||
"""Test minimum priority is valid."""
|
||||
assert validate_download_priority(0) == 0
|
||||
|
||||
def test_valid_priority_max(self):
|
||||
"""Test maximum priority is valid."""
|
||||
assert validate_download_priority(10) == 10
|
||||
|
||||
def test_negative_priority_raises(self):
|
||||
"""Test negative priority raises ValueError."""
|
||||
with pytest.raises(ValueError, match="between 0 and 10"):
|
||||
validate_download_priority(-1)
|
||||
|
||||
def test_priority_too_high_raises(self):
|
||||
"""Test priority over 10 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="between 0 and 10"):
|
||||
validate_download_priority(11)
|
||||
|
||||
|
||||
class TestValidateAnimeUrl:
|
||||
"""Tests for validate_anime_url function."""
|
||||
|
||||
def test_valid_aniworld_url(self):
|
||||
"""Test valid aniworld.to URL."""
|
||||
url = "https://aniworld.to/anime/stream/attack-on-titan"
|
||||
assert validate_anime_url(url) == url
|
||||
|
||||
def test_valid_s_to_url(self):
|
||||
"""Test valid s.to URL."""
|
||||
url = "https://s.to/serie/stream/naruto"
|
||||
assert validate_anime_url(url) == url
|
||||
|
||||
def test_empty_url_raises(self):
|
||||
"""Test empty URL raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_anime_url("")
|
||||
|
||||
def test_invalid_domain_raises(self):
|
||||
"""Test invalid domain raises ValueError."""
|
||||
with pytest.raises(ValueError, match="aniworld.to or s.to"):
|
||||
validate_anime_url("https://example.com/anime")
|
||||
|
||||
|
||||
class TestValidateBackupName:
|
||||
"""Tests for validate_backup_name function."""
|
||||
|
||||
def test_valid_backup_name(self):
|
||||
"""Test valid backup name."""
|
||||
assert validate_backup_name("backup-2024.json") == "backup-2024.json"
|
||||
|
||||
def test_empty_raises(self):
|
||||
"""Test empty name raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_backup_name("")
|
||||
|
||||
def test_invalid_chars_raises(self):
|
||||
"""Test invalid characters raise ValueError."""
|
||||
with pytest.raises(ValueError, match="only contain"):
|
||||
validate_backup_name("backup name.json")
|
||||
|
||||
def test_no_json_extension_raises(self):
|
||||
"""Test missing .json raises ValueError."""
|
||||
with pytest.raises(ValueError, match="end with .json"):
|
||||
validate_backup_name("backup.txt")
|
||||
|
||||
|
||||
class TestValidateConfigData:
|
||||
"""Tests for validate_config_data function."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""Test valid config data."""
|
||||
data = {
|
||||
"download_directory": "/downloads",
|
||||
"concurrent_downloads": 3
|
||||
}
|
||||
assert validate_config_data(data) == data
|
||||
|
||||
def test_missing_keys_raises(self):
|
||||
"""Test missing required keys raises ValueError."""
|
||||
with pytest.raises(ValueError, match="missing required keys"):
|
||||
validate_config_data({"download_directory": "/downloads"})
|
||||
|
||||
def test_invalid_concurrent_downloads_raises(self):
|
||||
"""Test invalid concurrent_downloads raises ValueError."""
|
||||
with pytest.raises(ValueError, match="between 1 and 10"):
|
||||
validate_config_data({
|
||||
"download_directory": "/downloads",
|
||||
"concurrent_downloads": 15
|
||||
})
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
"""Tests for sanitize_filename function."""
|
||||
|
||||
def test_valid_filename(self):
|
||||
"""Test valid filename unchanged."""
|
||||
assert sanitize_filename("episode-01.mp4") == "episode-01.mp4"
|
||||
|
||||
def test_removes_invalid_chars(self):
|
||||
"""Test invalid characters are replaced."""
|
||||
result = sanitize_filename("file<>name.mp4")
|
||||
assert "<" not in result
|
||||
assert ">" not in result
|
||||
|
||||
def test_strips_dots_spaces(self):
|
||||
"""Test leading/trailing dots and spaces removed."""
|
||||
assert sanitize_filename(" .filename. ") == "filename"
|
||||
|
||||
def test_empty_becomes_unnamed(self):
|
||||
"""Test empty filename becomes 'unnamed'."""
|
||||
assert sanitize_filename("") == "unnamed"
|
||||
|
||||
|
||||
class TestValidateJwtToken:
|
||||
"""Tests for validate_jwt_token function."""
|
||||
|
||||
def test_valid_token_format(self):
|
||||
"""Test valid JWT token format."""
|
||||
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" # noqa: E501
|
||||
assert validate_jwt_token(token) == token
|
||||
|
||||
def test_empty_raises(self):
|
||||
"""Test empty token raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_jwt_token("")
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
"""Test invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid JWT"):
|
||||
validate_jwt_token("not-a-jwt-token")
|
||||
|
||||
|
||||
class TestValidateIpAddress:
|
||||
"""Tests for validate_ip_address function."""
|
||||
|
||||
def test_valid_ipv4(self):
|
||||
"""Test valid IPv4 address."""
|
||||
assert validate_ip_address("192.168.1.1") == "192.168.1.1"
|
||||
|
||||
def test_valid_ipv4_localhost(self):
|
||||
"""Test localhost IPv4."""
|
||||
assert validate_ip_address("127.0.0.1") == "127.0.0.1"
|
||||
|
||||
def test_empty_raises(self):
|
||||
"""Test empty IP raises ValueError."""
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
validate_ip_address("")
|
||||
|
||||
def test_invalid_ip_raises(self):
|
||||
"""Test invalid IP raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid IP"):
|
||||
validate_ip_address("not-an-ip")
|
||||
|
||||
|
||||
class TestValidateWebsocketMessage:
|
||||
"""Tests for validate_websocket_message function."""
|
||||
|
||||
def test_valid_message(self):
|
||||
"""Test valid WebSocket message."""
|
||||
msg = {"type": "download_progress", "data": {}}
|
||||
assert validate_websocket_message(msg) == msg
|
||||
|
||||
def test_missing_type_raises(self):
|
||||
"""Test missing type raises ValueError."""
|
||||
with pytest.raises(ValueError, match="missing required keys"):
|
||||
validate_websocket_message({"data": {}})
|
||||
|
||||
def test_invalid_type_raises(self):
|
||||
"""Test invalid type raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid message type"):
|
||||
validate_websocket_message({"type": "invalid_type"})
|
||||
|
||||
|
||||
class TestValidatorMixin:
|
||||
"""Tests for ValidatorMixin class methods."""
|
||||
|
||||
def test_validate_password_strength_valid(self):
|
||||
"""Test valid password passes."""
|
||||
password = "SecurePass123!"
|
||||
assert ValidatorMixin.validate_password_strength(password) == password
|
||||
|
||||
def test_validate_password_too_short_raises(self):
|
||||
"""Test short password raises ValueError."""
|
||||
with pytest.raises(ValueError, match="8 characters"):
|
||||
ValidatorMixin.validate_password_strength("Short1!")
|
||||
|
||||
def test_validate_password_no_uppercase_raises(self):
|
||||
"""Test no uppercase raises ValueError."""
|
||||
with pytest.raises(ValueError, match="uppercase"):
|
||||
ValidatorMixin.validate_password_strength("lowercase123!")
|
||||
|
||||
def test_validate_password_no_lowercase_raises(self):
|
||||
"""Test no lowercase raises ValueError."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
ValidatorMixin.validate_password_strength("UPPERCASE123!")
|
||||
|
||||
def test_validate_password_no_digit_raises(self):
|
||||
"""Test no digit raises ValueError."""
|
||||
with pytest.raises(ValueError, match="digit"):
|
||||
ValidatorMixin.validate_password_strength("NoDigitsHere!")
|
||||
|
||||
def test_validate_password_no_special_raises(self):
|
||||
"""Test no special char raises ValueError."""
|
||||
with pytest.raises(ValueError, match="special character"):
|
||||
ValidatorMixin.validate_password_strength("NoSpecial123")
|
||||
|
||||
def test_validate_url_valid(self):
|
||||
"""Test valid URL."""
|
||||
url = "https://example.com/path"
|
||||
assert ValidatorMixin.validate_url(url) == url
|
||||
|
||||
def test_validate_url_invalid_raises(self):
|
||||
"""Test invalid URL raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid URL"):
|
||||
ValidatorMixin.validate_url("not-a-url")
|
||||
|
||||
def test_validate_port_valid(self):
|
||||
"""Test valid port."""
|
||||
assert ValidatorMixin.validate_port(8080) == 8080
|
||||
|
||||
def test_validate_port_invalid_raises(self):
|
||||
"""Test invalid port raises ValueError."""
|
||||
with pytest.raises(ValueError, match="between 1 and 65535"):
|
||||
ValidatorMixin.validate_port(70000)
|
||||
|
||||
def test_validate_positive_integer_valid(self):
|
||||
"""Test valid positive integer."""
|
||||
assert ValidatorMixin.validate_positive_integer(5) == 5
|
||||
|
||||
def test_validate_positive_integer_zero_raises(self):
|
||||
"""Test zero raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be positive"):
|
||||
ValidatorMixin.validate_positive_integer(0)
|
||||
|
||||
def test_validate_non_negative_integer_valid(self):
|
||||
"""Test valid non-negative integer."""
|
||||
assert ValidatorMixin.validate_non_negative_integer(0) == 0
|
||||
|
||||
def test_validate_non_negative_integer_negative_raises(self):
|
||||
"""Test negative raises ValueError."""
|
||||
with pytest.raises(ValueError, match="cannot be negative"):
|
||||
ValidatorMixin.validate_non_negative_integer(-1)
|
||||
|
||||
def test_validate_string_length_valid(self):
|
||||
"""Test valid string length."""
|
||||
result = ValidatorMixin.validate_string_length("test", 1, 10)
|
||||
assert result == "test"
|
||||
|
||||
def test_validate_string_length_too_short_raises(self):
|
||||
"""Test too short raises ValueError."""
|
||||
with pytest.raises(ValueError, match="at least"):
|
||||
ValidatorMixin.validate_string_length("ab", min_length=5)
|
||||
|
||||
def test_validate_string_length_too_long_raises(self):
|
||||
"""Test too long raises ValueError."""
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
ValidatorMixin.validate_string_length("abcdefgh", max_length=5)
|
||||
|
||||
def test_validate_choice_valid(self):
|
||||
"""Test valid choice."""
|
||||
result = ValidatorMixin.validate_choice("a", ["a", "b", "c"])
|
||||
assert result == "a"
|
||||
|
||||
def test_validate_choice_invalid_raises(self):
|
||||
"""Test invalid choice raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
ValidatorMixin.validate_choice("d", ["a", "b", "c"])
|
||||
|
||||
def test_validate_dict_keys_valid(self):
|
||||
"""Test valid dict with required keys."""
|
||||
data = {"a": 1, "b": 2}
|
||||
result = ValidatorMixin.validate_dict_keys(data, ["a", "b"])
|
||||
assert result == data
|
||||
|
||||
def test_validate_dict_keys_missing_raises(self):
|
||||
"""Test missing keys raises ValueError."""
|
||||
with pytest.raises(ValueError, match="missing required keys"):
|
||||
ValidatorMixin.validate_dict_keys({"a": 1}, ["a", "b"])
|
||||
@ -307,10 +307,16 @@ class TestWebSocketService:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_download_progress(self, service, mock_websocket):
|
||||
"""Test broadcasting download progress."""
|
||||
"""Test broadcasting download progress.
|
||||
|
||||
Verifies that progress data includes 'key' as the primary series
|
||||
identifier and 'folder' for display purposes only.
|
||||
"""
|
||||
connection_id = "test-conn"
|
||||
download_id = "download123"
|
||||
progress_data = {
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"percent": 50.0,
|
||||
"speed_mbps": 2.5,
|
||||
"eta_seconds": 120,
|
||||
@ -325,14 +331,24 @@ class TestWebSocketService:
|
||||
call_args = mock_websocket.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "download_progress"
|
||||
assert call_args["data"]["download_id"] == download_id
|
||||
assert call_args["data"]["key"] == "attack-on-titan"
|
||||
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
|
||||
assert call_args["data"]["percent"] == 50.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_download_complete(self, service, mock_websocket):
|
||||
"""Test broadcasting download completion."""
|
||||
"""Test broadcasting download completion.
|
||||
|
||||
Verifies that result data includes 'key' as the primary series
|
||||
identifier and 'folder' for display purposes only.
|
||||
"""
|
||||
connection_id = "test-conn"
|
||||
download_id = "download123"
|
||||
result_data = {"file_path": "/path/to/file.mp4"}
|
||||
result_data = {
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"file_path": "/path/to/file.mp4"
|
||||
}
|
||||
|
||||
await service.connect(mock_websocket, connection_id)
|
||||
await service._manager.join_room(connection_id, "downloads")
|
||||
@ -342,13 +358,23 @@ class TestWebSocketService:
|
||||
call_args = mock_websocket.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "download_complete"
|
||||
assert call_args["data"]["download_id"] == download_id
|
||||
assert call_args["data"]["key"] == "attack-on-titan"
|
||||
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_download_failed(self, service, mock_websocket):
|
||||
"""Test broadcasting download failure."""
|
||||
"""Test broadcasting download failure.
|
||||
|
||||
Verifies that error data includes 'key' as the primary series
|
||||
identifier and 'folder' for display purposes only.
|
||||
"""
|
||||
connection_id = "test-conn"
|
||||
download_id = "download123"
|
||||
error_data = {"error_message": "Network error"}
|
||||
error_data = {
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"error_message": "Network error"
|
||||
}
|
||||
|
||||
await service.connect(mock_websocket, connection_id)
|
||||
await service._manager.join_room(connection_id, "downloads")
|
||||
@ -358,6 +384,8 @@ class TestWebSocketService:
|
||||
call_args = mock_websocket.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "download_failed"
|
||||
assert call_args["data"]["download_id"] == download_id
|
||||
assert call_args["data"]["key"] == "attack-on-titan"
|
||||
assert call_args["data"]["folder"] == "Attack on Titan (2013)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_queue_status(self, service, mock_websocket):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user