Compare commits
No commits in common. "8f7c489bd2d4e5ab350866e3fc7451e58ed7d204" and "9096afbaceaf4f88ac518fb1770e00175d6b1cea" have entirely different histories.
8f7c489bd2
...
9096afbace
@ -1,338 +0,0 @@
|
|||||||
# Frontend Integration Changes
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document details the changes made to integrate the existing frontend JavaScript with the new FastAPI backend and native WebSocket implementation.
|
|
||||||
|
|
||||||
## Key Changes
|
|
||||||
|
|
||||||
### 1. WebSocket Migration (Socket.IO → Native WebSocket)
|
|
||||||
|
|
||||||
**Files Created:**
|
|
||||||
|
|
||||||
- `src/server/web/static/js/websocket_client.js` - Native WebSocket wrapper with Socket.IO-compatible interface
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
|
|
||||||
- `src/server/web/templates/index.html` - Replace Socket.IO CDN with websocket_client.js
|
|
||||||
- `src/server/web/templates/queue.html` - Replace Socket.IO CDN with websocket_client.js
|
|
||||||
|
|
||||||
**Migration Details:**
|
|
||||||
|
|
||||||
- Created `WebSocketClient` class that provides Socket.IO-style `.on()` and `.emit()` methods
|
|
||||||
- Automatic reconnection with exponential backoff
|
|
||||||
- Room-based subscriptions (join/leave rooms for topic filtering)
|
|
||||||
- Message queueing during disconnection
|
|
||||||
- Native WebSocket URL: `ws://host:port/ws/connect` (or `wss://` for HTTPS)
|
|
||||||
|
|
||||||
### 2. WebSocket Message Format Changes
|
|
||||||
|
|
||||||
**Old Format (Socket.IO custom events):**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
socket.on('download_progress', (data) => { ... });
|
|
||||||
// data was sent directly
|
|
||||||
```
|
|
||||||
|
|
||||||
**New Format (Structured messages):**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
"type": "download_progress",
|
|
||||||
"timestamp": "2025-10-17T12:34:56.789Z",
|
|
||||||
"data": {
|
|
||||||
// Message payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Mapping:**
|
|
||||||
|
|
||||||
| Old Socket.IO Event | New WebSocket Type | Room | Notes |
|
|
||||||
| ----------------------- | ------------------- | ------------------- | -------------------------- |
|
|
||||||
| `scan_progress` | `scan_progress` | `scan_progress` | Scan updates |
|
|
||||||
| `scan_completed` | `scan_complete` | `scan_progress` | Scan finished |
|
|
||||||
| `scan_error` | `scan_failed` | `scan_progress` | Scan error |
|
|
||||||
| `download_progress` | `download_progress` | `download_progress` | Real-time download updates |
|
|
||||||
| `download_completed` | `download_complete` | `downloads` | Single download finished |
|
|
||||||
| `download_error` | `download_failed` | `downloads` | Download failed |
|
|
||||||
| `download_queue_update` | `queue_status` | `downloads` | Queue state changes |
|
|
||||||
| `queue_started` | `queue_started` | `downloads` | Queue processing started |
|
|
||||||
| `queue_stopped` | `queue_stopped` | `downloads` | Queue processing stopped |
|
|
||||||
| `queue_paused` | `queue_paused` | `downloads` | Queue paused |
|
|
||||||
| `queue_resumed` | `queue_resumed` | `downloads` | Queue resumed |
|
|
||||||
|
|
||||||
### 3. API Endpoint Changes
|
|
||||||
|
|
||||||
**Authentication Endpoints:**
|
|
||||||
|
|
||||||
- ✅ `/api/auth/status` - Check auth status (GET)
|
|
||||||
- ✅ `/api/auth/login` - Login (POST)
|
|
||||||
- ✅ `/api/auth/logout` - Logout (POST)
|
|
||||||
- ✅ `/api/auth/setup` - Initial setup (POST)
|
|
||||||
|
|
||||||
**Anime Endpoints:**
|
|
||||||
|
|
||||||
- ✅ `/api/v1/anime` - List anime with missing episodes (GET)
|
|
||||||
- ✅ `/api/v1/anime/rescan` - Trigger rescan (POST)
|
|
||||||
- ✅ `/api/v1/anime/search` - Search for anime (POST)
|
|
||||||
- ✅ `/api/v1/anime/{anime_id}` - Get anime details (GET)
|
|
||||||
|
|
||||||
**Download Queue Endpoints:**
|
|
||||||
|
|
||||||
- ✅ `/api/queue/status` - Get queue status (GET)
|
|
||||||
- ✅ `/api/queue/add` - Add to queue (POST)
|
|
||||||
- ✅ `/api/queue/{item_id}` - Remove single item (DELETE)
|
|
||||||
- ✅ `/api/queue/` - Remove multiple items (DELETE)
|
|
||||||
- ✅ `/api/queue/start` - Start queue (POST)
|
|
||||||
- ✅ `/api/queue/stop` - Stop queue (POST)
|
|
||||||
- ✅ `/api/queue/pause` - Pause queue (POST)
|
|
||||||
- ✅ `/api/queue/resume` - Resume queue (POST)
|
|
||||||
- ✅ `/api/queue/reorder` - Reorder queue (POST)
|
|
||||||
- ✅ `/api/queue/completed` - Clear completed (DELETE)
|
|
||||||
- ✅ `/api/queue/retry` - Retry failed (POST)
|
|
||||||
|
|
||||||
**WebSocket Endpoint:**
|
|
||||||
|
|
||||||
- ✅ `/ws/connect` - WebSocket connection (WebSocket)
|
|
||||||
- ✅ `/ws/status` - WebSocket status (GET)
|
|
||||||
|
|
||||||
### 4. Required JavaScript Updates
|
|
||||||
|
|
||||||
**app.js Changes Needed:**
|
|
||||||
|
|
||||||
1. **WebSocket Initialization** - Add room subscriptions:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
initSocket() {
|
|
||||||
this.socket = io();
|
|
||||||
|
|
||||||
// Subscribe to relevant rooms after connection
|
|
||||||
this.socket.on('connected', () => {
|
|
||||||
this.socket.join('scan_progress');
|
|
||||||
this.socket.join('download_progress');
|
|
||||||
this.socket.join('downloads');
|
|
||||||
this.isConnected = true;
|
|
||||||
// ... rest of connect handler
|
|
||||||
});
|
|
||||||
|
|
||||||
// ... rest of event handlers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Event Handler Updates** - Map new message types:
|
|
||||||
|
|
||||||
- `scan_completed` → `scan_complete`
|
|
||||||
- `scan_error` → `scan_failed`
|
|
||||||
- Legacy events that are no longer sent need to be handled differently or removed
|
|
||||||
|
|
||||||
3. **API Call Updates** - Already correct:
|
|
||||||
|
|
||||||
- `/api/v1/anime` for anime list ✅
|
|
||||||
- `/api/auth/*` for authentication ✅
|
|
||||||
|
|
||||||
**queue.js Changes Needed:**
|
|
||||||
|
|
||||||
1. **WebSocket Initialization** - Add room subscriptions:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
initSocket() {
|
|
||||||
this.socket = io();
|
|
||||||
|
|
||||||
this.socket.on('connected', () => {
|
|
||||||
this.socket.join('downloads');
|
|
||||||
this.socket.join('download_progress');
|
|
||||||
// ... rest of connect handler
|
|
||||||
});
|
|
||||||
|
|
||||||
// ... rest of event handlers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **API Calls** - Already mostly correct:
|
|
||||||
|
|
||||||
- `/api/queue/status` ✅
|
|
||||||
- `/api/queue/*` operations ✅
|
|
||||||
|
|
||||||
3. **Event Handlers** - Map to new types:
|
|
||||||
|
|
||||||
- `queue_updated` → `queue_status`
|
|
||||||
- `download_progress_update` → `download_progress`
|
|
||||||
|
|
||||||
### 5. Authentication Flow
|
|
||||||
|
|
||||||
**Current Implementation:**
|
|
||||||
|
|
||||||
- JWT tokens stored in localStorage (via auth service)
|
|
||||||
- Tokens included in Authorization header for API requests
|
|
||||||
- WebSocket connections can optionally authenticate (user_id in session)
|
|
||||||
|
|
||||||
**JavaScript Implementation Needed:**
|
|
||||||
Add helper for authenticated requests:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async makeAuthenticatedRequest(url, options = {}) {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
...options.headers
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(url, { ...options, headers });
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
// Token expired or invalid
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
window.location.href = '/login';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Backend Router Registration
|
|
||||||
|
|
||||||
**Fixed in fastapi_app.py:**
|
|
||||||
|
|
||||||
- ✅ Added `anime_router` import
|
|
||||||
- ✅ Registered `app.include_router(anime_router)`
|
|
||||||
|
|
||||||
All routers now properly registered:
|
|
||||||
|
|
||||||
- health_router
|
|
||||||
- page_router
|
|
||||||
- auth_router
|
|
||||||
- anime_router ⭐ (newly added)
|
|
||||||
- download_router
|
|
||||||
- websocket_router
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### ✅ Completed
|
|
||||||
|
|
||||||
1. Created native WebSocket client wrapper
|
|
||||||
2. Updated HTML templates to use new WebSocket client
|
|
||||||
3. Registered anime router in FastAPI app
|
|
||||||
4. Documented API endpoint mappings
|
|
||||||
5. Documented WebSocket message format changes
|
|
||||||
|
|
||||||
### 🔄 In Progress
|
|
||||||
|
|
||||||
1. Update app.js WebSocket initialization and room subscriptions
|
|
||||||
2. Update app.js event handlers for new message types
|
|
||||||
3. Update queue.js WebSocket initialization and room subscriptions
|
|
||||||
4. Update queue.js event handlers for new message types
|
|
||||||
|
|
||||||
### ⏳ Pending
|
|
||||||
|
|
||||||
1. Add authentication token handling to all API requests
|
|
||||||
2. Test complete workflow (auth → scan → download)
|
|
||||||
3. Update other JavaScript modules if they use WebSocket/API
|
|
||||||
4. Integration tests for frontend-backend communication
|
|
||||||
5. Update infrastructure.md documentation
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
1. **Authentication Flow:**
|
|
||||||
|
|
||||||
- Test setup page → creates master password
|
|
||||||
- Test login page → authenticates with master password
|
|
||||||
- Test logout → clears session
|
|
||||||
- Test protected pages redirect to login
|
|
||||||
|
|
||||||
2. **Anime Management:**
|
|
||||||
|
|
||||||
- Test loading anime list
|
|
||||||
- Test rescan functionality with progress updates
|
|
||||||
- Test search functionality
|
|
||||||
|
|
||||||
3. **Download Queue:**
|
|
||||||
|
|
||||||
- Test adding items to queue
|
|
||||||
- Test queue operations (start, stop, pause, resume)
|
|
||||||
- Test progress updates via WebSocket
|
|
||||||
- Test retry and clear operations
|
|
||||||
|
|
||||||
4. **WebSocket Communication:**
|
|
||||||
- Test connection/reconnection
|
|
||||||
- Test room subscriptions
|
|
||||||
- Test message routing to correct handlers
|
|
||||||
- Test disconnect handling
|
|
||||||
|
|
||||||
## Known Issues & Limitations
|
|
||||||
|
|
||||||
1. **Legacy Events:** Some Socket.IO events in app.js don't have backend equivalents:
|
|
||||||
|
|
||||||
- `scheduled_rescan_*` events
|
|
||||||
- `auto_download_*` events
|
|
||||||
- `download_episode_update` event
|
|
||||||
- `download_series_completed` event
|
|
||||||
|
|
||||||
**Solution:** Either remove these handlers or implement corresponding backend events
|
|
||||||
|
|
||||||
2. **Configuration Endpoints:** Many config-related API calls in app.js don't have backend implementations:
|
|
||||||
|
|
||||||
- Scheduler configuration
|
|
||||||
- Logging configuration
|
|
||||||
- Advanced configuration
|
|
||||||
- Config backups
|
|
||||||
|
|
||||||
**Solution:** Implement these endpoints or remove the UI features
|
|
||||||
|
|
||||||
3. **Process Status Monitoring:** `checkProcessLocks()` method may not work with new backend
|
|
||||||
|
|
||||||
**Solution:** Implement equivalent status endpoint or remove feature
|
|
||||||
|
|
||||||
## Migration Guide for Developers
|
|
||||||
|
|
||||||
### Adding New WebSocket Events
|
|
||||||
|
|
||||||
1. Define message type in `src/server/models/websocket.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebSocketMessageType(str, Enum):
|
|
||||||
MY_NEW_EVENT = "my_new_event"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Broadcast from service:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await ws_service.broadcast_to_room(
|
|
||||||
{"type": "my_new_event", "data": {...}},
|
|
||||||
"my_room"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Subscribe and handle in JavaScript:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
this.socket.join("my_room");
|
|
||||||
this.socket.on("my_new_event", (data) => {
|
|
||||||
// Handle event
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding New API Endpoints
|
|
||||||
|
|
||||||
1. Define Pydantic models in `src/server/models/`
|
|
||||||
2. Create endpoint in appropriate router file in `src/server/api/`
|
|
||||||
3. Add endpoint to this documentation
|
|
||||||
4. Update JavaScript to call new endpoint
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- FastAPI Application: `src/server/fastapi_app.py`
|
|
||||||
- WebSocket Service: `src/server/services/websocket_service.py`
|
|
||||||
- WebSocket Models: `src/server/models/websocket.py`
|
|
||||||
- Download Service: `src/server/services/download_service.py`
|
|
||||||
- Anime Service: `src/server/services/anime_service.py`
|
|
||||||
- Progress Service: `src/server/services/progress_service.py`
|
|
||||||
- Infrastructure Doc: `infrastructure.md`
|
|
||||||
File diff suppressed because it is too large
Load Diff
123
instructions.md
123
instructions.md
@ -38,13 +38,127 @@ The tasks should be completed in the following order to ensure proper dependenci
|
|||||||
2. Process the task
|
2. Process the task
|
||||||
3. Make Tests.
|
3. Make Tests.
|
||||||
4. Remove task from instructions.md.
|
4. Remove task from instructions.md.
|
||||||
5. Update infrastructure.md, but only add text that belongs to a infrastructure doc. make sure to summarize text or delete text that do not belog to infrastructure.md. Keep it clear and short.
|
5. Update infrastructure.md, but only add text that belongs to a infrastructure doc.
|
||||||
6. Commit in git
|
6. Commit in git
|
||||||
|
|
||||||
## Core Tasks
|
## Core Tasks
|
||||||
|
|
||||||
|
### 3. Configuration Management
|
||||||
|
|
||||||
|
#### [] Implement configuration models
|
||||||
|
|
||||||
|
- []Create `src/server/models/config.py`
|
||||||
|
- []Define ConfigResponse, ConfigUpdate models
|
||||||
|
- []Add SchedulerConfig, LoggingConfig models
|
||||||
|
- []Include ValidationResult model
|
||||||
|
|
||||||
|
#### [] Create configuration service
|
||||||
|
|
||||||
|
- []Create `src/server/services/config_service.py`
|
||||||
|
- []Implement configuration loading/saving
|
||||||
|
- []Add configuration validation
|
||||||
|
- []Include backup/restore functionality
|
||||||
|
- []Add scheduler configuration management
|
||||||
|
|
||||||
|
#### [] Implement configuration API endpoints
|
||||||
|
|
||||||
|
- []Create `src/server/api/config.py`
|
||||||
|
- []Add GET `/api/config` - get configuration
|
||||||
|
- []Add PUT `/api/config` - update configuration
|
||||||
|
- []Add POST `/api/config/validate` - validate config
|
||||||
|
|
||||||
|
### 4. Anime Management Integration
|
||||||
|
|
||||||
|
#### [] Implement anime models
|
||||||
|
|
||||||
|
- []Create `src/server/models/anime.py`
|
||||||
|
- []Define AnimeSeriesResponse, EpisodeInfo models
|
||||||
|
- []Add SearchRequest, SearchResult models
|
||||||
|
- []Include MissingEpisodeInfo model
|
||||||
|
|
||||||
|
#### [] Create anime service wrapper
|
||||||
|
|
||||||
|
- []Create `src/server/services/anime_service.py`
|
||||||
|
- []Wrap SeriesApp functionality for web layer
|
||||||
|
- []Implement async wrappers for blocking operations
|
||||||
|
- []Add caching for frequently accessed data
|
||||||
|
- []Include error handling and logging
|
||||||
|
|
||||||
|
#### [] Implement anime API endpoints
|
||||||
|
|
||||||
|
- []Create `src/server/api/anime.py`
|
||||||
|
- []Add GET `/api/v1/anime` - list series with missing episodes
|
||||||
|
- []Add POST `/api/v1/anime/rescan` - trigger rescan
|
||||||
|
- []Add POST `/api/v1/anime/search` - search for new anime
|
||||||
|
- []Add GET `/api/v1/anime/{id}` - get series details
|
||||||
|
|
||||||
|
### 5. Download Queue Management
|
||||||
|
|
||||||
|
#### [] Implement download queue models
|
||||||
|
|
||||||
|
- []Create `src/server/models/download.py`
|
||||||
|
- []Define DownloadItem, QueueStatus models
|
||||||
|
- []Add DownloadProgress, QueueStats models
|
||||||
|
- []Include DownloadRequest model
|
||||||
|
|
||||||
|
#### [] Create download queue service
|
||||||
|
|
||||||
|
- []Create `src/server/services/download_service.py`
|
||||||
|
- []Implement queue management (add, remove, reorder)
|
||||||
|
- []Add download progress tracking
|
||||||
|
- []Include queue persistence and recovery
|
||||||
|
- []Add concurrent download management
|
||||||
|
|
||||||
|
#### [] Implement download API endpoints
|
||||||
|
|
||||||
|
- []Create `src/server/api/download.py`
|
||||||
|
- []Add GET `/api/queue/status` - get queue status
|
||||||
|
- []Add POST `/api/queue/add` - add to queue
|
||||||
|
- []Add DELETE `/api/queue/{id}` - remove from queue
|
||||||
|
- []Add POST `/api/queue/start` - start downloads
|
||||||
|
- []Add POST `/api/queue/stop` - stop downloads
|
||||||
|
|
||||||
|
### 6. WebSocket Real-time Updates
|
||||||
|
|
||||||
|
#### [] Implement WebSocket manager
|
||||||
|
|
||||||
|
- []Create `src/server/services/websocket_service.py`
|
||||||
|
- []Add connection management
|
||||||
|
- []Implement broadcast functionality
|
||||||
|
- []Include room-based messaging
|
||||||
|
- []Add connection cleanup
|
||||||
|
|
||||||
|
#### [] Add real-time progress updates
|
||||||
|
|
||||||
|
- []Create `src/server/services/progress_service.py`
|
||||||
|
- []Implement download progress broadcasting
|
||||||
|
- []Add scan progress updates
|
||||||
|
- []Include queue status changes
|
||||||
|
- []Add error notifications
|
||||||
|
|
||||||
|
#### [] Integrate WebSocket with core services
|
||||||
|
|
||||||
|
- []Update download service to emit progress
|
||||||
|
- []Add scan progress notifications
|
||||||
|
- []Include queue change broadcasts
|
||||||
|
- []Add error/completion notifications
|
||||||
|
|
||||||
### 7. Frontend Integration
|
### 7. Frontend Integration
|
||||||
|
|
||||||
|
#### [] Integrate existing HTML templates
|
||||||
|
|
||||||
|
- []Review and integrate existing HTML templates in `src/server/web/templates/`
|
||||||
|
- []Ensure templates work with FastAPI Jinja2 setup
|
||||||
|
- []Update template paths and static file references if needed
|
||||||
|
- []Maintain existing responsive layout and theme switching
|
||||||
|
|
||||||
|
#### [] Integrate existing JavaScript functionality
|
||||||
|
|
||||||
|
- []Review existing JavaScript files in `src/server/web/static/js/`
|
||||||
|
- []Update API endpoint URLs to match FastAPI routes
|
||||||
|
- []Ensure WebSocket connections work with new backend
|
||||||
|
- []Maintain existing functionality for app.js and queue.js
|
||||||
|
|
||||||
#### [] Integrate existing CSS styling
|
#### [] Integrate existing CSS styling
|
||||||
|
|
||||||
- []Review and integrate existing CSS files in `src/server/web/static/css/`
|
- []Review and integrate existing CSS files in `src/server/web/static/css/`
|
||||||
@ -137,6 +251,13 @@ The tasks should be completed in the following order to ensure proper dependenci
|
|||||||
|
|
||||||
### 11. Deployment and Configuration
|
### 11. Deployment and Configuration
|
||||||
|
|
||||||
|
#### [] Create Docker configuration
|
||||||
|
|
||||||
|
- []Create `Dockerfile`
|
||||||
|
- []Create `docker-compose.yml`
|
||||||
|
- []Add environment configuration
|
||||||
|
- []Include volume mappings for existing web assets
|
||||||
|
|
||||||
#### [] Create production configuration
|
#### [] Create production configuration
|
||||||
|
|
||||||
- []Create `src/server/config/production.py`
|
- []Create `src/server/config/production.py`
|
||||||
|
|||||||
@ -8,7 +8,6 @@ python-jose[cryptography]==3.3.0
|
|||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
structlog==24.1.0
|
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
@ -1,117 +0,0 @@
|
|||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from src.server.utils.dependencies import get_series_app
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/anime", tags=["anime"])
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSummary(BaseModel):
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
missing_episodes: int
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeDetail(BaseModel):
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
episodes: List[str]
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[AnimeSummary])
|
|
||||||
async def list_anime(series_app=Depends(get_series_app)):
|
|
||||||
"""List series with missing episodes using the core SeriesApp."""
|
|
||||||
try:
|
|
||||||
series = series_app.List.GetMissingEpisode()
|
|
||||||
result = []
|
|
||||||
for s in series:
|
|
||||||
missing = 0
|
|
||||||
try:
|
|
||||||
missing = len(s.episodeDict) if getattr(s, "episodeDict", None) is not None else 0
|
|
||||||
except Exception:
|
|
||||||
missing = 0
|
|
||||||
result.append(AnimeSummary(id=getattr(s, "key", getattr(s, "folder", "")), title=getattr(s, "name", ""), missing_episodes=missing))
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve anime list")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rescan")
|
|
||||||
async def trigger_rescan(series_app=Depends(get_series_app)):
|
|
||||||
"""Trigger a rescan of local series data using SeriesApp.ReScan."""
|
|
||||||
try:
|
|
||||||
# SeriesApp.ReScan expects a callback; pass a no-op
|
|
||||||
if hasattr(series_app, "ReScan"):
|
|
||||||
series_app.ReScan(lambda *args, **kwargs: None)
|
|
||||||
return {"success": True, "message": "Rescan started"}
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Rescan not available")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start rescan")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
|
||||||
query: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=List[AnimeSummary])
|
|
||||||
async def search_anime(request: SearchRequest, series_app=Depends(get_series_app)):
|
|
||||||
"""Search for new anime by query text using the SeriesApp loader."""
|
|
||||||
try:
|
|
||||||
matches = []
|
|
||||||
if hasattr(series_app, "search"):
|
|
||||||
# SeriesApp.search is synchronous in core; call directly
|
|
||||||
matches = series_app.search(request.query)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for m in matches:
|
|
||||||
# matches may be dicts or objects
|
|
||||||
if isinstance(m, dict):
|
|
||||||
mid = m.get("key") or m.get("id") or ""
|
|
||||||
title = m.get("title") or m.get("name") or ""
|
|
||||||
missing = int(m.get("missing", 0)) if m.get("missing") is not None else 0
|
|
||||||
else:
|
|
||||||
mid = getattr(m, "key", getattr(m, "id", ""))
|
|
||||||
title = getattr(m, "title", getattr(m, "name", ""))
|
|
||||||
missing = int(getattr(m, "missing", 0))
|
|
||||||
result.append(AnimeSummary(id=mid, title=title, missing_episodes=missing))
|
|
||||||
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Search failed")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{anime_id}", response_model=AnimeDetail)
|
|
||||||
async def get_anime(anime_id: str, series_app=Depends(get_series_app)):
|
|
||||||
"""Return detailed info about a series from SeriesApp.List."""
|
|
||||||
try:
|
|
||||||
series = series_app.List.GetList()
|
|
||||||
found = None
|
|
||||||
for s in series:
|
|
||||||
if getattr(s, "key", None) == anime_id or getattr(s, "folder", None) == anime_id:
|
|
||||||
found = s
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Series not found")
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
epdict = getattr(found, "episodeDict", {}) or {}
|
|
||||||
for season, eps in epdict.items():
|
|
||||||
for e in eps:
|
|
||||||
episodes.append(f"{season}-{e}")
|
|
||||||
|
|
||||||
return AnimeDetail(id=getattr(found, "key", getattr(found, "folder", "")), title=getattr(found, "name", ""), episodes=episodes, description=getattr(found, "description", None))
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve series details")
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
|
||||||
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
|
|
||||||
from src.server.utils.dependencies import require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppConfig)
|
|
||||||
def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
|
|
||||||
"""Return current application configuration (read-only)."""
|
|
||||||
# Construct AppConfig from pydantic-settings where possible
|
|
||||||
cfg_data = {
|
|
||||||
"name": getattr(settings, "app_name", "Aniworld"),
|
|
||||||
"data_dir": getattr(settings, "data_dir", "data"),
|
|
||||||
"scheduler": getattr(settings, "scheduler", {}),
|
|
||||||
"logging": getattr(settings, "logging", {}),
|
|
||||||
"backup": getattr(settings, "backup", {}),
|
|
||||||
"other": getattr(settings, "other", {}),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
return AppConfig(**cfg_data)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to read config: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=AppConfig)
|
|
||||||
def update_config(update: ConfigUpdate, auth: dict = Depends(require_auth)) -> AppConfig:
|
|
||||||
"""Apply an update to the configuration and return the new config.
|
|
||||||
|
|
||||||
Note: persistence strategy for settings is out-of-scope for this task.
|
|
||||||
This endpoint updates the in-memory Settings where possible and returns
|
|
||||||
the merged result as an AppConfig.
|
|
||||||
"""
|
|
||||||
# Build current AppConfig from settings then apply update
|
|
||||||
current = get_config(auth)
|
|
||||||
new_cfg = update.apply_to(current)
|
|
||||||
|
|
||||||
# Mirror some fields back into pydantic-settings 'settings' where safe.
|
|
||||||
# Avoid writing secrets or unsupported fields.
|
|
||||||
try:
|
|
||||||
if new_cfg.data_dir:
|
|
||||||
setattr(settings, "data_dir", new_cfg.data_dir)
|
|
||||||
# scheduler/logging/backup/other kept in memory only for now
|
|
||||||
setattr(settings, "scheduler", new_cfg.scheduler.model_dump())
|
|
||||||
setattr(settings, "logging", new_cfg.logging.model_dump())
|
|
||||||
setattr(settings, "backup", new_cfg.backup.model_dump())
|
|
||||||
setattr(settings, "other", new_cfg.other)
|
|
||||||
except Exception:
|
|
||||||
# Best-effort; do not fail the request if persistence is not available
|
|
||||||
pass
|
|
||||||
|
|
||||||
return new_cfg
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate", response_model=ValidationResult)
|
|
||||||
def validate_config(cfg: AppConfig, auth: dict = Depends(require_auth)) -> ValidationResult:
|
|
||||||
"""Validate a provided AppConfig without applying it.
|
|
||||||
|
|
||||||
Returns ValidationResult with any validation errors.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return cfg.validate()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
@ -1,474 +0,0 @@
|
|||||||
"""Download queue API endpoints for Aniworld web application.
|
|
||||||
|
|
||||||
This module provides REST API endpoints for managing the anime download queue,
|
|
||||||
including adding episodes, removing items, controlling queue processing, and
|
|
||||||
retrieving queue status and statistics.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
|
||||||
|
|
||||||
from src.server.models.download import (
|
|
||||||
DownloadRequest,
|
|
||||||
DownloadResponse,
|
|
||||||
QueueOperationRequest,
|
|
||||||
QueueReorderRequest,
|
|
||||||
QueueStatusResponse,
|
|
||||||
)
|
|
||||||
from src.server.services.download_service import DownloadService, DownloadServiceError
|
|
||||||
from src.server.utils.dependencies import get_download_service, require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/queue", tags=["download"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=QueueStatusResponse)
|
|
||||||
async def get_queue_status(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Get current download queue status and statistics.
|
|
||||||
|
|
||||||
Returns comprehensive information about all queue items including:
|
|
||||||
- Active downloads with progress
|
|
||||||
- Pending items waiting to be processed
|
|
||||||
- Recently completed downloads
|
|
||||||
- Failed downloads
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QueueStatusResponse: Complete queue status and statistics
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
queue_status = await download_service.get_queue_status()
|
|
||||||
queue_stats = await download_service.get_queue_stats()
|
|
||||||
|
|
||||||
return QueueStatusResponse(status=queue_status, statistics=queue_stats)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retrieve queue status: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/add",
|
|
||||||
response_model=DownloadResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_to_queue(
|
|
||||||
request: DownloadRequest,
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Add episodes to the download queue.
|
|
||||||
|
|
||||||
Adds one or more episodes to the download queue with specified priority.
|
|
||||||
Episodes are validated and queued for processing based on priority level:
|
|
||||||
- HIGH priority items are processed first
|
|
||||||
- NORMAL and LOW priority items follow FIFO order
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Download request with serie info, episodes, and priority
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DownloadResponse: Status and list of created download item IDs
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 400 for invalid request,
|
|
||||||
500 on service error
|
|
||||||
"""
|
|
||||||
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 DownloadResponse(
|
|
||||||
status="success",
|
|
||||||
message=f"Added {len(added_ids)} episode(s) to download queue",
|
|
||||||
added_items=added_ids,
|
|
||||||
failed_items=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to add episodes to queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_from_queue(
|
|
||||||
item_id: str = Path(..., description="Download item ID to remove"),
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Remove a specific item from the download queue.
|
|
||||||
|
|
||||||
Removes a download item from the queue. If the item is currently
|
|
||||||
downloading, it will be cancelled and marked as cancelled. If it's
|
|
||||||
pending, it will simply be removed from the queue.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item_id: Unique identifier of the download item to remove
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
|
||||||
500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
removed_ids = await download_service.remove_from_queue([item_id])
|
|
||||||
|
|
||||||
if not removed_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Download item {item_id} not found in queue",
|
|
||||||
)
|
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to remove item from queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_multiple_from_queue(
|
|
||||||
request: QueueOperationRequest,
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Remove multiple items from the download queue.
|
|
||||||
|
|
||||||
Batch removal of multiple download items. Each item is processed
|
|
||||||
individually, and the operation continues even if some items are not
|
|
||||||
found.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: List of download item IDs to remove
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 400 for invalid request,
|
|
||||||
500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not request.item_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="At least one item ID must be specified",
|
|
||||||
)
|
|
||||||
|
|
||||||
await download_service.remove_from_queue(request.item_ids)
|
|
||||||
|
|
||||||
# Note: We don't raise 404 if some items weren't found, as this is
|
|
||||||
# a batch operation and partial success is acceptable
|
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to remove items from queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", status_code=status.HTTP_200_OK)
|
|
||||||
async def start_queue(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Start the download queue processor.
|
|
||||||
|
|
||||||
Starts processing the download queue. Downloads will be processed according
|
|
||||||
to priority and concurrency limits. If the queue is already running, this
|
|
||||||
operation is idempotent.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been started
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.start()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue processing started",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to start download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop", status_code=status.HTTP_200_OK)
|
|
||||||
async def stop_queue(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Stop the download queue processor.
|
|
||||||
|
|
||||||
Stops processing the download queue. Active downloads will be allowed to
|
|
||||||
complete (with a timeout), then the queue processor will shut down.
|
|
||||||
Queue state is persisted before shutdown.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been stopped
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.stop()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue processing stopped",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to stop download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause", status_code=status.HTTP_200_OK)
|
|
||||||
async def pause_queue(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Pause the download queue processor.
|
|
||||||
|
|
||||||
Pauses download processing. Active downloads will continue, but no new
|
|
||||||
downloads will be started until the queue is resumed.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been paused
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.pause_queue()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue paused",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to pause download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume", status_code=status.HTTP_200_OK)
|
|
||||||
async def resume_queue(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Resume the download queue processor.
|
|
||||||
|
|
||||||
Resumes download processing after being paused. The queue will continue
|
|
||||||
processing pending items according to priority.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating queue has been resumed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await download_service.resume_queue()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Download queue resumed",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to resume download queue: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reorder", status_code=status.HTTP_200_OK)
|
|
||||||
async def reorder_queue(
|
|
||||||
request: QueueReorderRequest,
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Reorder an item in the pending queue.
|
|
||||||
|
|
||||||
Changes the position of a pending download item in the queue. This only
|
|
||||||
affects items that haven't started downloading yet. The position is
|
|
||||||
0-based.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Item ID and new position in queue
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message indicating item has been reordered
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
|
||||||
400 for invalid request, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
success = await download_service.reorder_queue(
|
|
||||||
item_id=request.item_id,
|
|
||||||
new_position=request.new_position,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Item {request.item_id} not found in pending queue",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": "Queue item reordered successfully",
|
|
||||||
}
|
|
||||||
|
|
||||||
except DownloadServiceError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to reorder queue item: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/completed", status_code=status.HTTP_200_OK)
|
|
||||||
async def clear_completed(
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Clear completed downloads from history.
|
|
||||||
|
|
||||||
Removes all completed download items from the queue history. This helps
|
|
||||||
keep the queue display clean and manageable.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message with count of cleared items
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cleared_count = await download_service.clear_completed()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"Cleared {cleared_count} completed item(s)",
|
|
||||||
"count": cleared_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to clear completed items: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/retry", status_code=status.HTTP_200_OK)
|
|
||||||
async def retry_failed(
|
|
||||||
request: QueueOperationRequest,
|
|
||||||
download_service: DownloadService = Depends(get_download_service),
|
|
||||||
_: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Retry failed downloads.
|
|
||||||
|
|
||||||
Moves failed download items back to the pending queue for retry. Only items
|
|
||||||
that haven't exceeded the maximum retry count will be retried.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: List of download item IDs to retry (empty list retries all)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Status message with count of retried items
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 401 if not authenticated, 500 on service error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# If no specific IDs provided, retry all failed items
|
|
||||||
item_ids = request.item_ids if request.item_ids else None
|
|
||||||
|
|
||||||
retried_ids = await download_service.retry_failed(item_ids)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"message": f"Retrying {len(retried_ids)} failed item(s)",
|
|
||||||
"retried_ids": retried_ids,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to retry downloads: {str(e)}",
|
|
||||||
)
|
|
||||||
@ -1,236 +0,0 @@
|
|||||||
"""WebSocket API endpoints for real-time communication.
|
|
||||||
|
|
||||||
This module provides WebSocket endpoints for clients to connect and receive
|
|
||||||
real-time updates about downloads, queue status, and system events.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, status
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from src.server.models.websocket import (
|
|
||||||
ClientMessage,
|
|
||||||
RoomSubscriptionRequest,
|
|
||||||
WebSocketMessageType,
|
|
||||||
)
|
|
||||||
from src.server.services.websocket_service import (
|
|
||||||
WebSocketService,
|
|
||||||
get_websocket_service,
|
|
||||||
)
|
|
||||||
from src.server.utils.dependencies import get_current_user_optional
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
|
||||||
|
|
||||||
|
|
||||||
@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.
|
|
||||||
|
|
||||||
Clients connect to this endpoint to receive real-time updates.
|
|
||||||
The connection is maintained until the client disconnects or
|
|
||||||
an error occurs.
|
|
||||||
|
|
||||||
Message flow:
|
|
||||||
1. Client connects
|
|
||||||
2. Server sends "connected" message
|
|
||||||
3. Client can send subscription requests (join/leave rooms)
|
|
||||||
4. Server broadcasts updates to subscribed rooms
|
|
||||||
5. Client disconnects
|
|
||||||
|
|
||||||
Example client subscription:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "join",
|
|
||||||
"room": "downloads"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server message format:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "download_progress",
|
|
||||||
"timestamp": "2025-10-17T10:30:00.000Z",
|
|
||||||
"data": {
|
|
||||||
"download_id": "abc123",
|
|
||||||
"percent": 45.2,
|
|
||||||
"speed_mbps": 2.5,
|
|
||||||
"eta_seconds": 180
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
connection_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Accept connection and register with service
|
|
||||||
await ws_service.connect(websocket, connection_id, user_id=user_id)
|
|
||||||
|
|
||||||
# Send connection confirmation
|
|
||||||
await ws_service.manager.send_personal_message(
|
|
||||||
{
|
|
||||||
"type": WebSocketMessageType.CONNECTED,
|
|
||||||
"data": {
|
|
||||||
"connection_id": connection_id,
|
|
||||||
"message": "Connected to Aniworld WebSocket",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"WebSocket client connected",
|
|
||||||
connection_id=connection_id,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle incoming messages
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# Receive message from client
|
|
||||||
data = await websocket.receive_json()
|
|
||||||
|
|
||||||
# Parse client message
|
|
||||||
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
|
|
||||||
|
|
||||||
# Handle room subscription requests
|
|
||||||
if client_msg.action in ["join", "leave"]:
|
|
||||||
try:
|
|
||||||
room_req = RoomSubscriptionRequest(
|
|
||||||
action=client_msg.action,
|
|
||||||
room=client_msg.data.get("room", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
if room_req.action == "join":
|
|
||||||
await ws_service.manager.join_room(
|
|
||||||
connection_id, room_req.room
|
|
||||||
)
|
|
||||||
await ws_service.manager.send_personal_message(
|
|
||||||
{
|
|
||||||
"type": WebSocketMessageType.SYSTEM_INFO,
|
|
||||||
"data": {
|
|
||||||
"message": (
|
|
||||||
f"Joined room: {room_req.room}"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connection_id,
|
|
||||||
)
|
|
||||||
elif room_req.action == "leave":
|
|
||||||
await ws_service.manager.leave_room(
|
|
||||||
connection_id, room_req.room
|
|
||||||
)
|
|
||||||
await ws_service.manager.send_personal_message(
|
|
||||||
{
|
|
||||||
"type": WebSocketMessageType.SYSTEM_INFO,
|
|
||||||
"data": {
|
|
||||||
"message": (
|
|
||||||
f"Left room: {room_req.room}"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"Invalid room subscription request",
|
|
||||||
connection_id=connection_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
await ws_service.send_error(
|
|
||||||
connection_id,
|
|
||||||
"Invalid room subscription",
|
|
||||||
"INVALID_SUBSCRIPTION",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle ping/pong for keepalive
|
|
||||||
elif client_msg.action == "ping":
|
|
||||||
await ws_service.manager.send_personal_message(
|
|
||||||
{"type": WebSocketMessageType.PONG, "data": {}},
|
|
||||||
connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"Unknown action from client",
|
|
||||||
connection_id=connection_id,
|
|
||||||
action=client_msg.action,
|
|
||||||
)
|
|
||||||
await ws_service.send_error(
|
|
||||||
connection_id,
|
|
||||||
f"Unknown action: {client_msg.action}",
|
|
||||||
"UNKNOWN_ACTION",
|
|
||||||
)
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
logger.info(
|
|
||||||
"WebSocket client disconnected",
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error handling WebSocket message",
|
|
||||||
connection_id=connection_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
await ws_service.send_error(
|
|
||||||
connection_id,
|
|
||||||
"Internal server error",
|
|
||||||
"SERVER_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"WebSocket connection error",
|
|
||||||
connection_id=connection_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# Cleanup connection
|
|
||||||
await ws_service.disconnect(connection_id)
|
|
||||||
logger.info("WebSocket connection closed", connection_id=connection_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def websocket_status(
|
|
||||||
ws_service: WebSocketService = Depends(get_websocket_service),
|
|
||||||
):
|
|
||||||
"""Get WebSocket service status and statistics.
|
|
||||||
|
|
||||||
Returns information about active connections and rooms.
|
|
||||||
Useful for monitoring and debugging.
|
|
||||||
"""
|
|
||||||
connection_count = await ws_service.manager.get_connection_count()
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_200_OK,
|
|
||||||
content={
|
|
||||||
"status": "operational",
|
|
||||||
"active_connections": connection_count,
|
|
||||||
"supported_message_types": [t.value for t in WebSocketMessageType],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@ -6,7 +6,7 @@ This module provides custom error handlers for different HTTP status codes.
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from src.server.utils.template_helpers import render_template
|
from src.server.utils.templates import templates
|
||||||
|
|
||||||
|
|
||||||
async def not_found_handler(request: Request, exc: HTTPException):
|
async def not_found_handler(request: Request, exc: HTTPException):
|
||||||
@ -16,11 +16,9 @@ async def not_found_handler(request: Request, exc: HTTPException):
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
content={"detail": "API endpoint not found"}
|
content={"detail": "API endpoint not found"}
|
||||||
)
|
)
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"error.html",
|
"error.html",
|
||||||
request,
|
{"request": request, "error": "Page not found", "status_code": 404}
|
||||||
context={"error": "Page not found", "status_code": 404},
|
|
||||||
title="404 - Not Found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -31,9 +29,11 @@ async def server_error_handler(request: Request, exc: Exception):
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content={"detail": "Internal server error"}
|
content={"detail": "Internal server error"}
|
||||||
)
|
)
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"error.html",
|
"error.html",
|
||||||
request,
|
{
|
||||||
context={"error": "Internal server error", "status_code": 500},
|
"request": request,
|
||||||
title="500 - Server Error"
|
"error": "Internal server error",
|
||||||
|
"status_code": 500
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@ -6,7 +6,7 @@ This module provides endpoints for serving HTML pages using Jinja2 templates.
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from src.server.utils.template_helpers import render_template
|
from src.server.utils.templates import templates
|
||||||
|
|
||||||
router = APIRouter(tags=["pages"])
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
@ -14,38 +14,34 @@ router = APIRouter(tags=["pages"])
|
|||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def root(request: Request):
|
async def root(request: Request):
|
||||||
"""Serve the main application page."""
|
"""Serve the main application page."""
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"index.html",
|
||||||
request,
|
{"request": request, "title": "Aniworld Download Manager"}
|
||||||
title="Aniworld Download Manager"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setup", response_class=HTMLResponse)
|
@router.get("/setup", response_class=HTMLResponse)
|
||||||
async def setup_page(request: Request):
|
async def setup_page(request: Request):
|
||||||
"""Serve the setup page."""
|
"""Serve the setup page."""
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"setup.html",
|
"setup.html",
|
||||||
request,
|
{"request": request, "title": "Setup - Aniworld"}
|
||||||
title="Setup - Aniworld"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
"""Serve the login page."""
|
"""Serve the login page."""
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"login.html",
|
"login.html",
|
||||||
request,
|
{"request": request, "title": "Login - Aniworld"}
|
||||||
title="Login - Aniworld"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/queue", response_class=HTMLResponse)
|
@router.get("/queue", response_class=HTMLResponse)
|
||||||
async def queue_page(request: Request):
|
async def queue_page(request: Request):
|
||||||
"""Serve the download queue page."""
|
"""Serve the download queue page."""
|
||||||
return render_template(
|
return templates.TemplateResponse(
|
||||||
"queue.html",
|
"queue.html",
|
||||||
request,
|
{"request": request, "title": "Download Queue - Aniworld"}
|
||||||
title="Download Queue - Aniworld"
|
|
||||||
)
|
)
|
||||||
@ -17,10 +17,7 @@ from src.config.settings import settings
|
|||||||
|
|
||||||
# Import core functionality
|
# Import core functionality
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
from src.server.api.anime import router as anime_router
|
|
||||||
from src.server.api.auth import router as auth_router
|
from src.server.api.auth import router as auth_router
|
||||||
from src.server.api.download import router as download_router
|
|
||||||
from src.server.api.websocket import router as websocket_router
|
|
||||||
from src.server.controllers.error_controller import (
|
from src.server.controllers.error_controller import (
|
||||||
not_found_handler,
|
not_found_handler,
|
||||||
server_error_handler,
|
server_error_handler,
|
||||||
@ -30,8 +27,6 @@ from src.server.controllers.error_controller import (
|
|||||||
from src.server.controllers.health_controller import router as health_router
|
from src.server.controllers.health_controller import router as health_router
|
||||||
from src.server.controllers.page_controller import router as page_router
|
from src.server.controllers.page_controller import router as page_router
|
||||||
from src.server.middleware.auth import AuthMiddleware
|
from src.server.middleware.auth import AuthMiddleware
|
||||||
from src.server.services.progress_service import get_progress_service
|
|
||||||
from src.server.services.websocket_service import get_websocket_service
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -62,9 +57,6 @@ app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
|||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(page_router)
|
app.include_router(page_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(anime_router)
|
|
||||||
app.include_router(download_router)
|
|
||||||
app.include_router(websocket_router)
|
|
||||||
|
|
||||||
# Global variables for application state
|
# Global variables for application state
|
||||||
series_app: Optional[SeriesApp] = None
|
series_app: Optional[SeriesApp] = None
|
||||||
@ -78,23 +70,6 @@ async def startup_event():
|
|||||||
# Initialize SeriesApp with configured directory
|
# Initialize SeriesApp with configured directory
|
||||||
if settings.anime_directory:
|
if settings.anime_directory:
|
||||||
series_app = SeriesApp(settings.anime_directory)
|
series_app = SeriesApp(settings.anime_directory)
|
||||||
|
|
||||||
# Initialize progress service with websocket callback
|
|
||||||
progress_service = get_progress_service()
|
|
||||||
ws_service = get_websocket_service()
|
|
||||||
|
|
||||||
async def broadcast_callback(
|
|
||||||
message_type: str, data: dict, room: str
|
|
||||||
):
|
|
||||||
"""Broadcast progress updates via WebSocket."""
|
|
||||||
message = {
|
|
||||||
"type": message_type,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
await ws_service.manager.broadcast_to_room(message, room)
|
|
||||||
|
|
||||||
progress_service.set_broadcast_callback(broadcast_callback)
|
|
||||||
|
|
||||||
print("FastAPI application started successfully")
|
print("FastAPI application started successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during startup: {e}")
|
print(f"Error during startup: {e}")
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""Models package for server-side Pydantic models."""
|
"""Models package for server-side Pydantic models."""
|
||||||
|
|
||||||
__all__ = ["auth", "anime", "config", "download"]
|
__all__ = ["auth"]
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
|
||||||
|
|
||||||
|
|
||||||
class EpisodeInfo(BaseModel):
|
|
||||||
"""Information about a single episode."""
|
|
||||||
|
|
||||||
episode_number: int = Field(..., ge=1, description="Episode index (1-based)")
|
|
||||||
title: Optional[str] = Field(None, description="Optional episode title")
|
|
||||||
aired_at: Optional[datetime] = Field(None, description="Air date/time if known")
|
|
||||||
duration_seconds: Optional[int] = Field(None, ge=0, description="Duration in seconds")
|
|
||||||
available: bool = Field(True, description="Whether the episode is available for download")
|
|
||||||
sources: List[HttpUrl] = Field(default_factory=list, description="List of known streaming/download source URLs")
|
|
||||||
|
|
||||||
|
|
||||||
class MissingEpisodeInfo(BaseModel):
|
|
||||||
"""Represents a gap in the episode list for a series."""
|
|
||||||
|
|
||||||
from_episode: int = Field(..., ge=1, description="Starting missing episode number")
|
|
||||||
to_episode: int = Field(..., ge=1, description="Ending missing episode number (inclusive)")
|
|
||||||
reason: Optional[str] = Field(None, description="Optional explanation why episodes are missing")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Number of missing episodes in the range."""
|
|
||||||
return max(0, self.to_episode - self.from_episode + 1)
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSeriesResponse(BaseModel):
|
|
||||||
"""Response model for a series with metadata and episodes."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique series identifier")
|
|
||||||
title: str = Field(..., description="Series title")
|
|
||||||
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")
|
|
||||||
episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information")
|
|
||||||
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
|
||||||
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
|
||||||
"""Search result item for a series discovery endpoint."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
snippet: Optional[str] = None
|
|
||||||
thumbnail: Optional[HttpUrl] = None
|
|
||||||
score: Optional[float] = None
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
|
||||||
|
|
||||||
|
|
||||||
class EpisodeInfo(BaseModel):
|
|
||||||
"""Information about a single episode."""
|
|
||||||
|
|
||||||
episode_number: int = Field(..., ge=1, description="Episode index (1-based)")
|
|
||||||
title: Optional[str] = Field(None, description="Optional episode title")
|
|
||||||
aired_at: Optional[datetime] = Field(None, description="Air date/time if known")
|
|
||||||
duration_seconds: Optional[int] = Field(None, ge=0, description="Duration in seconds")
|
|
||||||
available: bool = Field(True, description="Whether the episode is available for download")
|
|
||||||
sources: List[HttpUrl] = Field(default_factory=list, description="List of known streaming/download source URLs")
|
|
||||||
|
|
||||||
|
|
||||||
class MissingEpisodeInfo(BaseModel):
|
|
||||||
"""Represents a gap in the episode list for a series."""
|
|
||||||
|
|
||||||
from_episode: int = Field(..., ge=1, description="Starting missing episode number")
|
|
||||||
to_episode: int = Field(..., ge=1, description="Ending missing episode number (inclusive)")
|
|
||||||
reason: Optional[str] = Field(None, description="Optional explanation why episodes are missing")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Number of missing episodes in the range."""
|
|
||||||
return max(0, self.to_episode - self.from_episode + 1)
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSeriesResponse(BaseModel):
|
|
||||||
"""Response model for a series with metadata and episodes."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique series identifier")
|
|
||||||
title: str = Field(..., description="Series title")
|
|
||||||
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")
|
|
||||||
episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information")
|
|
||||||
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
|
||||||
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
|
||||||
"""Search result item for a series discovery endpoint."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
snippet: Optional[str] = None
|
|
||||||
thumbnail: Optional[HttpUrl] = None
|
|
||||||
score: Optional[float] = None
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationError, validator
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulerConfig(BaseModel):
|
|
||||||
"""Scheduler related configuration."""
|
|
||||||
|
|
||||||
enabled: bool = Field(
|
|
||||||
default=True, description="Whether the scheduler is enabled"
|
|
||||||
)
|
|
||||||
interval_minutes: int = Field(
|
|
||||||
default=60, ge=1, description="Scheduler interval in minutes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BackupConfig(BaseModel):
|
|
||||||
"""Configuration for automatic backups of application data."""
|
|
||||||
|
|
||||||
enabled: bool = Field(
|
|
||||||
default=False, description="Whether backups are enabled"
|
|
||||||
)
|
|
||||||
path: Optional[str] = Field(
|
|
||||||
default="data/backups", description="Path to store backups"
|
|
||||||
)
|
|
||||||
keep_days: int = Field(
|
|
||||||
default=30, ge=0, description="How many days to keep backups"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseModel):
|
|
||||||
"""Logging configuration with basic validation for level."""
|
|
||||||
|
|
||||||
level: str = Field(
|
|
||||||
default="INFO", description="Logging level"
|
|
||||||
)
|
|
||||||
file: Optional[str] = Field(
|
|
||||||
default=None, description="Optional file path for log output"
|
|
||||||
)
|
|
||||||
max_bytes: Optional[int] = Field(
|
|
||||||
default=None, ge=0, description="Max bytes per log file for rotation"
|
|
||||||
)
|
|
||||||
backup_count: Optional[int] = Field(
|
|
||||||
default=3, ge=0, description="Number of rotated log files to keep"
|
|
||||||
)
|
|
||||||
|
|
||||||
@validator("level")
|
|
||||||
def validate_level(cls, v: str) -> str:
|
|
||||||
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
|
||||||
lvl = (v or "").upper()
|
|
||||||
if lvl not in allowed:
|
|
||||||
raise ValueError(f"invalid logging level: {v}")
|
|
||||||
return lvl
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationResult(BaseModel):
|
|
||||||
"""Result of a configuration validation attempt."""
|
|
||||||
|
|
||||||
valid: bool = Field(..., description="Whether the configuration is valid")
|
|
||||||
errors: Optional[List[str]] = Field(
|
|
||||||
default_factory=list, description="List of validation error messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AppConfig(BaseModel):
|
|
||||||
"""Top-level application configuration model used by the web layer.
|
|
||||||
|
|
||||||
This model intentionally keeps things small and serializable to JSON.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str = Field(default="Aniworld", description="Application name")
|
|
||||||
data_dir: str = Field(default="data", description="Base data directory")
|
|
||||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
||||||
backup: BackupConfig = Field(default_factory=BackupConfig)
|
|
||||||
other: Dict[str, object] = Field(
|
|
||||||
default_factory=dict, description="Arbitrary other settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self) -> ValidationResult:
|
|
||||||
"""Perform light-weight validation and return a ValidationResult.
|
|
||||||
|
|
||||||
This method intentionally avoids performing IO (no filesystem checks)
|
|
||||||
so it remains fast and side-effect free for unit tests and API use.
|
|
||||||
"""
|
|
||||||
errors: List[str] = []
|
|
||||||
|
|
||||||
# Pydantic field validators already run on construction; re-run a
|
|
||||||
# quick check for common constraints and collect messages.
|
|
||||||
try:
|
|
||||||
# Reconstruct to ensure nested validators are executed
|
|
||||||
AppConfig(**self.model_dump())
|
|
||||||
except ValidationError as exc:
|
|
||||||
for e in exc.errors():
|
|
||||||
loc = ".".join(str(x) for x in e.get("loc", []))
|
|
||||||
msg = f"{loc}: {e.get('msg')}"
|
|
||||||
errors.append(msg)
|
|
||||||
|
|
||||||
# backup.path must be set when backups are enabled
|
|
||||||
if self.backup.enabled and (not self.backup.path):
|
|
||||||
errors.append(
|
|
||||||
"backup.path must be set when backups.enabled is true"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ValidationResult(valid=(len(errors) == 0), errors=errors)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigUpdate(BaseModel):
|
|
||||||
scheduler: Optional[SchedulerConfig] = None
|
|
||||||
logging: Optional[LoggingConfig] = None
|
|
||||||
backup: Optional[BackupConfig] = None
|
|
||||||
other: Optional[Dict[str, object]] = None
|
|
||||||
|
|
||||||
def apply_to(self, current: AppConfig) -> AppConfig:
|
|
||||||
"""Return a new AppConfig with updates applied to the current config.
|
|
||||||
|
|
||||||
Performs a shallow merge for `other`.
|
|
||||||
"""
|
|
||||||
data = current.model_dump()
|
|
||||||
if self.scheduler is not None:
|
|
||||||
data["scheduler"] = self.scheduler.model_dump()
|
|
||||||
if self.logging is not None:
|
|
||||||
data["logging"] = self.logging.model_dump()
|
|
||||||
if self.backup is not None:
|
|
||||||
data["backup"] = self.backup.model_dump()
|
|
||||||
if self.other is not None:
|
|
||||||
merged = dict(current.other or {})
|
|
||||||
merged.update(self.other)
|
|
||||||
data["other"] = merged
|
|
||||||
return AppConfig(**data)
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
"""Download queue Pydantic models for the Aniworld web application.
|
|
||||||
|
|
||||||
This module defines request/response models used by the download queue API
|
|
||||||
and the download service. Models are intentionally lightweight and focused
|
|
||||||
on serialization, validation, and OpenAPI documentation.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadStatus(str, Enum):
|
|
||||||
"""Status of a download item in the queue."""
|
|
||||||
|
|
||||||
PENDING = "pending"
|
|
||||||
DOWNLOADING = "downloading"
|
|
||||||
PAUSED = "paused"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadPriority(str, Enum):
|
|
||||||
"""Priority level for download queue items."""
|
|
||||||
|
|
||||||
LOW = "low"
|
|
||||||
NORMAL = "normal"
|
|
||||||
HIGH = "high"
|
|
||||||
|
|
||||||
|
|
||||||
class EpisodeIdentifier(BaseModel):
|
|
||||||
"""Episode identification information for a download item."""
|
|
||||||
|
|
||||||
season: int = Field(..., ge=1, description="Season number (1-based)")
|
|
||||||
episode: int = Field(
|
|
||||||
..., ge=1, description="Episode number within season (1-based)"
|
|
||||||
)
|
|
||||||
title: Optional[str] = Field(None, description="Episode title if known")
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadProgress(BaseModel):
|
|
||||||
"""Real-time progress information for an active download."""
|
|
||||||
|
|
||||||
percent: float = Field(
|
|
||||||
0.0, ge=0.0, le=100.0, description="Download progress percentage"
|
|
||||||
)
|
|
||||||
downloaded_mb: float = Field(
|
|
||||||
0.0, ge=0.0, description="Downloaded size in megabytes"
|
|
||||||
)
|
|
||||||
total_mb: Optional[float] = Field(
|
|
||||||
None, ge=0.0, description="Total size in megabytes if known"
|
|
||||||
)
|
|
||||||
speed_mbps: Optional[float] = Field(
|
|
||||||
None, ge=0.0, description="Download speed in MB/s"
|
|
||||||
)
|
|
||||||
eta_seconds: Optional[int] = Field(
|
|
||||||
None, ge=0, description="Estimated time remaining in seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadItem(BaseModel):
|
|
||||||
"""Represents a single download item in the queue."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="Unique download item identifier")
|
|
||||||
serie_id: str = Field(..., description="Series identifier")
|
|
||||||
serie_name: str = Field(..., min_length=1, description="Series name")
|
|
||||||
episode: EpisodeIdentifier = Field(
|
|
||||||
..., description="Episode identification"
|
|
||||||
)
|
|
||||||
status: DownloadStatus = Field(
|
|
||||||
DownloadStatus.PENDING, description="Current download status"
|
|
||||||
)
|
|
||||||
priority: DownloadPriority = Field(
|
|
||||||
DownloadPriority.NORMAL, description="Queue priority"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timestamps
|
|
||||||
added_at: datetime = Field(
|
|
||||||
default_factory=datetime.utcnow,
|
|
||||||
description="When item was added to queue"
|
|
||||||
)
|
|
||||||
started_at: Optional[datetime] = Field(
|
|
||||||
None, description="When download started"
|
|
||||||
)
|
|
||||||
completed_at: Optional[datetime] = Field(
|
|
||||||
None, description="When download completed/failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Progress tracking
|
|
||||||
progress: Optional[DownloadProgress] = Field(
|
|
||||||
None, description="Current progress if downloading"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
error: Optional[str] = Field(None, description="Error message if failed")
|
|
||||||
retry_count: int = Field(0, ge=0, description="Number of retry attempts")
|
|
||||||
|
|
||||||
# Download source
|
|
||||||
source_url: Optional[HttpUrl] = Field(
|
|
||||||
None, description="Source URL for download"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStatus(BaseModel):
|
|
||||||
"""Overall status of the download queue system."""
|
|
||||||
|
|
||||||
is_running: bool = Field(
|
|
||||||
False, description="Whether the queue processor is running"
|
|
||||||
)
|
|
||||||
is_paused: bool = Field(False, description="Whether downloads are paused")
|
|
||||||
active_downloads: List[DownloadItem] = Field(
|
|
||||||
default_factory=list, description="Currently downloading items"
|
|
||||||
)
|
|
||||||
pending_queue: List[DownloadItem] = Field(
|
|
||||||
default_factory=list, description="Items waiting to be downloaded"
|
|
||||||
)
|
|
||||||
completed_downloads: List[DownloadItem] = Field(
|
|
||||||
default_factory=list, description="Recently completed downloads"
|
|
||||||
)
|
|
||||||
failed_downloads: List[DownloadItem] = Field(
|
|
||||||
default_factory=list, description="Failed download items"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStats(BaseModel):
|
|
||||||
"""Statistics about the download queue."""
|
|
||||||
|
|
||||||
total_items: int = Field(
|
|
||||||
0, ge=0, description="Total number of items in all queues"
|
|
||||||
)
|
|
||||||
pending_count: int = Field(0, ge=0, description="Number of pending items")
|
|
||||||
active_count: int = Field(
|
|
||||||
0, ge=0, description="Number of active downloads"
|
|
||||||
)
|
|
||||||
completed_count: int = Field(
|
|
||||||
0, ge=0, description="Number of completed downloads"
|
|
||||||
)
|
|
||||||
failed_count: int = Field(
|
|
||||||
0, ge=0, description="Number of failed downloads"
|
|
||||||
)
|
|
||||||
|
|
||||||
total_downloaded_mb: float = Field(
|
|
||||||
0.0, ge=0.0, description="Total megabytes downloaded"
|
|
||||||
)
|
|
||||||
average_speed_mbps: Optional[float] = Field(
|
|
||||||
None, ge=0.0, description="Average download speed in MB/s"
|
|
||||||
)
|
|
||||||
estimated_time_remaining: Optional[int] = Field(
|
|
||||||
None, ge=0, description="Estimated time to complete queue in seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadRequest(BaseModel):
|
|
||||||
"""Request to add episode(s) to the download queue."""
|
|
||||||
|
|
||||||
serie_id: str = Field(..., description="Series identifier")
|
|
||||||
serie_name: str = Field(
|
|
||||||
..., min_length=1, description="Series name for display"
|
|
||||||
)
|
|
||||||
episodes: List[EpisodeIdentifier] = Field(
|
|
||||||
..., min_length=1, description="List of episodes to download"
|
|
||||||
)
|
|
||||||
priority: DownloadPriority = Field(
|
|
||||||
DownloadPriority.NORMAL, description="Priority level for queue items"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadResponse(BaseModel):
|
|
||||||
"""Response after adding items to the download queue."""
|
|
||||||
|
|
||||||
status: str = Field(..., description="Status of the request")
|
|
||||||
message: str = Field(..., description="Human-readable status message")
|
|
||||||
added_items: List[str] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="IDs of successfully added download items"
|
|
||||||
)
|
|
||||||
failed_items: List[str] = Field(
|
|
||||||
default_factory=list, description="Episodes that failed to be added"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueOperationRequest(BaseModel):
|
|
||||||
"""Request to perform operations on queue items."""
|
|
||||||
|
|
||||||
item_ids: List[str] = Field(
|
|
||||||
..., min_length=1, description="List of download item IDs"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueReorderRequest(BaseModel):
|
|
||||||
"""Request to reorder items in the pending queue."""
|
|
||||||
|
|
||||||
item_id: str = Field(..., description="Download item ID to move")
|
|
||||||
new_position: int = Field(
|
|
||||||
..., ge=0, description="New position in queue (0-based)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStatusResponse(BaseModel):
|
|
||||||
"""Complete response for queue status endpoint."""
|
|
||||||
|
|
||||||
status: QueueStatus = Field(..., description="Current queue status")
|
|
||||||
statistics: QueueStats = Field(..., description="Queue statistics")
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
"""WebSocket message Pydantic models for the Aniworld web application.
|
|
||||||
|
|
||||||
This module defines message models for WebSocket communication between
|
|
||||||
the server and clients. Models ensure type safety and provide validation
|
|
||||||
for real-time updates.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketMessageType(str, Enum):
|
|
||||||
"""Types of WebSocket messages."""
|
|
||||||
|
|
||||||
# Download-related messages
|
|
||||||
DOWNLOAD_PROGRESS = "download_progress"
|
|
||||||
DOWNLOAD_COMPLETE = "download_complete"
|
|
||||||
DOWNLOAD_FAILED = "download_failed"
|
|
||||||
DOWNLOAD_ADDED = "download_added"
|
|
||||||
DOWNLOAD_REMOVED = "download_removed"
|
|
||||||
|
|
||||||
# Queue-related messages
|
|
||||||
QUEUE_STATUS = "queue_status"
|
|
||||||
QUEUE_STARTED = "queue_started"
|
|
||||||
QUEUE_STOPPED = "queue_stopped"
|
|
||||||
QUEUE_PAUSED = "queue_paused"
|
|
||||||
QUEUE_RESUMED = "queue_resumed"
|
|
||||||
|
|
||||||
# Progress-related messages
|
|
||||||
SCAN_PROGRESS = "scan_progress"
|
|
||||||
SCAN_COMPLETE = "scan_complete"
|
|
||||||
SCAN_FAILED = "scan_failed"
|
|
||||||
|
|
||||||
# System messages
|
|
||||||
SYSTEM_INFO = "system_info"
|
|
||||||
SYSTEM_WARNING = "system_warning"
|
|
||||||
SYSTEM_ERROR = "system_error"
|
|
||||||
|
|
||||||
# Error messages
|
|
||||||
ERROR = "error"
|
|
||||||
|
|
||||||
# Connection messages
|
|
||||||
CONNECTED = "connected"
|
|
||||||
PING = "ping"
|
|
||||||
PONG = "pong"
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketMessage(BaseModel):
|
|
||||||
"""Base WebSocket message structure."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
..., description="Type of the message"
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp when message was created",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
default_factory=dict, description="Message payload"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadProgressMessage(BaseModel):
|
|
||||||
"""Download progress update message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.DOWNLOAD_PROGRESS,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description="Progress data including download_id, percent, speed, eta",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadCompleteMessage(BaseModel):
|
|
||||||
"""Download completion message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.DOWNLOAD_COMPLETE,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
..., description="Completion data including download_id, file_path"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadFailedMessage(BaseModel):
|
|
||||||
"""Download failure message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.DOWNLOAD_FAILED,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
..., description="Error data including download_id, error_message"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStatusMessage(BaseModel):
|
|
||||||
"""Queue status update message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.QUEUE_STATUS,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description="Queue status including active, pending, completed counts",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SystemMessage(BaseModel):
|
|
||||||
"""System-level message (info, warning, error)."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
..., description="System message type"
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
..., description="System message data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorMessage(BaseModel):
|
|
||||||
"""Error message to client."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.ERROR, description="Message type"
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
..., description="Error data including code and message"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionMessage(BaseModel):
|
|
||||||
"""Connection-related message (connected, ping, pong)."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
..., description="Connection message type"
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
default_factory=dict, description="Connection message data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientMessage(BaseModel):
|
|
||||||
"""Inbound message from client to server."""
|
|
||||||
|
|
||||||
action: str = Field(..., description="Action requested by client")
|
|
||||||
data: Optional[Dict[str, Any]] = Field(
|
|
||||||
default_factory=dict, description="Action payload"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RoomSubscriptionRequest(BaseModel):
|
|
||||||
"""Request to join or leave a room."""
|
|
||||||
|
|
||||||
action: str = Field(
|
|
||||||
..., description="Action: 'join' or 'leave'"
|
|
||||||
)
|
|
||||||
room: str = Field(
|
|
||||||
..., min_length=1, description="Room name to join or leave"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScanProgressMessage(BaseModel):
|
|
||||||
"""Scan progress update message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.SCAN_PROGRESS,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description="Scan progress data including current, total, percent",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScanCompleteMessage(BaseModel):
|
|
||||||
"""Scan completion message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.SCAN_COMPLETE,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description="Scan completion data including series_found, duration",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScanFailedMessage(BaseModel):
|
|
||||||
"""Scan failure message."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.SCAN_FAILED,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
..., description="Scan error data including error_message"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorNotificationMessage(BaseModel):
|
|
||||||
"""Error notification message for critical errors."""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
default=WebSocketMessageType.SYSTEM_ERROR,
|
|
||||||
description="Message type",
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description=(
|
|
||||||
"Error notification data including severity, message, details"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressUpdateMessage(BaseModel):
|
|
||||||
"""Generic progress update message.
|
|
||||||
|
|
||||||
Can be used for any type of progress (download, scan, queue, etc.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
type: WebSocketMessageType = Field(
|
|
||||||
..., description="Type of progress message"
|
|
||||||
)
|
|
||||||
timestamp: str = Field(
|
|
||||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
|
||||||
description="ISO 8601 timestamp",
|
|
||||||
)
|
|
||||||
data: Dict[str, Any] = Field(
|
|
||||||
...,
|
|
||||||
description=(
|
|
||||||
"Progress data including id, status, percent, current, total"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Callable, List, Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from src.core.SeriesApp import SeriesApp
|
|
||||||
from src.server.services.progress_service import (
|
|
||||||
ProgressService,
|
|
||||||
ProgressType,
|
|
||||||
get_progress_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeServiceError(Exception):
|
|
||||||
"""Service-level exception for anime operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeService:
|
|
||||||
"""Wraps the blocking SeriesApp for use in the FastAPI web layer.
|
|
||||||
|
|
||||||
- Runs blocking operations in a threadpool
|
|
||||||
- Exposes async methods
|
|
||||||
- Adds simple in-memory caching for read operations
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
directory: str,
|
|
||||||
max_workers: int = 4,
|
|
||||||
progress_service: Optional[ProgressService] = None,
|
|
||||||
):
|
|
||||||
self._directory = directory
|
|
||||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
||||||
self._progress_service = progress_service or get_progress_service()
|
|
||||||
# SeriesApp is blocking; instantiate per-service
|
|
||||||
try:
|
|
||||||
self._app = SeriesApp(directory)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to initialize SeriesApp")
|
|
||||||
raise AnimeServiceError("Initialization failed") from e
|
|
||||||
|
|
||||||
async def _run_in_executor(self, func, *args, **kwargs):
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
|
||||||
return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs))
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Executor task failed")
|
|
||||||
raise AnimeServiceError(str(e)) from e
|
|
||||||
|
|
||||||
@lru_cache(maxsize=128)
|
|
||||||
def _cached_list_missing(self) -> List[dict]:
|
|
||||||
# Synchronous cached call used by async wrapper
|
|
||||||
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]
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to get missing episodes list")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def list_missing(self) -> List[dict]:
|
|
||||||
"""Return list of series with missing episodes."""
|
|
||||||
try:
|
|
||||||
return await self._run_in_executor(self._cached_list_missing)
|
|
||||||
except AnimeServiceError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("list_missing failed")
|
|
||||||
raise AnimeServiceError("Failed to list missing series") from e
|
|
||||||
|
|
||||||
async def search(self, query: str) -> List[dict]:
|
|
||||||
"""Search for series using underlying loader.Search."""
|
|
||||||
if not query:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
result = await self._run_in_executor(self._app.search, query)
|
|
||||||
# result may already be list of dicts or objects
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("search failed")
|
|
||||||
raise AnimeServiceError("Search failed") from e
|
|
||||||
|
|
||||||
async def rescan(self, callback: Optional[Callable] = None) -> None:
|
|
||||||
"""Trigger a re-scan. Accepts an optional callback function.
|
|
||||||
|
|
||||||
The callback is executed in the threadpool by SeriesApp.
|
|
||||||
Progress updates are tracked and broadcasted via ProgressService.
|
|
||||||
"""
|
|
||||||
scan_id = "library_scan"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start progress tracking
|
|
||||||
await self._progress_service.start_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Scanning anime library",
|
|
||||||
message="Initializing scan...",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create wrapped callback for progress updates
|
|
||||||
def progress_callback(progress_data: dict) -> None:
|
|
||||||
"""Update progress during scan."""
|
|
||||||
try:
|
|
||||||
if callback:
|
|
||||||
callback(progress_data)
|
|
||||||
|
|
||||||
# Update progress service
|
|
||||||
current = progress_data.get("current", 0)
|
|
||||||
total = progress_data.get("total", 0)
|
|
||||||
message = progress_data.get("message", "Scanning...")
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
self._progress_service.update_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
current=current,
|
|
||||||
total=total,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Scan progress callback error", error=str(e))
|
|
||||||
|
|
||||||
# Run scan
|
|
||||||
await self._run_in_executor(self._app.ReScan, progress_callback)
|
|
||||||
|
|
||||||
# invalidate cache
|
|
||||||
try:
|
|
||||||
self._cached_list_missing.cache_clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Complete progress tracking
|
|
||||||
await self._progress_service.complete_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
message="Scan completed successfully",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("rescan failed")
|
|
||||||
|
|
||||||
# Fail progress tracking
|
|
||||||
await self._progress_service.fail_progress(
|
|
||||||
progress_id=scan_id,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
raise AnimeServiceError("Rescan failed") from e
|
|
||||||
|
|
||||||
async def download(self, serie_folder: str, season: int, episode: int, key: str, callback=None) -> bool:
|
|
||||||
"""Start a download via the underlying loader.
|
|
||||||
|
|
||||||
Returns True on success or raises AnimeServiceError on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self._run_in_executor(self._app.download, serie_folder, season, episode, key, callback)
|
|
||||||
return bool(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("download failed")
|
|
||||||
raise AnimeServiceError("Download failed") from e
|
|
||||||
|
|
||||||
|
|
||||||
def get_anime_service(directory: str = "./") -> AnimeService:
|
|
||||||
"""Factory used by FastAPI dependency injection."""
|
|
||||||
return AnimeService(directory)
|
|
||||||
@ -1,859 +0,0 @@
|
|||||||
"""Download queue service for managing anime episode downloads.
|
|
||||||
|
|
||||||
This module provides a comprehensive queue management system for handling
|
|
||||||
concurrent anime episode downloads with priority-based scheduling, progress
|
|
||||||
tracking, persistence, and automatic retry functionality.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from collections import deque
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
from src.server.models.download import (
|
|
||||||
DownloadItem,
|
|
||||||
DownloadPriority,
|
|
||||||
DownloadProgress,
|
|
||||||
DownloadStatus,
|
|
||||||
EpisodeIdentifier,
|
|
||||||
QueueStats,
|
|
||||||
QueueStatus,
|
|
||||||
)
|
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
|
||||||
from src.server.services.progress_service import (
|
|
||||||
ProgressService,
|
|
||||||
ProgressType,
|
|
||||||
get_progress_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadServiceError(Exception):
|
|
||||||
"""Service-level exception for download queue operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadService:
|
|
||||||
"""Manages the download queue with concurrent processing and persistence.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Priority-based queue management
|
|
||||||
- Concurrent download processing
|
|
||||||
- Real-time progress tracking
|
|
||||||
- Queue persistence and recovery
|
|
||||||
- Automatic retry logic
|
|
||||||
- WebSocket broadcast support
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
anime_service: AnimeService,
|
|
||||||
max_concurrent_downloads: int = 2,
|
|
||||||
max_retries: int = 3,
|
|
||||||
persistence_path: str = "./data/download_queue.json",
|
|
||||||
progress_service: Optional[ProgressService] = None,
|
|
||||||
):
|
|
||||||
"""Initialize the download service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_service: Service for anime operations
|
|
||||||
max_concurrent_downloads: Maximum simultaneous downloads
|
|
||||||
max_retries: Maximum retry attempts for failed downloads
|
|
||||||
persistence_path: Path to persist queue state
|
|
||||||
progress_service: Optional progress service for tracking
|
|
||||||
"""
|
|
||||||
self._anime_service = anime_service
|
|
||||||
self._max_concurrent = max_concurrent_downloads
|
|
||||||
self._max_retries = max_retries
|
|
||||||
self._persistence_path = Path(persistence_path)
|
|
||||||
self._progress_service = progress_service or get_progress_service()
|
|
||||||
|
|
||||||
# Queue storage by status
|
|
||||||
self._pending_queue: deque[DownloadItem] = deque()
|
|
||||||
self._active_downloads: Dict[str, DownloadItem] = {}
|
|
||||||
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
|
||||||
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
|
||||||
|
|
||||||
# Control flags
|
|
||||||
self._is_running = False
|
|
||||||
self._is_paused = False
|
|
||||||
self._shutdown_event = asyncio.Event()
|
|
||||||
|
|
||||||
# Executor for blocking operations
|
|
||||||
self._executor = ThreadPoolExecutor(
|
|
||||||
max_workers=max_concurrent_downloads
|
|
||||||
)
|
|
||||||
|
|
||||||
# WebSocket broadcast callback
|
|
||||||
self._broadcast_callback: Optional[Callable] = None
|
|
||||||
|
|
||||||
# Statistics tracking
|
|
||||||
self._total_downloaded_mb: float = 0.0
|
|
||||||
self._download_speeds: deque[float] = deque(maxlen=10)
|
|
||||||
|
|
||||||
# Load persisted queue
|
|
||||||
self._load_queue()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"DownloadService initialized",
|
|
||||||
max_concurrent=max_concurrent_downloads,
|
|
||||||
max_retries=max_retries,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_broadcast_callback(self, callback: Callable) -> None:
|
|
||||||
"""Set callback for broadcasting status updates via WebSocket."""
|
|
||||||
self._broadcast_callback = callback
|
|
||||||
logger.debug("Broadcast callback registered")
|
|
||||||
|
|
||||||
async def _broadcast_update(self, update_type: str, data: dict) -> None:
|
|
||||||
"""Broadcast update to connected WebSocket clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
update_type: Type of update (download_progress, queue_status, etc.)
|
|
||||||
data: Update data to broadcast
|
|
||||||
"""
|
|
||||||
if self._broadcast_callback:
|
|
||||||
try:
|
|
||||||
await self._broadcast_callback(update_type, data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast update",
|
|
||||||
update_type=update_type,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_item_id(self) -> str:
|
|
||||||
"""Generate unique identifier for download items."""
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
def _load_queue(self) -> None:
|
|
||||||
"""Load persisted queue from disk."""
|
|
||||||
try:
|
|
||||||
if self._persistence_path.exists():
|
|
||||||
with open(self._persistence_path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
# Restore pending items
|
|
||||||
for item_dict in data.get("pending", []):
|
|
||||||
item = DownloadItem(**item_dict)
|
|
||||||
# Reset status if was downloading when saved
|
|
||||||
if item.status == DownloadStatus.DOWNLOADING:
|
|
||||||
item.status = DownloadStatus.PENDING
|
|
||||||
self._pending_queue.append(item)
|
|
||||||
|
|
||||||
# Restore failed items that can be retried
|
|
||||||
for item_dict in data.get("failed", []):
|
|
||||||
item = DownloadItem(**item_dict)
|
|
||||||
if item.retry_count < self._max_retries:
|
|
||||||
item.status = DownloadStatus.PENDING
|
|
||||||
self._pending_queue.append(item)
|
|
||||||
else:
|
|
||||||
self._failed_items.append(item)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Queue restored from disk",
|
|
||||||
pending_count=len(self._pending_queue),
|
|
||||||
failed_count=len(self._failed_items),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to load persisted queue", error=str(e))
|
|
||||||
|
|
||||||
def _save_queue(self) -> None:
|
|
||||||
"""Persist current queue state to disk."""
|
|
||||||
try:
|
|
||||||
self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"pending": [
|
|
||||||
item.model_dump(mode="json")
|
|
||||||
for item in self._pending_queue
|
|
||||||
],
|
|
||||||
"active": [
|
|
||||||
item.model_dump(mode="json")
|
|
||||||
for item in self._active_downloads.values()
|
|
||||||
],
|
|
||||||
"failed": [
|
|
||||||
item.model_dump(mode="json")
|
|
||||||
for item in self._failed_items
|
|
||||||
],
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(self._persistence_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
logger.debug("Queue persisted to disk")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to persist queue", error=str(e))
|
|
||||||
|
|
||||||
async def add_to_queue(
|
|
||||||
self,
|
|
||||||
serie_id: str,
|
|
||||||
serie_name: str,
|
|
||||||
episodes: List[EpisodeIdentifier],
|
|
||||||
priority: DownloadPriority = DownloadPriority.NORMAL,
|
|
||||||
) -> List[str]:
|
|
||||||
"""Add episodes to the download queue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
serie_id: Series identifier
|
|
||||||
serie_name: Series display name
|
|
||||||
episodes: List of episodes to download
|
|
||||||
priority: Queue priority level
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of created download item IDs
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DownloadServiceError: If adding items fails
|
|
||||||
"""
|
|
||||||
created_ids = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
for episode in episodes:
|
|
||||||
item = DownloadItem(
|
|
||||||
id=self._generate_item_id(),
|
|
||||||
serie_id=serie_id,
|
|
||||||
serie_name=serie_name,
|
|
||||||
episode=episode,
|
|
||||||
status=DownloadStatus.PENDING,
|
|
||||||
priority=priority,
|
|
||||||
added_at=datetime.utcnow(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert based on priority
|
|
||||||
if priority == DownloadPriority.HIGH:
|
|
||||||
self._pending_queue.appendleft(item)
|
|
||||||
else:
|
|
||||||
self._pending_queue.append(item)
|
|
||||||
|
|
||||||
created_ids.append(item.id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Item added to queue",
|
|
||||||
item_id=item.id,
|
|
||||||
serie=serie_name,
|
|
||||||
season=episode.season,
|
|
||||||
episode=episode.episode,
|
|
||||||
priority=priority.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._save_queue()
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "items_added",
|
|
||||||
"added_ids": created_ids,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return created_ids
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to add items to queue", error=str(e))
|
|
||||||
raise DownloadServiceError(f"Failed to add items: {str(e)}") from e
|
|
||||||
|
|
||||||
async def remove_from_queue(self, item_ids: List[str]) -> List[str]:
|
|
||||||
"""Remove items from the queue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item_ids: List of download item IDs to remove
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of successfully removed item IDs
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DownloadServiceError: If removal fails
|
|
||||||
"""
|
|
||||||
removed_ids = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
for item_id in item_ids:
|
|
||||||
# Check if item is currently downloading
|
|
||||||
if item_id in self._active_downloads:
|
|
||||||
item = self._active_downloads[item_id]
|
|
||||||
item.status = DownloadStatus.CANCELLED
|
|
||||||
item.completed_at = datetime.utcnow()
|
|
||||||
self._failed_items.append(item)
|
|
||||||
del self._active_downloads[item_id]
|
|
||||||
removed_ids.append(item_id)
|
|
||||||
logger.info("Cancelled active download", item_id=item_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check pending queue
|
|
||||||
for item in list(self._pending_queue):
|
|
||||||
if item.id == item_id:
|
|
||||||
self._pending_queue.remove(item)
|
|
||||||
removed_ids.append(item_id)
|
|
||||||
logger.info(
|
|
||||||
"Removed from pending queue", item_id=item_id
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
if removed_ids:
|
|
||||||
self._save_queue()
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "items_removed",
|
|
||||||
"removed_ids": removed_ids,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return removed_ids
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove items", error=str(e))
|
|
||||||
raise DownloadServiceError(
|
|
||||||
f"Failed to remove items: {str(e)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
async def reorder_queue(self, item_id: str, new_position: int) -> bool:
|
|
||||||
"""Reorder an item in the pending queue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item_id: Download item ID to reorder
|
|
||||||
new_position: New position in queue (0-based)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if reordering was successful
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
DownloadServiceError: If reordering fails
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Find and remove item
|
|
||||||
item_to_move = None
|
|
||||||
for item in list(self._pending_queue):
|
|
||||||
if item.id == item_id:
|
|
||||||
self._pending_queue.remove(item)
|
|
||||||
item_to_move = item
|
|
||||||
break
|
|
||||||
|
|
||||||
if not item_to_move:
|
|
||||||
raise DownloadServiceError(
|
|
||||||
f"Item {item_id} not found in pending queue"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert at new position
|
|
||||||
queue_list = list(self._pending_queue)
|
|
||||||
new_position = max(0, min(new_position, len(queue_list)))
|
|
||||||
queue_list.insert(new_position, item_to_move)
|
|
||||||
self._pending_queue = deque(queue_list)
|
|
||||||
|
|
||||||
self._save_queue()
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "queue_reordered",
|
|
||||||
"item_id": item_id,
|
|
||||||
"new_position": new_position,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Queue item reordered",
|
|
||||||
item_id=item_id,
|
|
||||||
new_position=new_position
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to reorder queue", error=str(e))
|
|
||||||
raise DownloadServiceError(
|
|
||||||
f"Failed to reorder: {str(e)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
async def get_queue_status(self) -> QueueStatus:
|
|
||||||
"""Get current status of all queues.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete queue status with all items
|
|
||||||
"""
|
|
||||||
return QueueStatus(
|
|
||||||
is_running=self._is_running,
|
|
||||||
is_paused=self._is_paused,
|
|
||||||
active_downloads=list(self._active_downloads.values()),
|
|
||||||
pending_queue=list(self._pending_queue),
|
|
||||||
completed_downloads=list(self._completed_items),
|
|
||||||
failed_downloads=list(self._failed_items),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_queue_stats(self) -> QueueStats:
|
|
||||||
"""Calculate queue statistics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Statistics about the download queue
|
|
||||||
"""
|
|
||||||
active_count = len(self._active_downloads)
|
|
||||||
pending_count = len(self._pending_queue)
|
|
||||||
completed_count = len(self._completed_items)
|
|
||||||
failed_count = len(self._failed_items)
|
|
||||||
|
|
||||||
# Calculate average speed
|
|
||||||
avg_speed = None
|
|
||||||
if self._download_speeds:
|
|
||||||
avg_speed = (
|
|
||||||
sum(self._download_speeds) / len(self._download_speeds)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Estimate remaining time
|
|
||||||
eta_seconds = None
|
|
||||||
if avg_speed and avg_speed > 0 and pending_count > 0:
|
|
||||||
# Rough estimation based on average file size
|
|
||||||
estimated_size_per_episode = 500 # MB
|
|
||||||
remaining_mb = pending_count * estimated_size_per_episode
|
|
||||||
eta_seconds = int(remaining_mb / avg_speed)
|
|
||||||
|
|
||||||
return QueueStats(
|
|
||||||
total_items=(
|
|
||||||
active_count + pending_count + completed_count + failed_count
|
|
||||||
),
|
|
||||||
pending_count=pending_count,
|
|
||||||
active_count=active_count,
|
|
||||||
completed_count=completed_count,
|
|
||||||
failed_count=failed_count,
|
|
||||||
total_downloaded_mb=self._total_downloaded_mb,
|
|
||||||
average_speed_mbps=avg_speed,
|
|
||||||
estimated_time_remaining=eta_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def pause_queue(self) -> None:
|
|
||||||
"""Pause download processing."""
|
|
||||||
self._is_paused = True
|
|
||||||
logger.info("Download queue paused")
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_paused",
|
|
||||||
{
|
|
||||||
"is_paused": True,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def resume_queue(self) -> None:
|
|
||||||
"""Resume download processing."""
|
|
||||||
self._is_paused = False
|
|
||||||
logger.info("Download queue resumed")
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_resumed",
|
|
||||||
{
|
|
||||||
"is_paused": False,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def clear_completed(self) -> int:
|
|
||||||
"""Clear completed downloads from history.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of items cleared
|
|
||||||
"""
|
|
||||||
count = len(self._completed_items)
|
|
||||||
self._completed_items.clear()
|
|
||||||
logger.info("Cleared completed items", count=count)
|
|
||||||
|
|
||||||
# Broadcast queue status update
|
|
||||||
if count > 0:
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "completed_cleared",
|
|
||||||
"cleared_count": count,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def retry_failed(
|
|
||||||
self, item_ids: Optional[List[str]] = None
|
|
||||||
) -> List[str]:
|
|
||||||
"""Retry failed downloads.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item_ids: Specific item IDs to retry, or None for all failed items
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of item IDs moved back to pending queue
|
|
||||||
"""
|
|
||||||
retried_ids = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
failed_list = list(self._failed_items)
|
|
||||||
|
|
||||||
for item in failed_list:
|
|
||||||
# Skip if specific IDs requested and this isn't one
|
|
||||||
if item_ids and item.id not in item_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if max retries reached
|
|
||||||
if item.retry_count >= self._max_retries:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Move back to pending
|
|
||||||
self._failed_items.remove(item)
|
|
||||||
item.status = DownloadStatus.PENDING
|
|
||||||
item.retry_count += 1
|
|
||||||
item.error = None
|
|
||||||
item.progress = None
|
|
||||||
self._pending_queue.append(item)
|
|
||||||
retried_ids.append(item.id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Retrying failed item",
|
|
||||||
item_id=item.id,
|
|
||||||
retry_count=item.retry_count
|
|
||||||
)
|
|
||||||
|
|
||||||
if retried_ids:
|
|
||||||
self._save_queue()
|
|
||||||
# Broadcast queue status update
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_status",
|
|
||||||
{
|
|
||||||
"action": "items_retried",
|
|
||||||
"retried_ids": retried_ids,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return retried_ids
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to retry items", error=str(e))
|
|
||||||
raise DownloadServiceError(
|
|
||||||
f"Failed to retry: {str(e)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
def _create_progress_callback(self, item: DownloadItem) -> Callable:
|
|
||||||
"""Create a progress callback for a download item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item: Download item to track progress for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Callback function for progress updates
|
|
||||||
"""
|
|
||||||
def progress_callback(progress_data: dict) -> None:
|
|
||||||
"""Update progress and broadcast to clients."""
|
|
||||||
try:
|
|
||||||
# Update item progress
|
|
||||||
item.progress = DownloadProgress(
|
|
||||||
percent=progress_data.get("percent", 0.0),
|
|
||||||
downloaded_mb=progress_data.get("downloaded_mb", 0.0),
|
|
||||||
total_mb=progress_data.get("total_mb"),
|
|
||||||
speed_mbps=progress_data.get("speed_mbps"),
|
|
||||||
eta_seconds=progress_data.get("eta_seconds"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track speed for statistics
|
|
||||||
if item.progress.speed_mbps:
|
|
||||||
self._download_speeds.append(item.progress.speed_mbps)
|
|
||||||
|
|
||||||
# Update progress service
|
|
||||||
if item.progress.total_mb and item.progress.total_mb > 0:
|
|
||||||
current_mb = int(item.progress.downloaded_mb)
|
|
||||||
total_mb = int(item.progress.total_mb)
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
self._progress_service.update_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
current=current_mb,
|
|
||||||
total=total_mb,
|
|
||||||
metadata={
|
|
||||||
"speed_mbps": item.progress.speed_mbps,
|
|
||||||
"eta_seconds": item.progress.eta_seconds,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast update (fire and forget)
|
|
||||||
asyncio.create_task(
|
|
||||||
self._broadcast_update(
|
|
||||||
"download_progress",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"progress": item.progress.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Progress callback error", error=str(e))
|
|
||||||
|
|
||||||
return progress_callback
|
|
||||||
|
|
||||||
async def _process_download(self, item: DownloadItem) -> None:
|
|
||||||
"""Process a single download item.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item: Download item to process
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Update status
|
|
||||||
item.status = DownloadStatus.DOWNLOADING
|
|
||||||
item.started_at = datetime.utcnow()
|
|
||||||
self._active_downloads[item.id] = item
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Starting download",
|
|
||||||
item_id=item.id,
|
|
||||||
serie=item.serie_name,
|
|
||||||
season=item.episode.season,
|
|
||||||
episode=item.episode.episode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start progress tracking
|
|
||||||
await self._progress_service.start_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title=f"Downloading {item.serie_name}",
|
|
||||||
message=(
|
|
||||||
f"S{item.episode.season:02d}E{item.episode.episode:02d}"
|
|
||||||
),
|
|
||||||
metadata={
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create progress callback
|
|
||||||
progress_callback = self._create_progress_callback(item)
|
|
||||||
|
|
||||||
# Execute download via anime service
|
|
||||||
success = await self._anime_service.download(
|
|
||||||
serie_folder=item.serie_id,
|
|
||||||
season=item.episode.season,
|
|
||||||
episode=item.episode.episode,
|
|
||||||
key=item.serie_id, # Assuming serie_id is the provider key
|
|
||||||
callback=progress_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle result
|
|
||||||
if success:
|
|
||||||
item.status = DownloadStatus.COMPLETED
|
|
||||||
item.completed_at = datetime.utcnow()
|
|
||||||
|
|
||||||
# Track downloaded size
|
|
||||||
if item.progress and item.progress.downloaded_mb:
|
|
||||||
self._total_downloaded_mb += item.progress.downloaded_mb
|
|
||||||
|
|
||||||
self._completed_items.append(item)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Download completed successfully", item_id=item.id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Complete progress tracking
|
|
||||||
await self._progress_service.complete_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
message="Download completed successfully",
|
|
||||||
metadata={
|
|
||||||
"downloaded_mb": item.progress.downloaded_mb
|
|
||||||
if item.progress
|
|
||||||
else 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._broadcast_update(
|
|
||||||
"download_complete",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"downloaded_mb": item.progress.downloaded_mb
|
|
||||||
if item.progress
|
|
||||||
else 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise AnimeServiceError("Download returned False")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Handle failure
|
|
||||||
item.status = DownloadStatus.FAILED
|
|
||||||
item.completed_at = datetime.utcnow()
|
|
||||||
item.error = str(e)
|
|
||||||
self._failed_items.append(item)
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
"Download failed",
|
|
||||||
item_id=item.id,
|
|
||||||
error=str(e),
|
|
||||||
retry_count=item.retry_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fail progress tracking
|
|
||||||
await self._progress_service.fail_progress(
|
|
||||||
progress_id=f"download_{item.id}",
|
|
||||||
error_message=str(e),
|
|
||||||
metadata={"retry_count": item.retry_count},
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._broadcast_update(
|
|
||||||
"download_failed",
|
|
||||||
{
|
|
||||||
"download_id": item.id,
|
|
||||||
"item_id": item.id,
|
|
||||||
"serie_name": item.serie_name,
|
|
||||||
"season": item.episode.season,
|
|
||||||
"episode": item.episode.episode,
|
|
||||||
"error": item.error,
|
|
||||||
"retry_count": item.retry_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Remove from active downloads
|
|
||||||
if item.id in self._active_downloads:
|
|
||||||
del self._active_downloads[item.id]
|
|
||||||
|
|
||||||
self._save_queue()
|
|
||||||
|
|
||||||
async def _queue_processor(self) -> None:
|
|
||||||
"""Main queue processing loop."""
|
|
||||||
logger.info("Queue processor started")
|
|
||||||
|
|
||||||
while not self._shutdown_event.is_set():
|
|
||||||
try:
|
|
||||||
# Wait if paused
|
|
||||||
if self._is_paused:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if we can start more downloads
|
|
||||||
if len(self._active_downloads) >= self._max_concurrent:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get next item from queue
|
|
||||||
if not self._pending_queue:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
item = self._pending_queue.popleft()
|
|
||||||
|
|
||||||
# Process download in background
|
|
||||||
asyncio.create_task(self._process_download(item))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Queue processor error", error=str(e))
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
logger.info("Queue processor stopped")
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Start the download queue processor."""
|
|
||||||
if self._is_running:
|
|
||||||
logger.warning("Queue processor already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._is_running = True
|
|
||||||
self._shutdown_event.clear()
|
|
||||||
|
|
||||||
# Start processor task
|
|
||||||
asyncio.create_task(self._queue_processor())
|
|
||||||
|
|
||||||
logger.info("Download queue service started")
|
|
||||||
|
|
||||||
# Broadcast queue started event
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_started",
|
|
||||||
{
|
|
||||||
"is_running": True,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Stop the download queue processor."""
|
|
||||||
if not self._is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Stopping download queue service...")
|
|
||||||
|
|
||||||
self._is_running = False
|
|
||||||
self._shutdown_event.set()
|
|
||||||
|
|
||||||
# Wait for active downloads to complete (with timeout)
|
|
||||||
timeout = 30 # seconds
|
|
||||||
start_time = asyncio.get_event_loop().time()
|
|
||||||
|
|
||||||
while (
|
|
||||||
self._active_downloads
|
|
||||||
and (asyncio.get_event_loop().time() - start_time) < timeout
|
|
||||||
):
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
# Save final state
|
|
||||||
self._save_queue()
|
|
||||||
|
|
||||||
# Shutdown executor
|
|
||||||
self._executor.shutdown(wait=True)
|
|
||||||
|
|
||||||
logger.info("Download queue service stopped")
|
|
||||||
|
|
||||||
# Broadcast queue stopped event
|
|
||||||
queue_status = await self.get_queue_status()
|
|
||||||
await self._broadcast_update(
|
|
||||||
"queue_stopped",
|
|
||||||
{
|
|
||||||
"is_running": False,
|
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
_download_service_instance: Optional[DownloadService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_service(anime_service: AnimeService) -> DownloadService:
|
|
||||||
"""Factory function for FastAPI dependency injection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_service: AnimeService instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Singleton DownloadService instance
|
|
||||||
"""
|
|
||||||
global _download_service_instance
|
|
||||||
|
|
||||||
if _download_service_instance is None:
|
|
||||||
_download_service_instance = DownloadService(anime_service)
|
|
||||||
|
|
||||||
return _download_service_instance
|
|
||||||
@ -1,485 +0,0 @@
|
|||||||
"""Progress service for managing real-time progress updates.
|
|
||||||
|
|
||||||
This module provides a centralized service for tracking and broadcasting
|
|
||||||
real-time progress updates for downloads, scans, queue changes, and
|
|
||||||
system events. It integrates with the WebSocket service to push updates
|
|
||||||
to connected clients.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressType(str, Enum):
|
|
||||||
"""Types of progress updates."""
|
|
||||||
|
|
||||||
DOWNLOAD = "download"
|
|
||||||
SCAN = "scan"
|
|
||||||
QUEUE = "queue"
|
|
||||||
SYSTEM = "system"
|
|
||||||
ERROR = "error"
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressStatus(str, Enum):
|
|
||||||
"""Status of a progress operation."""
|
|
||||||
|
|
||||||
STARTED = "started"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProgressUpdate:
|
|
||||||
"""Represents a progress update event.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Unique identifier for this progress operation
|
|
||||||
type: Type of progress (download, scan, etc.)
|
|
||||||
status: Current status of the operation
|
|
||||||
title: Human-readable title
|
|
||||||
message: Detailed message
|
|
||||||
percent: Completion percentage (0-100)
|
|
||||||
current: Current progress value
|
|
||||||
total: Total progress value
|
|
||||||
metadata: Additional metadata
|
|
||||||
started_at: When operation started
|
|
||||||
updated_at: When last updated
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
type: ProgressType
|
|
||||||
status: ProgressStatus
|
|
||||||
title: str
|
|
||||||
message: str = ""
|
|
||||||
percent: float = 0.0
|
|
||||||
current: int = 0
|
|
||||||
total: int = 0
|
|
||||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
started_at: datetime = field(default_factory=datetime.utcnow)
|
|
||||||
updated_at: datetime = field(default_factory=datetime.utcnow)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Convert progress update to dictionary."""
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"type": self.type.value,
|
|
||||||
"status": self.status.value,
|
|
||||||
"title": self.title,
|
|
||||||
"message": self.message,
|
|
||||||
"percent": round(self.percent, 2),
|
|
||||||
"current": self.current,
|
|
||||||
"total": self.total,
|
|
||||||
"metadata": self.metadata,
|
|
||||||
"started_at": self.started_at.isoformat(),
|
|
||||||
"updated_at": self.updated_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressServiceError(Exception):
|
|
||||||
"""Service-level exception for progress operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressService:
|
|
||||||
"""Manages real-time progress updates and broadcasting.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Track multiple concurrent progress operations
|
|
||||||
- Calculate progress percentages and rates
|
|
||||||
- Broadcast updates via WebSocket
|
|
||||||
- Manage progress lifecycle (start, update, complete, fail)
|
|
||||||
- Support for different progress types (download, scan, queue)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the progress service."""
|
|
||||||
# Active progress operations: id -> ProgressUpdate
|
|
||||||
self._active_progress: Dict[str, ProgressUpdate] = {}
|
|
||||||
|
|
||||||
# Completed progress history (limited size)
|
|
||||||
self._history: Dict[str, ProgressUpdate] = {}
|
|
||||||
self._max_history_size = 50
|
|
||||||
|
|
||||||
# WebSocket broadcast callback
|
|
||||||
self._broadcast_callback: Optional[Callable] = None
|
|
||||||
|
|
||||||
# Lock for thread-safe operations
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
logger.info("ProgressService initialized")
|
|
||||||
|
|
||||||
def set_broadcast_callback(self, callback: Callable) -> None:
|
|
||||||
"""Set callback for broadcasting progress updates via WebSocket.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
callback: Async function to call for broadcasting updates
|
|
||||||
"""
|
|
||||||
self._broadcast_callback = callback
|
|
||||||
logger.debug("Progress broadcast callback registered")
|
|
||||||
|
|
||||||
async def _broadcast(self, update: ProgressUpdate, room: str) -> None:
|
|
||||||
"""Broadcast progress update to WebSocket clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
update: Progress update to broadcast
|
|
||||||
room: WebSocket room to broadcast to
|
|
||||||
"""
|
|
||||||
if self._broadcast_callback:
|
|
||||||
try:
|
|
||||||
await self._broadcast_callback(
|
|
||||||
message_type=f"{update.type.value}_progress",
|
|
||||||
data=update.to_dict(),
|
|
||||||
room=room,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast progress update",
|
|
||||||
error=str(e),
|
|
||||||
progress_id=update.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def start_progress(
|
|
||||||
self,
|
|
||||||
progress_id: str,
|
|
||||||
progress_type: ProgressType,
|
|
||||||
title: str,
|
|
||||||
total: int = 0,
|
|
||||||
message: str = "",
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> ProgressUpdate:
|
|
||||||
"""Start a new progress operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Unique identifier for this progress
|
|
||||||
progress_type: Type of progress operation
|
|
||||||
title: Human-readable title
|
|
||||||
total: Total items/bytes to process
|
|
||||||
message: Initial message
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created progress update object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProgressServiceError: If progress already exists
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id in self._active_progress:
|
|
||||||
raise ProgressServiceError(
|
|
||||||
f"Progress with id '{progress_id}' already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
update = ProgressUpdate(
|
|
||||||
id=progress_id,
|
|
||||||
type=progress_type,
|
|
||||||
status=ProgressStatus.STARTED,
|
|
||||||
title=title,
|
|
||||||
message=message,
|
|
||||||
total=total,
|
|
||||||
metadata=metadata or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
self._active_progress[progress_id] = update
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Progress started",
|
|
||||||
progress_id=progress_id,
|
|
||||||
type=progress_type.value,
|
|
||||||
title=title,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast to appropriate room
|
|
||||||
room = f"{progress_type.value}_progress"
|
|
||||||
await self._broadcast(update, room)
|
|
||||||
|
|
||||||
return update
|
|
||||||
|
|
||||||
async def update_progress(
|
|
||||||
self,
|
|
||||||
progress_id: str,
|
|
||||||
current: Optional[int] = None,
|
|
||||||
total: Optional[int] = None,
|
|
||||||
message: Optional[str] = None,
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
force_broadcast: bool = False,
|
|
||||||
) -> ProgressUpdate:
|
|
||||||
"""Update an existing progress operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Progress identifier
|
|
||||||
current: Current progress value
|
|
||||||
total: Updated total value
|
|
||||||
message: Updated message
|
|
||||||
metadata: Additional metadata to merge
|
|
||||||
force_broadcast: Force broadcasting even for small changes
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated progress object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProgressServiceError: If progress not found
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id not in self._active_progress:
|
|
||||||
raise ProgressServiceError(
|
|
||||||
f"Progress with id '{progress_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update = self._active_progress[progress_id]
|
|
||||||
old_percent = update.percent
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
if current is not None:
|
|
||||||
update.current = current
|
|
||||||
if total is not None:
|
|
||||||
update.total = total
|
|
||||||
if message is not None:
|
|
||||||
update.message = message
|
|
||||||
if metadata:
|
|
||||||
update.metadata.update(metadata)
|
|
||||||
|
|
||||||
# Calculate percentage
|
|
||||||
if update.total > 0:
|
|
||||||
update.percent = (update.current / update.total) * 100
|
|
||||||
else:
|
|
||||||
update.percent = 0.0
|
|
||||||
|
|
||||||
update.status = ProgressStatus.IN_PROGRESS
|
|
||||||
update.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
# Only broadcast if significant change or forced
|
|
||||||
percent_change = abs(update.percent - old_percent)
|
|
||||||
should_broadcast = force_broadcast or percent_change >= 1.0
|
|
||||||
|
|
||||||
if should_broadcast:
|
|
||||||
room = f"{update.type.value}_progress"
|
|
||||||
await self._broadcast(update, room)
|
|
||||||
|
|
||||||
return update
|
|
||||||
|
|
||||||
async def complete_progress(
|
|
||||||
self,
|
|
||||||
progress_id: str,
|
|
||||||
message: str = "Completed successfully",
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> ProgressUpdate:
|
|
||||||
"""Mark a progress operation as completed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Progress identifier
|
|
||||||
message: Completion message
|
|
||||||
metadata: Additional metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Completed progress object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProgressServiceError: If progress not found
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id not in self._active_progress:
|
|
||||||
raise ProgressServiceError(
|
|
||||||
f"Progress with id '{progress_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update = self._active_progress[progress_id]
|
|
||||||
update.status = ProgressStatus.COMPLETED
|
|
||||||
update.message = message
|
|
||||||
update.percent = 100.0
|
|
||||||
update.current = update.total
|
|
||||||
update.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
if metadata:
|
|
||||||
update.metadata.update(metadata)
|
|
||||||
|
|
||||||
# Move to history
|
|
||||||
del self._active_progress[progress_id]
|
|
||||||
self._add_to_history(update)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Progress completed",
|
|
||||||
progress_id=progress_id,
|
|
||||||
type=update.type.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast completion
|
|
||||||
room = f"{update.type.value}_progress"
|
|
||||||
await self._broadcast(update, room)
|
|
||||||
|
|
||||||
return update
|
|
||||||
|
|
||||||
async def fail_progress(
|
|
||||||
self,
|
|
||||||
progress_id: str,
|
|
||||||
error_message: str,
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> ProgressUpdate:
|
|
||||||
"""Mark a progress operation as failed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Progress identifier
|
|
||||||
error_message: Error description
|
|
||||||
metadata: Additional error metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Failed progress object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProgressServiceError: If progress not found
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id not in self._active_progress:
|
|
||||||
raise ProgressServiceError(
|
|
||||||
f"Progress with id '{progress_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update = self._active_progress[progress_id]
|
|
||||||
update.status = ProgressStatus.FAILED
|
|
||||||
update.message = error_message
|
|
||||||
update.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
if metadata:
|
|
||||||
update.metadata.update(metadata)
|
|
||||||
|
|
||||||
# Move to history
|
|
||||||
del self._active_progress[progress_id]
|
|
||||||
self._add_to_history(update)
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
"Progress failed",
|
|
||||||
progress_id=progress_id,
|
|
||||||
type=update.type.value,
|
|
||||||
error=error_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast failure
|
|
||||||
room = f"{update.type.value}_progress"
|
|
||||||
await self._broadcast(update, room)
|
|
||||||
|
|
||||||
return update
|
|
||||||
|
|
||||||
async def cancel_progress(
|
|
||||||
self,
|
|
||||||
progress_id: str,
|
|
||||||
message: str = "Cancelled by user",
|
|
||||||
) -> ProgressUpdate:
|
|
||||||
"""Cancel a progress operation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Progress identifier
|
|
||||||
message: Cancellation message
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cancelled progress object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProgressServiceError: If progress not found
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id not in self._active_progress:
|
|
||||||
raise ProgressServiceError(
|
|
||||||
f"Progress with id '{progress_id}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
update = self._active_progress[progress_id]
|
|
||||||
update.status = ProgressStatus.CANCELLED
|
|
||||||
update.message = message
|
|
||||||
update.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
# Move to history
|
|
||||||
del self._active_progress[progress_id]
|
|
||||||
self._add_to_history(update)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Progress cancelled",
|
|
||||||
progress_id=progress_id,
|
|
||||||
type=update.type.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast cancellation
|
|
||||||
room = f"{update.type.value}_progress"
|
|
||||||
await self._broadcast(update, room)
|
|
||||||
|
|
||||||
return update
|
|
||||||
|
|
||||||
def _add_to_history(self, update: ProgressUpdate) -> None:
|
|
||||||
"""Add completed progress to history with size limit."""
|
|
||||||
self._history[update.id] = update
|
|
||||||
|
|
||||||
# Maintain history size limit
|
|
||||||
if len(self._history) > self._max_history_size:
|
|
||||||
# Remove oldest entries
|
|
||||||
oldest_keys = sorted(
|
|
||||||
self._history.keys(),
|
|
||||||
key=lambda k: self._history[k].updated_at,
|
|
||||||
)[: len(self._history) - self._max_history_size]
|
|
||||||
|
|
||||||
for key in oldest_keys:
|
|
||||||
del self._history[key]
|
|
||||||
|
|
||||||
async def get_progress(self, progress_id: str) -> Optional[ProgressUpdate]:
|
|
||||||
"""Get current progress state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_id: Progress identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Progress update object or None if not found
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_id in self._active_progress:
|
|
||||||
return self._active_progress[progress_id]
|
|
||||||
if progress_id in self._history:
|
|
||||||
return self._history[progress_id]
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_all_active_progress(
|
|
||||||
self, progress_type: Optional[ProgressType] = None
|
|
||||||
) -> Dict[str, ProgressUpdate]:
|
|
||||||
"""Get all active progress operations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_type: Optional filter by progress type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of progress_id -> ProgressUpdate
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if progress_type:
|
|
||||||
return {
|
|
||||||
pid: update
|
|
||||||
for pid, update in self._active_progress.items()
|
|
||||||
if update.type == progress_type
|
|
||||||
}
|
|
||||||
return self._active_progress.copy()
|
|
||||||
|
|
||||||
async def clear_history(self) -> None:
|
|
||||||
"""Clear progress history."""
|
|
||||||
async with self._lock:
|
|
||||||
self._history.clear()
|
|
||||||
logger.info("Progress history cleared")
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton instance
|
|
||||||
_progress_service: Optional[ProgressService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_progress_service() -> ProgressService:
|
|
||||||
"""Get or create the global progress service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Global ProgressService instance
|
|
||||||
"""
|
|
||||||
global _progress_service
|
|
||||||
if _progress_service is None:
|
|
||||||
_progress_service = ProgressService()
|
|
||||||
return _progress_service
|
|
||||||
@ -1,461 +0,0 @@
|
|||||||
"""WebSocket service for real-time communication with clients.
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, List, Optional, Set
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketServiceError(Exception):
|
|
||||||
"""Service-level exception for WebSocket operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
|
||||||
"""Manages WebSocket connections with room-based messaging support.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Connection lifecycle management
|
|
||||||
- Room-based messaging (rooms for specific topics)
|
|
||||||
- Broadcast to all connections or specific rooms
|
|
||||||
- Connection health monitoring
|
|
||||||
- Automatic cleanup on disconnect
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the connection manager."""
|
|
||||||
# Active connections: connection_id -> WebSocket
|
|
||||||
self._active_connections: Dict[str, WebSocket] = {}
|
|
||||||
|
|
||||||
# Room memberships: room_name -> set of connection_ids
|
|
||||||
self._rooms: Dict[str, Set[str]] = defaultdict(set)
|
|
||||||
|
|
||||||
# Connection metadata: connection_id -> metadata dict
|
|
||||||
self._connection_metadata: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
# Lock for thread-safe operations
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
logger.info("ConnectionManager initialized")
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
self,
|
|
||||||
websocket: WebSocket,
|
|
||||||
connection_id: str,
|
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Accept and register a new WebSocket connection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
websocket: The WebSocket connection to accept
|
|
||||||
connection_id: Unique identifier for this connection
|
|
||||||
metadata: Optional metadata to associate with the connection
|
|
||||||
"""
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
async with self._lock:
|
|
||||||
self._active_connections[connection_id] = websocket
|
|
||||||
self._connection_metadata[connection_id] = metadata or {}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"WebSocket connected",
|
|
||||||
connection_id=connection_id,
|
|
||||||
total_connections=len(self._active_connections),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def disconnect(self, connection_id: str) -> None:
|
|
||||||
"""Remove a WebSocket connection and cleanup associated resources.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
connection_id: The connection to remove
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
# Remove from all rooms
|
|
||||||
for room_members in self._rooms.values():
|
|
||||||
room_members.discard(connection_id)
|
|
||||||
|
|
||||||
# Remove empty rooms
|
|
||||||
self._rooms = {
|
|
||||||
room: members
|
|
||||||
for room, members in self._rooms.items()
|
|
||||||
if members
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove connection and metadata
|
|
||||||
self._active_connections.pop(connection_id, None)
|
|
||||||
self._connection_metadata.pop(connection_id, None)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"WebSocket disconnected",
|
|
||||||
connection_id=connection_id,
|
|
||||||
total_connections=len(self._active_connections),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def join_room(self, connection_id: str, room: str) -> None:
|
|
||||||
"""Add a connection to a room.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
connection_id: The connection to add
|
|
||||||
room: The room name to join
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if connection_id in self._active_connections:
|
|
||||||
self._rooms[room].add(connection_id)
|
|
||||||
logger.debug(
|
|
||||||
"Connection joined room",
|
|
||||||
connection_id=connection_id,
|
|
||||||
room=room,
|
|
||||||
room_size=len(self._rooms[room]),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Attempted to join room with inactive connection",
|
|
||||||
connection_id=connection_id,
|
|
||||||
room=room,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def leave_room(self, connection_id: str, room: str) -> None:
|
|
||||||
"""Remove a connection from a room.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
connection_id: The connection to remove
|
|
||||||
room: The room name to leave
|
|
||||||
"""
|
|
||||||
async with self._lock:
|
|
||||||
if room in self._rooms:
|
|
||||||
self._rooms[room].discard(connection_id)
|
|
||||||
|
|
||||||
# Remove empty room
|
|
||||||
if not self._rooms[room]:
|
|
||||||
del self._rooms[room]
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Connection left room",
|
|
||||||
connection_id=connection_id,
|
|
||||||
room=room,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_personal_message(
|
|
||||||
self, message: Dict[str, Any], connection_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Send a message to a specific connection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: The message to send (will be JSON serialized)
|
|
||||||
connection_id: Target connection identifier
|
|
||||||
"""
|
|
||||||
websocket = self._active_connections.get(connection_id)
|
|
||||||
if websocket:
|
|
||||||
try:
|
|
||||||
await websocket.send_json(message)
|
|
||||||
logger.debug(
|
|
||||||
"Personal message sent",
|
|
||||||
connection_id=connection_id,
|
|
||||||
message_type=message.get("type", "unknown"),
|
|
||||||
)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
logger.warning(
|
|
||||||
"Connection disconnected during send",
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
await self.disconnect(connection_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to send personal message",
|
|
||||||
connection_id=connection_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Attempted to send message to inactive connection",
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def broadcast(
|
|
||||||
self, message: Dict[str, Any], exclude: Optional[Set[str]] = None
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast a message to all active connections.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: The message to broadcast (will be JSON serialized)
|
|
||||||
exclude: Optional set of connection IDs to exclude from broadcast
|
|
||||||
"""
|
|
||||||
exclude = exclude or set()
|
|
||||||
disconnected = []
|
|
||||||
|
|
||||||
for connection_id, websocket in self._active_connections.items():
|
|
||||||
if connection_id in exclude:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
await websocket.send_json(message)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
logger.warning(
|
|
||||||
"Connection disconnected during broadcast",
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
disconnected.append(connection_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast to connection",
|
|
||||||
connection_id=connection_id,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cleanup disconnected connections
|
|
||||||
for connection_id in disconnected:
|
|
||||||
await self.disconnect(connection_id)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Message broadcast",
|
|
||||||
message_type=message.get("type", "unknown"),
|
|
||||||
recipient_count=len(self._active_connections) - len(exclude),
|
|
||||||
failed_count=len(disconnected),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def broadcast_to_room(
|
|
||||||
self, message: Dict[str, Any], room: str
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast a message to all connections in a specific room.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: The message to broadcast (will be JSON serialized)
|
|
||||||
room: The room to broadcast to
|
|
||||||
"""
|
|
||||||
room_members = self._rooms.get(room, set()).copy()
|
|
||||||
disconnected = []
|
|
||||||
|
|
||||||
for connection_id in room_members:
|
|
||||||
websocket = self._active_connections.get(connection_id)
|
|
||||||
if not websocket:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
await websocket.send_json(message)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
logger.warning(
|
|
||||||
"Connection disconnected during room broadcast",
|
|
||||||
connection_id=connection_id,
|
|
||||||
room=room,
|
|
||||||
)
|
|
||||||
disconnected.append(connection_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to broadcast to room member",
|
|
||||||
connection_id=connection_id,
|
|
||||||
room=room,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cleanup disconnected connections
|
|
||||||
for connection_id in disconnected:
|
|
||||||
await self.disconnect(connection_id)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Message broadcast to room",
|
|
||||||
room=room,
|
|
||||||
message_type=message.get("type", "unknown"),
|
|
||||||
recipient_count=len(room_members),
|
|
||||||
failed_count=len(disconnected),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_connection_count(self) -> int:
|
|
||||||
"""Get the total number of active connections."""
|
|
||||||
return len(self._active_connections)
|
|
||||||
|
|
||||||
async def get_room_members(self, room: str) -> List[str]:
|
|
||||||
"""Get list of connection IDs in a specific room."""
|
|
||||||
return list(self._rooms.get(room, set()))
|
|
||||||
|
|
||||||
async def get_connection_metadata(
|
|
||||||
self, connection_id: str
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get metadata associated with a connection."""
|
|
||||||
return self._connection_metadata.get(connection_id)
|
|
||||||
|
|
||||||
async def update_connection_metadata(
|
|
||||||
self, connection_id: str, metadata: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Update metadata for a connection."""
|
|
||||||
if connection_id in self._active_connections:
|
|
||||||
async with self._lock:
|
|
||||||
self._connection_metadata[connection_id].update(metadata)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Attempted to update metadata for inactive connection",
|
|
||||||
connection_id=connection_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketService:
|
|
||||||
"""High-level WebSocket service for application-wide messaging.
|
|
||||||
|
|
||||||
This service provides a convenient interface for broadcasting
|
|
||||||
application events and managing WebSocket connections. It wraps
|
|
||||||
the ConnectionManager with application-specific message types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the WebSocket service."""
|
|
||||||
self._manager = ConnectionManager()
|
|
||||||
logger.info("WebSocketService initialized")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def manager(self) -> ConnectionManager:
|
|
||||||
"""Access the underlying connection manager."""
|
|
||||||
return self._manager
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
self,
|
|
||||||
websocket: WebSocket,
|
|
||||||
connection_id: str,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Connect a new WebSocket client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
websocket: The WebSocket connection
|
|
||||||
connection_id: Unique connection identifier
|
|
||||||
user_id: Optional user identifier for authentication
|
|
||||||
"""
|
|
||||||
metadata = {
|
|
||||||
"connected_at": datetime.utcnow().isoformat(),
|
|
||||||
"user_id": user_id,
|
|
||||||
}
|
|
||||||
await self._manager.connect(websocket, connection_id, metadata)
|
|
||||||
|
|
||||||
async def disconnect(self, connection_id: str) -> None:
|
|
||||||
"""Disconnect a WebSocket client."""
|
|
||||||
await self._manager.disconnect(connection_id)
|
|
||||||
|
|
||||||
async def broadcast_download_progress(
|
|
||||||
self, download_id: str, progress_data: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast download progress update to all clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
download_id: The download item identifier
|
|
||||||
progress_data: Progress information (percent, speed, etc.)
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": "download_progress",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": {
|
|
||||||
"download_id": download_id,
|
|
||||||
**progress_data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await self._manager.broadcast_to_room(message, "downloads")
|
|
||||||
|
|
||||||
async def broadcast_download_complete(
|
|
||||||
self, download_id: str, result_data: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast download completion to all clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
download_id: The download item identifier
|
|
||||||
result_data: Download result information
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": "download_complete",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": {
|
|
||||||
"download_id": download_id,
|
|
||||||
**result_data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await self._manager.broadcast_to_room(message, "downloads")
|
|
||||||
|
|
||||||
async def broadcast_download_failed(
|
|
||||||
self, download_id: str, error_data: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast download failure to all clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
download_id: The download item identifier
|
|
||||||
error_data: Error information
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": "download_failed",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": {
|
|
||||||
"download_id": download_id,
|
|
||||||
**error_data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await self._manager.broadcast_to_room(message, "downloads")
|
|
||||||
|
|
||||||
async def broadcast_queue_status(self, status_data: Dict[str, Any]) -> None:
|
|
||||||
"""Broadcast queue status update to all clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status_data: Queue status information
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": "queue_status",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": status_data,
|
|
||||||
}
|
|
||||||
await self._manager.broadcast_to_room(message, "downloads")
|
|
||||||
|
|
||||||
async def broadcast_system_message(
|
|
||||||
self, message_type: str, data: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Broadcast a system message to all clients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_type: Type of system message
|
|
||||||
data: Message data
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": f"system_{message_type}",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
await self._manager.broadcast(message)
|
|
||||||
|
|
||||||
async def send_error(
|
|
||||||
self, connection_id: str, error_message: str, error_code: str = "ERROR"
|
|
||||||
) -> None:
|
|
||||||
"""Send an error message to a specific connection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
connection_id: Target connection
|
|
||||||
error_message: Error description
|
|
||||||
error_code: Error code for client handling
|
|
||||||
"""
|
|
||||||
message = {
|
|
||||||
"type": "error",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"data": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": error_message,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await self._manager.send_personal_message(message, connection_id)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance for application-wide access
|
|
||||||
_websocket_service: Optional[WebSocketService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_websocket_service() -> WebSocketService:
|
|
||||||
"""Get or create the singleton WebSocket service instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The WebSocket service instance
|
|
||||||
"""
|
|
||||||
global _websocket_service
|
|
||||||
if _websocket_service is None:
|
|
||||||
_websocket_service = WebSocketService()
|
|
||||||
return _websocket_service
|
|
||||||
@ -2,18 +2,14 @@
|
|||||||
Dependency injection utilities for FastAPI.
|
Dependency injection utilities for FastAPI.
|
||||||
|
|
||||||
This module provides dependency injection functions for the FastAPI
|
This module provides dependency injection functions for the FastAPI
|
||||||
application, including SeriesApp instances, AnimeService, DownloadService,
|
application, including SeriesApp instances, database sessions, and
|
||||||
database sessions, and authentication dependencies.
|
authentication dependencies.
|
||||||
"""
|
"""
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
try:
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
except Exception: # pragma: no cover - optional dependency
|
|
||||||
AsyncSession = object
|
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
@ -26,10 +22,6 @@ security = HTTPBearer()
|
|||||||
# Global SeriesApp instance
|
# Global SeriesApp instance
|
||||||
_series_app: Optional[SeriesApp] = None
|
_series_app: Optional[SeriesApp] = None
|
||||||
|
|
||||||
# Global service instances
|
|
||||||
_anime_service: Optional[object] = None
|
|
||||||
_download_service: Optional[object] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_series_app() -> SeriesApp:
|
def get_series_app() -> SeriesApp:
|
||||||
"""
|
"""
|
||||||
@ -154,26 +146,6 @@ def optional_auth(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_optional(
|
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
|
||||||
HTTPBearer(auto_error=False)
|
|
||||||
)
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Dependency to get optional current user ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
credentials: Optional JWT token from Authorization header
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[str]: User ID if authenticated, None otherwise
|
|
||||||
"""
|
|
||||||
user_dict = optional_auth(credentials)
|
|
||||||
if user_dict:
|
|
||||||
return user_dict.get("user_id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class CommonQueryParams:
|
class CommonQueryParams:
|
||||||
"""Common query parameters for API endpoints."""
|
"""Common query parameters for API endpoints."""
|
||||||
|
|
||||||
@ -217,106 +189,3 @@ async def log_request_dependency():
|
|||||||
TODO: Implement request logging logic
|
TODO: Implement request logging logic
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_anime_service() -> object:
|
|
||||||
"""
|
|
||||||
Dependency to get AnimeService instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AnimeService: The anime service for async operations
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If anime directory is not configured or
|
|
||||||
AnimeService initialization fails
|
|
||||||
"""
|
|
||||||
global _anime_service
|
|
||||||
|
|
||||||
if not settings.anime_directory:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Anime directory not configured. Please complete setup.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if _anime_service is None:
|
|
||||||
try:
|
|
||||||
from src.server.services.anime_service import AnimeService
|
|
||||||
_anime_service = AnimeService(settings.anime_directory)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to initialize AnimeService: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return _anime_service
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_service() -> object:
|
|
||||||
"""
|
|
||||||
Dependency to get DownloadService instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DownloadService: The download queue service
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If DownloadService initialization fails
|
|
||||||
"""
|
|
||||||
global _download_service
|
|
||||||
|
|
||||||
if _download_service is None:
|
|
||||||
try:
|
|
||||||
from src.server.services.download_service import DownloadService
|
|
||||||
from src.server.services.websocket_service import get_websocket_service
|
|
||||||
|
|
||||||
# Get anime service first (required dependency)
|
|
||||||
anime_service = get_anime_service()
|
|
||||||
|
|
||||||
# Initialize download service with anime service
|
|
||||||
_download_service = DownloadService(anime_service)
|
|
||||||
|
|
||||||
# Setup WebSocket broadcast callback
|
|
||||||
ws_service = get_websocket_service()
|
|
||||||
|
|
||||||
async def broadcast_callback(update_type: str, data: dict):
|
|
||||||
"""Broadcast download updates via WebSocket."""
|
|
||||||
if update_type == "download_progress":
|
|
||||||
await ws_service.broadcast_download_progress(
|
|
||||||
data.get("download_id", ""), data
|
|
||||||
)
|
|
||||||
elif update_type == "download_complete":
|
|
||||||
await ws_service.broadcast_download_complete(
|
|
||||||
data.get("download_id", ""), data
|
|
||||||
)
|
|
||||||
elif update_type == "download_failed":
|
|
||||||
await ws_service.broadcast_download_failed(
|
|
||||||
data.get("download_id", ""), data
|
|
||||||
)
|
|
||||||
elif update_type == "queue_status":
|
|
||||||
await ws_service.broadcast_queue_status(data)
|
|
||||||
else:
|
|
||||||
# Generic queue update
|
|
||||||
await ws_service.broadcast_queue_status(data)
|
|
||||||
|
|
||||||
_download_service.set_broadcast_callback(broadcast_callback)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to initialize DownloadService: {str(e)}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return _download_service
|
|
||||||
|
|
||||||
|
|
||||||
def reset_anime_service() -> None:
|
|
||||||
"""Reset global AnimeService instance (for testing/config changes)."""
|
|
||||||
global _anime_service
|
|
||||||
_anime_service = None
|
|
||||||
|
|
||||||
|
|
||||||
def reset_download_service() -> None:
|
|
||||||
"""Reset global DownloadService instance (for testing/config changes)."""
|
|
||||||
global _download_service
|
|
||||||
_download_service = None
|
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
"""
|
|
||||||
Template integration utilities for FastAPI application.
|
|
||||||
|
|
||||||
This module provides utilities for template rendering with common context
|
|
||||||
and helper functions.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
# Configure templates directory
|
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_context(
|
|
||||||
request: Request, title: str = "Aniworld"
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get base context for all templates.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: FastAPI request object
|
|
||||||
title: Page title
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with base context variables
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"request": request,
|
|
||||||
"title": title,
|
|
||||||
"app_name": "Aniworld Download Manager",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_template(
|
|
||||||
template_name: str,
|
|
||||||
request: Request,
|
|
||||||
context: Optional[Dict[str, Any]] = None,
|
|
||||||
title: Optional[str] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Render a template with base context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_name: Name of the template file
|
|
||||||
request: FastAPI request object
|
|
||||||
context: Additional context variables
|
|
||||||
title: Page title (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TemplateResponse object
|
|
||||||
"""
|
|
||||||
base_context = get_base_context(
|
|
||||||
request,
|
|
||||||
title or template_name.replace('.html', '').replace('_', ' ').title()
|
|
||||||
)
|
|
||||||
|
|
||||||
if context:
|
|
||||||
base_context.update(context)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(template_name, base_context)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_template_exists(template_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a template file exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_name: Name of the template file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if template exists, False otherwise
|
|
||||||
"""
|
|
||||||
template_path = TEMPLATES_DIR / template_name
|
|
||||||
return template_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def list_available_templates() -> list[str]:
|
|
||||||
"""
|
|
||||||
Get list of all available template files.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of template file names
|
|
||||||
"""
|
|
||||||
if not TEMPLATES_DIR.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
f.name
|
|
||||||
for f in TEMPLATES_DIR.glob("*.html")
|
|
||||||
if f.is_file()
|
|
||||||
]
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
/**
|
|
||||||
* UX Features CSS
|
|
||||||
* Additional styling for enhanced user experience features
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Drag and drop indicators */
|
|
||||||
.drag-over {
|
|
||||||
border: 2px dashed var(--color-accent);
|
|
||||||
background-color: var(--color-bg-tertiary);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bulk operation selection */
|
|
||||||
.bulk-select-mode .series-card {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-select-mode .series-card.selected {
|
|
||||||
border: 2px solid var(--color-accent);
|
|
||||||
background-color: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keyboard navigation focus indicators */
|
|
||||||
.keyboard-focus {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch gestures feedback */
|
|
||||||
.touch-feedback {
|
|
||||||
animation: touchPulse 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes touchPulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive enhancements */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.mobile-hide {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-full-width {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility high contrast mode */
|
|
||||||
@media (prefers-contrast: high) {
|
|
||||||
:root {
|
|
||||||
--color-border: #000000;
|
|
||||||
--color-text-primary: #000000;
|
|
||||||
--color-bg-primary: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-border: #ffffff;
|
|
||||||
--color-text-primary: #ffffff;
|
|
||||||
--color-bg-primary: #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen reader only content */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Multi-screen support */
|
|
||||||
.window-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-control-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-control-btn:hover {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Undo/Redo notification */
|
|
||||||
.undo-notification {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
z-index: 1000;
|
|
||||||
animation: slideInUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced search panel */
|
|
||||||
.advanced-search-panel {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-search-panel.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading states */
|
|
||||||
.loading-skeleton {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-bg-tertiary) 25%,
|
|
||||||
var(--color-surface-hover) 50%,
|
|
||||||
var(--color-bg-tertiary) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading {
|
|
||||||
0% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip enhancements */
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-caption);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
z-index: 1000;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduced motion support */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Accessibility Features Module
|
|
||||||
* Enhances accessibility for all users
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize accessibility features
|
|
||||||
*/
|
|
||||||
function initAccessibilityFeatures() {
|
|
||||||
setupFocusManagement();
|
|
||||||
setupAriaLabels();
|
|
||||||
console.log('[Accessibility Features] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup focus management
|
|
||||||
*/
|
|
||||||
function setupFocusManagement() {
|
|
||||||
// Add focus visible class for keyboard navigation
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
document.body.classList.add('keyboard-navigation');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', () => {
|
|
||||||
document.body.classList.remove('keyboard-navigation');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup ARIA labels for dynamic content
|
|
||||||
*/
|
|
||||||
function setupAriaLabels() {
|
|
||||||
// Ensure all interactive elements have proper ARIA labels
|
|
||||||
const buttons = document.querySelectorAll('button:not([aria-label])');
|
|
||||||
buttons.forEach(button => {
|
|
||||||
if (!button.getAttribute('aria-label') && button.title) {
|
|
||||||
button.setAttribute('aria-label', button.title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Announce message to screen readers
|
|
||||||
*/
|
|
||||||
function announceToScreenReader(message, priority = 'polite') {
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('role', 'status');
|
|
||||||
announcement.setAttribute('aria-live', priority);
|
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
|
||||||
announcement.className = 'sr-only';
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.Accessibility = {
|
|
||||||
announce: announceToScreenReader
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initAccessibilityFeatures);
|
|
||||||
} else {
|
|
||||||
initAccessibilityFeatures();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Advanced Search Module
|
|
||||||
* Provides advanced search and filtering capabilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize advanced search
|
|
||||||
*/
|
|
||||||
function initAdvancedSearch() {
|
|
||||||
console.log('[Advanced Search] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement advanced search features
|
|
||||||
// - Filter by genre
|
|
||||||
// - Filter by year
|
|
||||||
// - Filter by status
|
|
||||||
// - Sort options
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initAdvancedSearch);
|
|
||||||
} else {
|
|
||||||
initAdvancedSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -133,20 +133,9 @@ class AniWorldApp {
|
|||||||
initSocket() {
|
initSocket() {
|
||||||
this.socket = io();
|
this.socket = io();
|
||||||
|
|
||||||
// Handle initial connection message from server
|
|
||||||
this.socket.on('connected', (data) => {
|
|
||||||
console.log('WebSocket connection confirmed', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
console.log('Connected to server');
|
console.log('Connected to server');
|
||||||
|
|
||||||
// Subscribe to rooms for targeted updates
|
|
||||||
this.socket.join('scan_progress');
|
|
||||||
this.socket.join('download_progress');
|
|
||||||
this.socket.join('downloads');
|
|
||||||
|
|
||||||
this.showToast(this.localization.getText('connected-server'), 'success');
|
this.showToast(this.localization.getText('connected-server'), 'success');
|
||||||
this.updateConnectionStatus();
|
this.updateConnectionStatus();
|
||||||
this.checkProcessLocks();
|
this.checkProcessLocks();
|
||||||
@ -169,24 +158,18 @@ class AniWorldApp {
|
|||||||
this.updateStatus(`Scanning: ${data.folder} (${data.counter})`);
|
this.updateStatus(`Scanning: ${data.folder} (${data.counter})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend)
|
this.socket.on('scan_completed', () => {
|
||||||
const handleScanComplete = () => {
|
|
||||||
this.hideStatus();
|
this.hideStatus();
|
||||||
this.showToast('Scan completed successfully', 'success');
|
this.showToast('Scan completed successfully', 'success');
|
||||||
this.updateProcessStatus('rescan', false);
|
this.updateProcessStatus('rescan', false);
|
||||||
this.loadSeries();
|
this.loadSeries();
|
||||||
};
|
});
|
||||||
this.socket.on('scan_completed', handleScanComplete);
|
|
||||||
this.socket.on('scan_complete', handleScanComplete);
|
|
||||||
|
|
||||||
// Handle both 'scan_error' (legacy) and 'scan_failed' (new backend)
|
this.socket.on('scan_error', (data) => {
|
||||||
const handleScanError = (data) => {
|
|
||||||
this.hideStatus();
|
this.hideStatus();
|
||||||
this.showToast(`Scan error: ${data.message || data.error}`, 'error');
|
this.showToast(`Scan error: ${data.message}`, 'error');
|
||||||
this.updateProcessStatus('rescan', false, true);
|
this.updateProcessStatus('rescan', false, true);
|
||||||
};
|
});
|
||||||
this.socket.on('scan_error', handleScanError);
|
|
||||||
this.socket.on('scan_failed', handleScanError);
|
|
||||||
|
|
||||||
// Scheduled scan events
|
// Scheduled scan events
|
||||||
this.socket.on('scheduled_rescan_started', () => {
|
this.socket.on('scheduled_rescan_started', () => {
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bulk Operations Module
|
|
||||||
* Handles bulk selection and operations on multiple series
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize bulk operations
|
|
||||||
*/
|
|
||||||
function initBulkOperations() {
|
|
||||||
console.log('[Bulk Operations] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement bulk operations
|
|
||||||
// - Select multiple series
|
|
||||||
// - Bulk download
|
|
||||||
// - Bulk mark as watched
|
|
||||||
// - Bulk delete
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initBulkOperations);
|
|
||||||
} else {
|
|
||||||
initBulkOperations();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Color Contrast Compliance Module
|
|
||||||
* Ensures WCAG color contrast compliance
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize color contrast compliance
|
|
||||||
*/
|
|
||||||
function initColorContrastCompliance() {
|
|
||||||
checkContrastCompliance();
|
|
||||||
console.log('[Color Contrast Compliance] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if color contrast meets WCAG standards
|
|
||||||
*/
|
|
||||||
function checkContrastCompliance() {
|
|
||||||
// This would typically check computed styles
|
|
||||||
// For now, we rely on CSS variables defined in styles.css
|
|
||||||
console.log('[Color Contrast] Relying on predefined WCAG-compliant color scheme');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate contrast ratio between two colors
|
|
||||||
*/
|
|
||||||
function calculateContrastRatio(color1, color2) {
|
|
||||||
// Simplified contrast calculation
|
|
||||||
// Real implementation would use relative luminance
|
|
||||||
return 4.5; // Placeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initColorContrastCompliance);
|
|
||||||
} else {
|
|
||||||
initColorContrastCompliance();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drag and Drop Module
|
|
||||||
* Handles drag-and-drop functionality for series cards
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize drag and drop
|
|
||||||
*/
|
|
||||||
function initDragDrop() {
|
|
||||||
console.log('[Drag & Drop] Module loaded (functionality to be implemented)');
|
|
||||||
|
|
||||||
// TODO: Implement drag-and-drop for series cards
|
|
||||||
// This will allow users to reorder series or add to queue via drag-and-drop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initDragDrop);
|
|
||||||
} else {
|
|
||||||
initDragDrop();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* Keyboard Shortcuts Module
|
|
||||||
* Handles keyboard navigation and shortcuts for improved accessibility
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Keyboard shortcuts configuration
|
|
||||||
const shortcuts = {
|
|
||||||
'ctrl+k': 'focusSearch',
|
|
||||||
'ctrl+r': 'triggerRescan',
|
|
||||||
'ctrl+q': 'openQueue',
|
|
||||||
'escape': 'closeModals',
|
|
||||||
'tab': 'navigationMode',
|
|
||||||
'/': 'focusSearch'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize keyboard shortcuts
|
|
||||||
*/
|
|
||||||
function initKeyboardShortcuts() {
|
|
||||||
document.addEventListener('keydown', handleKeydown);
|
|
||||||
console.log('[Keyboard Shortcuts] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle keydown events
|
|
||||||
*/
|
|
||||||
function handleKeydown(event) {
|
|
||||||
const key = getKeyCombo(event);
|
|
||||||
|
|
||||||
if (shortcuts[key]) {
|
|
||||||
const action = shortcuts[key];
|
|
||||||
handleShortcut(action, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get key combination string
|
|
||||||
*/
|
|
||||||
function getKeyCombo(event) {
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
if (event.ctrlKey) parts.push('ctrl');
|
|
||||||
if (event.altKey) parts.push('alt');
|
|
||||||
if (event.shiftKey) parts.push('shift');
|
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
parts.push(key);
|
|
||||||
|
|
||||||
return parts.join('+');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle keyboard shortcut action
|
|
||||||
*/
|
|
||||||
function handleShortcut(action, event) {
|
|
||||||
switch(action) {
|
|
||||||
case 'focusSearch':
|
|
||||||
event.preventDefault();
|
|
||||||
focusSearchInput();
|
|
||||||
break;
|
|
||||||
case 'triggerRescan':
|
|
||||||
event.preventDefault();
|
|
||||||
triggerRescan();
|
|
||||||
break;
|
|
||||||
case 'openQueue':
|
|
||||||
event.preventDefault();
|
|
||||||
openQueue();
|
|
||||||
break;
|
|
||||||
case 'closeModals':
|
|
||||||
closeAllModals();
|
|
||||||
break;
|
|
||||||
case 'navigationMode':
|
|
||||||
handleTabNavigation(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Focus search input
|
|
||||||
*/
|
|
||||||
function focusSearchInput() {
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.focus();
|
|
||||||
searchInput.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger rescan
|
|
||||||
*/
|
|
||||||
function triggerRescan() {
|
|
||||||
const rescanBtn = document.getElementById('rescan-btn');
|
|
||||||
if (rescanBtn && !rescanBtn.disabled) {
|
|
||||||
rescanBtn.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open queue page
|
|
||||||
*/
|
|
||||||
function openQueue() {
|
|
||||||
window.location.href = '/queue';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all open modals
|
|
||||||
*/
|
|
||||||
function closeAllModals() {
|
|
||||||
const modals = document.querySelectorAll('.modal.active');
|
|
||||||
modals.forEach(modal => {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle tab navigation with visual indicators
|
|
||||||
*/
|
|
||||||
function handleTabNavigation(event) {
|
|
||||||
// Add keyboard-focus class to focused element
|
|
||||||
const previousFocus = document.querySelector('.keyboard-focus');
|
|
||||||
if (previousFocus) {
|
|
||||||
previousFocus.classList.remove('keyboard-focus');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will be applied after tab completes
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.activeElement) {
|
|
||||||
document.activeElement.classList.add('keyboard-focus');
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initKeyboardShortcuts);
|
|
||||||
} else {
|
|
||||||
initKeyboardShortcuts();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Responsive Module
|
|
||||||
* Handles mobile-specific functionality and responsive behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let isMobile = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize mobile responsive features
|
|
||||||
*/
|
|
||||||
function initMobileResponsive() {
|
|
||||||
detectMobile();
|
|
||||||
setupResponsiveHandlers();
|
|
||||||
console.log('[Mobile Responsive] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if device is mobile
|
|
||||||
*/
|
|
||||||
function detectMobile() {
|
|
||||||
isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
document.body.classList.add('mobile-device');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup responsive event handlers
|
|
||||||
*/
|
|
||||||
function setupResponsiveHandlers() {
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
handleResize(); // Initial call
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle window resize
|
|
||||||
*/
|
|
||||||
function handleResize() {
|
|
||||||
const width = window.innerWidth;
|
|
||||||
|
|
||||||
if (width < 768) {
|
|
||||||
applyMobileLayout();
|
|
||||||
} else {
|
|
||||||
applyDesktopLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply mobile-specific layout
|
|
||||||
*/
|
|
||||||
function applyMobileLayout() {
|
|
||||||
document.body.classList.add('mobile-layout');
|
|
||||||
document.body.classList.remove('desktop-layout');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply desktop-specific layout
|
|
||||||
*/
|
|
||||||
function applyDesktopLayout() {
|
|
||||||
document.body.classList.add('desktop-layout');
|
|
||||||
document.body.classList.remove('mobile-layout');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.MobileResponsive = {
|
|
||||||
isMobile: () => isMobile
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initMobileResponsive);
|
|
||||||
} else {
|
|
||||||
initMobileResponsive();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Multi-Screen Support Module
|
|
||||||
* Handles multi-monitor and window management
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize multi-screen support
|
|
||||||
*/
|
|
||||||
function initMultiScreenSupport() {
|
|
||||||
if ('screen' in window) {
|
|
||||||
detectScreens();
|
|
||||||
console.log('[Multi-Screen Support] Initialized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect available screens
|
|
||||||
*/
|
|
||||||
function detectScreens() {
|
|
||||||
// Modern browsers support window.screen
|
|
||||||
const screenInfo = {
|
|
||||||
width: window.screen.width,
|
|
||||||
height: window.screen.height,
|
|
||||||
availWidth: window.screen.availWidth,
|
|
||||||
availHeight: window.screen.availHeight,
|
|
||||||
colorDepth: window.screen.colorDepth,
|
|
||||||
pixelDepth: window.screen.pixelDepth
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[Multi-Screen] Screen info:', screenInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request fullscreen
|
|
||||||
*/
|
|
||||||
function requestFullscreen() {
|
|
||||||
const elem = document.documentElement;
|
|
||||||
if (elem.requestFullscreen) {
|
|
||||||
elem.requestFullscreen();
|
|
||||||
} else if (elem.webkitRequestFullscreen) {
|
|
||||||
elem.webkitRequestFullscreen();
|
|
||||||
} else if (elem.msRequestFullscreen) {
|
|
||||||
elem.msRequestFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exit fullscreen
|
|
||||||
*/
|
|
||||||
function exitFullscreen() {
|
|
||||||
if (document.exitFullscreen) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else if (document.webkitExitFullscreen) {
|
|
||||||
document.webkitExitFullscreen();
|
|
||||||
} else if (document.msExitFullscreen) {
|
|
||||||
document.msExitFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.MultiScreen = {
|
|
||||||
requestFullscreen: requestFullscreen,
|
|
||||||
exitFullscreen: exitFullscreen
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initMultiScreenSupport);
|
|
||||||
} else {
|
|
||||||
initMultiScreenSupport();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -22,18 +22,8 @@ class QueueManager {
|
|||||||
initSocket() {
|
initSocket() {
|
||||||
this.socket = io();
|
this.socket = io();
|
||||||
|
|
||||||
// Handle initial connection message from server
|
|
||||||
this.socket.on('connected', (data) => {
|
|
||||||
console.log('WebSocket connection confirmed', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
console.log('Connected to server');
|
console.log('Connected to server');
|
||||||
|
|
||||||
// Subscribe to rooms for targeted updates
|
|
||||||
this.socket.join('downloads');
|
|
||||||
this.socket.join('download_progress');
|
|
||||||
|
|
||||||
this.showToast('Connected to server', 'success');
|
this.showToast('Connected to server', 'success');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -42,18 +32,10 @@ class QueueManager {
|
|||||||
this.showToast('Disconnected from server', 'warning');
|
this.showToast('Disconnected from server', 'warning');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queue update events - handle both old and new message types
|
// Queue update events
|
||||||
this.socket.on('queue_updated', (data) => {
|
this.socket.on('queue_updated', (data) => {
|
||||||
this.updateQueueDisplay(data);
|
this.updateQueueDisplay(data);
|
||||||
});
|
});
|
||||||
this.socket.on('queue_status', (data) => {
|
|
||||||
// New backend sends queue_status messages
|
|
||||||
if (data.queue_status) {
|
|
||||||
this.updateQueueDisplay(data.queue_status);
|
|
||||||
} else {
|
|
||||||
this.updateQueueDisplay(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('download_progress_update', (data) => {
|
this.socket.on('download_progress_update', (data) => {
|
||||||
this.updateDownloadProgress(data);
|
this.updateDownloadProgress(data);
|
||||||
@ -64,33 +46,21 @@ class QueueManager {
|
|||||||
this.showToast('Download queue started', 'success');
|
this.showToast('Download queue started', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
this.loadQueueData(); // Refresh data
|
||||||
});
|
});
|
||||||
this.socket.on('queue_started', () => {
|
|
||||||
this.showToast('Download queue started', 'success');
|
|
||||||
this.loadQueueData(); // Refresh data
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('download_progress', (data) => {
|
this.socket.on('download_progress', (data) => {
|
||||||
this.updateDownloadProgress(data);
|
this.updateDownloadProgress(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle both old and new download completion events
|
this.socket.on('download_completed', (data) => {
|
||||||
const handleDownloadComplete = (data) => {
|
this.showToast(`Completed: ${data.serie} - Episode ${data.episode}`, 'success');
|
||||||
const serieName = data.serie_name || data.serie || 'Unknown';
|
|
||||||
const episode = data.episode || '';
|
|
||||||
this.showToast(`Completed: ${serieName}${episode ? ' - Episode ' + episode : ''}`, 'success');
|
|
||||||
this.loadQueueData(); // Refresh data
|
this.loadQueueData(); // Refresh data
|
||||||
};
|
});
|
||||||
this.socket.on('download_completed', handleDownloadComplete);
|
|
||||||
this.socket.on('download_complete', handleDownloadComplete);
|
|
||||||
|
|
||||||
// Handle both old and new download error events
|
this.socket.on('download_error', (data) => {
|
||||||
const handleDownloadError = (data) => {
|
|
||||||
const message = data.error || data.message || 'Unknown error';
|
const message = data.error || data.message || 'Unknown error';
|
||||||
this.showToast(`Download failed: ${message}`, 'error');
|
this.showToast(`Download failed: ${message}`, 'error');
|
||||||
this.loadQueueData(); // Refresh data
|
this.loadQueueData(); // Refresh data
|
||||||
};
|
});
|
||||||
this.socket.on('download_error', handleDownloadError);
|
|
||||||
this.socket.on('download_failed', handleDownloadError);
|
|
||||||
|
|
||||||
this.socket.on('download_queue_completed', () => {
|
this.socket.on('download_queue_completed', () => {
|
||||||
this.showToast('All downloads completed!', 'success');
|
this.showToast('All downloads completed!', 'success');
|
||||||
@ -101,23 +71,9 @@ class QueueManager {
|
|||||||
this.showToast('Stopping downloads...', 'info');
|
this.showToast('Stopping downloads...', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle both old and new queue stopped events
|
this.socket.on('download_stopped', () => {
|
||||||
const handleQueueStopped = () => {
|
|
||||||
this.showToast('Download queue stopped', 'success');
|
this.showToast('Download queue stopped', 'success');
|
||||||
this.loadQueueData(); // Refresh data
|
this.loadQueueData(); // Refresh data
|
||||||
};
|
|
||||||
this.socket.on('download_stopped', handleQueueStopped);
|
|
||||||
this.socket.on('queue_stopped', handleQueueStopped);
|
|
||||||
|
|
||||||
// Handle queue paused/resumed
|
|
||||||
this.socket.on('queue_paused', () => {
|
|
||||||
this.showToast('Queue paused', 'info');
|
|
||||||
this.loadQueueData();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('queue_resumed', () => {
|
|
||||||
this.showToast('Queue resumed', 'success');
|
|
||||||
this.loadQueueData();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Screen Reader Support Module
|
|
||||||
* Provides enhanced screen reader support
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize screen reader support
|
|
||||||
*/
|
|
||||||
function initScreenReaderSupport() {
|
|
||||||
setupLiveRegions();
|
|
||||||
setupNavigationAnnouncements();
|
|
||||||
console.log('[Screen Reader Support] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup live regions for dynamic content
|
|
||||||
*/
|
|
||||||
function setupLiveRegions() {
|
|
||||||
// Create global live region if it doesn't exist
|
|
||||||
if (!document.getElementById('sr-live-region')) {
|
|
||||||
const liveRegion = document.createElement('div');
|
|
||||||
liveRegion.id = 'sr-live-region';
|
|
||||||
liveRegion.className = 'sr-only';
|
|
||||||
liveRegion.setAttribute('role', 'status');
|
|
||||||
liveRegion.setAttribute('aria-live', 'polite');
|
|
||||||
liveRegion.setAttribute('aria-atomic', 'true');
|
|
||||||
document.body.appendChild(liveRegion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup navigation announcements
|
|
||||||
*/
|
|
||||||
function setupNavigationAnnouncements() {
|
|
||||||
// Announce page navigation
|
|
||||||
const pageTitle = document.title;
|
|
||||||
announceToScreenReader(`Page loaded: ${pageTitle}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Announce message to screen readers
|
|
||||||
*/
|
|
||||||
function announceToScreenReader(message) {
|
|
||||||
const liveRegion = document.getElementById('sr-live-region');
|
|
||||||
if (liveRegion) {
|
|
||||||
liveRegion.textContent = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.ScreenReader = {
|
|
||||||
announce: announceToScreenReader
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initScreenReaderSupport);
|
|
||||||
} else {
|
|
||||||
initScreenReaderSupport();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Touch Gestures Module
|
|
||||||
* Handles touch gestures for mobile devices
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize touch gestures
|
|
||||||
*/
|
|
||||||
function initTouchGestures() {
|
|
||||||
if ('ontouchstart' in window) {
|
|
||||||
setupSwipeGestures();
|
|
||||||
console.log('[Touch Gestures] Initialized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup swipe gesture handlers
|
|
||||||
*/
|
|
||||||
function setupSwipeGestures() {
|
|
||||||
let touchStartX = 0;
|
|
||||||
let touchStartY = 0;
|
|
||||||
let touchEndX = 0;
|
|
||||||
let touchEndY = 0;
|
|
||||||
|
|
||||||
document.addEventListener('touchstart', (e) => {
|
|
||||||
touchStartX = e.changedTouches[0].screenX;
|
|
||||||
touchStartY = e.changedTouches[0].screenY;
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
document.addEventListener('touchend', (e) => {
|
|
||||||
touchEndX = e.changedTouches[0].screenX;
|
|
||||||
touchEndY = e.changedTouches[0].screenY;
|
|
||||||
handleSwipe();
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
function handleSwipe() {
|
|
||||||
const deltaX = touchEndX - touchStartX;
|
|
||||||
const deltaY = touchEndY - touchStartY;
|
|
||||||
const minSwipeDistance = 50;
|
|
||||||
|
|
||||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
||||||
// Horizontal swipe
|
|
||||||
if (Math.abs(deltaX) > minSwipeDistance) {
|
|
||||||
if (deltaX > 0) {
|
|
||||||
// Swipe right
|
|
||||||
console.log('[Touch Gestures] Swipe right detected');
|
|
||||||
} else {
|
|
||||||
// Swipe left
|
|
||||||
console.log('[Touch Gestures] Swipe left detected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initTouchGestures);
|
|
||||||
} else {
|
|
||||||
initTouchGestures();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Undo/Redo Module
|
|
||||||
* Provides undo/redo functionality for user actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const actionHistory = [];
|
|
||||||
let currentIndex = -1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize undo/redo system
|
|
||||||
*/
|
|
||||||
function initUndoRedo() {
|
|
||||||
setupKeyboardShortcuts();
|
|
||||||
console.log('[Undo/Redo] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup keyboard shortcuts for undo/redo
|
|
||||||
*/
|
|
||||||
function setupKeyboardShortcuts() {
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
if (event.key === 'z' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
undo();
|
|
||||||
} else if (event.key === 'z' && event.shiftKey || event.key === 'y') {
|
|
||||||
event.preventDefault();
|
|
||||||
redo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add action to history
|
|
||||||
*/
|
|
||||||
function addAction(action) {
|
|
||||||
// Remove any actions after current index
|
|
||||||
actionHistory.splice(currentIndex + 1);
|
|
||||||
|
|
||||||
// Add new action
|
|
||||||
actionHistory.push(action);
|
|
||||||
currentIndex++;
|
|
||||||
|
|
||||||
// Limit history size
|
|
||||||
if (actionHistory.length > 50) {
|
|
||||||
actionHistory.shift();
|
|
||||||
currentIndex--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo last action
|
|
||||||
*/
|
|
||||||
function undo() {
|
|
||||||
if (currentIndex >= 0) {
|
|
||||||
const action = actionHistory[currentIndex];
|
|
||||||
if (action && action.undo) {
|
|
||||||
action.undo();
|
|
||||||
currentIndex--;
|
|
||||||
showNotification('Action undone');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redo last undone action
|
|
||||||
*/
|
|
||||||
function redo() {
|
|
||||||
if (currentIndex < actionHistory.length - 1) {
|
|
||||||
currentIndex++;
|
|
||||||
const action = actionHistory[currentIndex];
|
|
||||||
if (action && action.redo) {
|
|
||||||
action.redo();
|
|
||||||
showNotification('Action redone');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show undo/redo notification
|
|
||||||
*/
|
|
||||||
function showNotification(message) {
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'undo-notification';
|
|
||||||
notification.textContent = message;
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.remove();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.UndoRedo = {
|
|
||||||
add: addAction,
|
|
||||||
undo: undo,
|
|
||||||
redo: redo
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initUndoRedo);
|
|
||||||
} else {
|
|
||||||
initUndoRedo();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* User Preferences Module
|
|
||||||
* Manages user preferences and settings persistence
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'aniworld_preferences';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize user preferences
|
|
||||||
*/
|
|
||||||
function initUserPreferences() {
|
|
||||||
loadPreferences();
|
|
||||||
console.log('[User Preferences] Initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load preferences from localStorage
|
|
||||||
*/
|
|
||||||
function loadPreferences() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
const preferences = JSON.parse(stored);
|
|
||||||
applyPreferences(preferences);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[User Preferences] Error loading:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save preferences to localStorage
|
|
||||||
*/
|
|
||||||
function savePreferences(preferences) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[User Preferences] Error saving:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply preferences to the application
|
|
||||||
*/
|
|
||||||
function applyPreferences(preferences) {
|
|
||||||
if (preferences.theme) {
|
|
||||||
document.documentElement.setAttribute('data-theme', preferences.theme);
|
|
||||||
}
|
|
||||||
if (preferences.language) {
|
|
||||||
// Language preference would be applied here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preferences
|
|
||||||
*/
|
|
||||||
function getPreferences() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : {};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[User Preferences] Error getting preferences:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update specific preference
|
|
||||||
*/
|
|
||||||
function updatePreference(key, value) {
|
|
||||||
const preferences = getPreferences();
|
|
||||||
preferences[key] = value;
|
|
||||||
savePreferences(preferences);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
window.UserPreferences = {
|
|
||||||
load: loadPreferences,
|
|
||||||
save: savePreferences,
|
|
||||||
get: getPreferences,
|
|
||||||
update: updatePreference
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initUserPreferences);
|
|
||||||
} else {
|
|
||||||
initUserPreferences();
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* Native WebSocket Client Wrapper
|
|
||||||
* Provides Socket.IO-like interface using native WebSocket API
|
|
||||||
*
|
|
||||||
* This wrapper maintains compatibility with existing Socket.IO-style
|
|
||||||
* event handlers while using the modern WebSocket API underneath.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class WebSocketClient {
|
|
||||||
constructor(url = null) {
|
|
||||||
this.ws = null;
|
|
||||||
this.url = url || this.getWebSocketUrl();
|
|
||||||
this.eventHandlers = new Map();
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.maxReconnectAttempts = 5;
|
|
||||||
this.reconnectDelay = 1000;
|
|
||||||
this.isConnected = false;
|
|
||||||
this.rooms = new Set();
|
|
||||||
this.messageQueue = [];
|
|
||||||
this.autoReconnect = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get WebSocket URL based on current page URL
|
|
||||||
*/
|
|
||||||
getWebSocketUrl() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const host = window.location.host;
|
|
||||||
return `${protocol}//${host}/ws/connect`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to WebSocket server
|
|
||||||
*/
|
|
||||||
connect() {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
console.log('WebSocket already connected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.ws = new WebSocket(this.url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
this.isConnected = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
|
|
||||||
// Emit connect event
|
|
||||||
this.emit('connect');
|
|
||||||
|
|
||||||
// Rejoin rooms
|
|
||||||
this.rejoinRooms();
|
|
||||||
|
|
||||||
// Process queued messages
|
|
||||||
this.processMessageQueue();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
this.handleMessage(event.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.emit('error', { error: 'WebSocket connection error' });
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
console.log('WebSocket disconnected', event.code, event.reason);
|
|
||||||
this.isConnected = false;
|
|
||||||
this.emit('disconnect', { code: event.code, reason: event.reason });
|
|
||||||
|
|
||||||
// Attempt reconnection
|
|
||||||
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
|
||||||
console.log(`Attempting reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
||||||
setTimeout(() => this.connect(), delay);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create WebSocket connection:', error);
|
|
||||||
this.emit('error', { error: 'Failed to connect' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from WebSocket server
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this.autoReconnect = false;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close(1000, 'Client disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming WebSocket message
|
|
||||||
*/
|
|
||||||
handleMessage(data) {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(data);
|
|
||||||
const { type, data: payload, timestamp } = message;
|
|
||||||
|
|
||||||
// Emit event with payload
|
|
||||||
if (type) {
|
|
||||||
this.emit(type, payload || {});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register event handler (Socket.IO-style)
|
|
||||||
*/
|
|
||||||
on(event, handler) {
|
|
||||||
if (!this.eventHandlers.has(event)) {
|
|
||||||
this.eventHandlers.set(event, []);
|
|
||||||
}
|
|
||||||
this.eventHandlers.get(event).push(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove event handler
|
|
||||||
*/
|
|
||||||
off(event, handler) {
|
|
||||||
if (!this.eventHandlers.has(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = this.eventHandlers.get(event);
|
|
||||||
const index = handlers.indexOf(handler);
|
|
||||||
if (index !== -1) {
|
|
||||||
handlers.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit event to registered handlers
|
|
||||||
*/
|
|
||||||
emit(event, data = null) {
|
|
||||||
if (!this.eventHandlers.has(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = this.eventHandlers.get(event);
|
|
||||||
handlers.forEach(handler => {
|
|
||||||
try {
|
|
||||||
if (data !== null) {
|
|
||||||
handler(data);
|
|
||||||
} else {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in event handler for ${event}:`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send message to server
|
|
||||||
*/
|
|
||||||
send(type, data = {}) {
|
|
||||||
const message = JSON.stringify({
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(message);
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket not connected, queueing message');
|
|
||||||
this.messageQueue.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Join a room (subscribe to topic)
|
|
||||||
*/
|
|
||||||
join(room) {
|
|
||||||
this.rooms.add(room);
|
|
||||||
if (this.isConnected) {
|
|
||||||
this.send('join', { room });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Leave a room (unsubscribe from topic)
|
|
||||||
*/
|
|
||||||
leave(room) {
|
|
||||||
this.rooms.delete(room);
|
|
||||||
if (this.isConnected) {
|
|
||||||
this.send('leave', { room });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rejoin all rooms after reconnection
|
|
||||||
*/
|
|
||||||
rejoinRooms() {
|
|
||||||
this.rooms.forEach(room => {
|
|
||||||
this.send('join', { room });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process queued messages after connection
|
|
||||||
*/
|
|
||||||
processMessageQueue() {
|
|
||||||
while (this.messageQueue.length > 0 && this.isConnected) {
|
|
||||||
const message = this.messageQueue.shift();
|
|
||||||
this.ws.send(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connected
|
|
||||||
*/
|
|
||||||
connected() {
|
|
||||||
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create global io() function for Socket.IO compatibility
|
|
||||||
*/
|
|
||||||
function io(url = null) {
|
|
||||||
const client = new WebSocketClient(url);
|
|
||||||
client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
@ -455,7 +455,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/websocket_client.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
<script src="/static/js/localization.js"></script>
|
<script src="/static/js/localization.js"></script>
|
||||||
|
|
||||||
<!-- UX Enhancement Scripts -->
|
<!-- UX Enhancement Scripts -->
|
||||||
|
|||||||
@ -245,7 +245,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/websocket_client.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
<script src="/static/js/queue.js"></script>
|
<script src="/static/js/queue.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from src.server.api import anime as anime_module
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSerie:
|
|
||||||
def __init__(self, key, name, folder, episodeDict=None):
|
|
||||||
self.key = key
|
|
||||||
self.name = name
|
|
||||||
self.folder = folder
|
|
||||||
self.episodeDict = episodeDict or {}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSeriesApp:
|
|
||||||
def __init__(self):
|
|
||||||
self.List = self
|
|
||||||
self._items = [
|
|
||||||
FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}),
|
|
||||||
FakeSerie("2", "Complete Show", "complete_show", {}),
|
|
||||||
]
|
|
||||||
|
|
||||||
def GetMissingEpisode(self):
|
|
||||||
return [s for s in self._items if s.episodeDict]
|
|
||||||
|
|
||||||
def GetList(self):
|
|
||||||
return self._items
|
|
||||||
|
|
||||||
def ReScan(self, callback):
|
|
||||||
callback()
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_anime_direct_call():
|
|
||||||
fake = FakeSeriesApp()
|
|
||||||
result = asyncio.run(anime_module.list_anime(series_app=fake))
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert any(item.title == "Test Show" for item in result)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_anime_detail_direct_call():
|
|
||||||
fake = FakeSeriesApp()
|
|
||||||
result = asyncio.run(anime_module.get_anime("1", series_app=fake))
|
|
||||||
assert result.title == "Test Show"
|
|
||||||
assert "1-1" in result.episodes
|
|
||||||
|
|
||||||
|
|
||||||
def test_rescan_direct_call():
|
|
||||||
fake = FakeSeriesApp()
|
|
||||||
result = asyncio.run(anime_module.trigger_rescan(series_app=fake))
|
|
||||||
assert result["success"] is True
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
|
||||||
from src.server.models.config import AppConfig, SchedulerConfig
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_config_public():
|
|
||||||
resp = client.get("/api/config")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert "name" in data
|
|
||||||
assert "data_dir" in data
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_config():
|
|
||||||
cfg = {
|
|
||||||
"name": "Aniworld",
|
|
||||||
"data_dir": "data",
|
|
||||||
"scheduler": {"enabled": True, "interval_minutes": 30},
|
|
||||||
"logging": {"level": "INFO"},
|
|
||||||
"backup": {"enabled": False},
|
|
||||||
"other": {},
|
|
||||||
}
|
|
||||||
resp = client.post("/api/config/validate", json=cfg)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body.get("valid") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_config_unauthorized():
|
|
||||||
# update requires auth; without auth should be 401
|
|
||||||
update = {"scheduler": {"enabled": False}}
|
|
||||||
resp = client.put("/api/config", json=update)
|
|
||||||
assert resp.status_code in (401, 422)
|
|
||||||
@ -1,443 +0,0 @@
|
|||||||
"""Tests for download queue API endpoints."""
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
|
||||||
from src.server.models.download import DownloadPriority, QueueStats, QueueStatus
|
|
||||||
from src.server.services.auth_service import auth_service
|
|
||||||
from src.server.services.download_service import DownloadServiceError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def authenticated_client():
|
|
||||||
"""Create authenticated async client."""
|
|
||||||
# Ensure auth is configured for test
|
|
||||||
if not auth_service.is_configured():
|
|
||||||
auth_service.setup_master_password("TestPass123!")
|
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
# Login to get token
|
|
||||||
r = await client.post(
|
|
||||||
"/api/auth/login", json={"password": "TestPass123!"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
token = r.json()["access_token"]
|
|
||||||
|
|
||||||
# Set authorization header for all requests
|
|
||||||
client.headers["Authorization"] = f"Bearer {token}"
|
|
||||||
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_download_service():
|
|
||||||
"""Mock DownloadService for testing."""
|
|
||||||
with patch(
|
|
||||||
"src.server.utils.dependencies.get_download_service"
|
|
||||||
) as mock:
|
|
||||||
service = MagicMock()
|
|
||||||
|
|
||||||
# Mock queue status
|
|
||||||
service.get_queue_status = AsyncMock(
|
|
||||||
return_value=QueueStatus(
|
|
||||||
is_running=True,
|
|
||||||
is_paused=False,
|
|
||||||
active_downloads=[],
|
|
||||||
pending_queue=[],
|
|
||||||
completed_downloads=[],
|
|
||||||
failed_downloads=[],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock queue stats
|
|
||||||
service.get_queue_stats = AsyncMock(
|
|
||||||
return_value=QueueStats(
|
|
||||||
total_items=0,
|
|
||||||
pending_count=0,
|
|
||||||
active_count=0,
|
|
||||||
completed_count=0,
|
|
||||||
failed_count=0,
|
|
||||||
total_downloaded_mb=0.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock add_to_queue
|
|
||||||
service.add_to_queue = AsyncMock(
|
|
||||||
return_value=["item-id-1", "item-id-2"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock remove_from_queue
|
|
||||||
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
|
|
||||||
|
|
||||||
# Mock reorder_queue
|
|
||||||
service.reorder_queue = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
# Mock start/stop/pause/resume
|
|
||||||
service.start = AsyncMock()
|
|
||||||
service.stop = AsyncMock()
|
|
||||||
service.pause_queue = AsyncMock()
|
|
||||||
service.resume_queue = AsyncMock()
|
|
||||||
|
|
||||||
# Mock clear_completed and retry_failed
|
|
||||||
service.clear_completed = AsyncMock(return_value=5)
|
|
||||||
service.retry_failed = AsyncMock(return_value=["item-id-3"])
|
|
||||||
|
|
||||||
mock.return_value = service
|
|
||||||
yield service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_queue_status(authenticated_client, mock_download_service):
|
|
||||||
"""Test GET /api/queue/status endpoint."""
|
|
||||||
response = await authenticated_client.get("/api/queue/status")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert "status" in data
|
|
||||||
assert "statistics" in data
|
|
||||||
assert data["status"]["is_running"] is True
|
|
||||||
assert data["status"]["is_paused"] is False
|
|
||||||
|
|
||||||
mock_download_service.get_queue_status.assert_called_once()
|
|
||||||
mock_download_service.get_queue_stats.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_queue_status_unauthorized():
|
|
||||||
"""Test GET /api/queue/status without authentication."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
response = await client.get("/api/queue/status")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_add_to_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/add endpoint."""
|
|
||||||
request_data = {
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Test Anime",
|
|
||||||
"episodes": [
|
|
||||||
{"season": 1, "episode": 1},
|
|
||||||
{"season": 1, "episode": 2},
|
|
||||||
],
|
|
||||||
"priority": "normal",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/add", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert len(data["added_items"]) == 2
|
|
||||||
assert data["added_items"] == ["item-id-1", "item-id-2"]
|
|
||||||
|
|
||||||
mock_download_service.add_to_queue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_add_to_queue_with_high_priority(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test adding items with HIGH priority."""
|
|
||||||
request_data = {
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Test Anime",
|
|
||||||
"episodes": [{"season": 1, "episode": 1}],
|
|
||||||
"priority": "high",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/add", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
# Verify priority was passed correctly
|
|
||||||
call_args = mock_download_service.add_to_queue.call_args
|
|
||||||
assert call_args[1]["priority"] == DownloadPriority.HIGH
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_add_to_queue_empty_episodes(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test adding empty episodes list returns 400."""
|
|
||||||
request_data = {
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Test Anime",
|
|
||||||
"episodes": [],
|
|
||||||
"priority": "normal",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/add", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_add_to_queue_service_error(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test adding to queue when service raises error."""
|
|
||||||
mock_download_service.add_to_queue.side_effect = DownloadServiceError(
|
|
||||||
"Queue full"
|
|
||||||
)
|
|
||||||
|
|
||||||
request_data = {
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Test Anime",
|
|
||||||
"episodes": [{"season": 1, "episode": 1}],
|
|
||||||
"priority": "normal",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/add", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "Queue full" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_remove_from_queue_single(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test DELETE /api/queue/{item_id} endpoint."""
|
|
||||||
response = await authenticated_client.delete("/api/queue/item-id-1")
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
|
||||||
["item-id-1"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_remove_from_queue_not_found(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test removing non-existent item returns 404."""
|
|
||||||
mock_download_service.remove_from_queue.return_value = []
|
|
||||||
|
|
||||||
response = await authenticated_client.delete(
|
|
||||||
"/api/queue/non-existent-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_remove_multiple_from_queue(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test DELETE /api/queue/ with multiple items."""
|
|
||||||
request_data = {"item_ids": ["item-id-1", "item-id-2"]}
|
|
||||||
|
|
||||||
response = await authenticated_client.request(
|
|
||||||
"DELETE", "/api/queue/", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
|
||||||
["item-id-1", "item-id-2"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_remove_multiple_empty_list(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test removing with empty item list returns 400."""
|
|
||||||
request_data = {"item_ids": []}
|
|
||||||
|
|
||||||
response = await authenticated_client.request(
|
|
||||||
"DELETE", "/api/queue/", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_start_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/start endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/start")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "started" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.start.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_stop_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/stop endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/stop")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "stopped" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.stop.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_pause_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/pause endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/pause")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "paused" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.pause_queue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_resume_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/resume endpoint."""
|
|
||||||
response = await authenticated_client.post("/api/queue/resume")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert "resumed" in data["message"].lower()
|
|
||||||
|
|
||||||
mock_download_service.resume_queue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_reorder_queue(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/reorder endpoint."""
|
|
||||||
request_data = {"item_id": "item-id-1", "new_position": 0}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/reorder", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
|
|
||||||
mock_download_service.reorder_queue.assert_called_once_with(
|
|
||||||
item_id="item-id-1", new_position=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_reorder_queue_not_found(
|
|
||||||
authenticated_client, mock_download_service
|
|
||||||
):
|
|
||||||
"""Test reordering non-existent item returns 404."""
|
|
||||||
mock_download_service.reorder_queue.return_value = False
|
|
||||||
|
|
||||||
request_data = {"item_id": "non-existent", "new_position": 0}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/reorder", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_clear_completed(authenticated_client, mock_download_service):
|
|
||||||
"""Test DELETE /api/queue/completed endpoint."""
|
|
||||||
response = await authenticated_client.delete("/api/queue/completed")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert data["count"] == 5
|
|
||||||
|
|
||||||
mock_download_service.clear_completed.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_retry_failed(authenticated_client, mock_download_service):
|
|
||||||
"""Test POST /api/queue/retry endpoint."""
|
|
||||||
request_data = {"item_ids": ["item-id-3"]}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/retry", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert data["status"] == "success"
|
|
||||||
assert len(data["retried_ids"]) == 1
|
|
||||||
|
|
||||||
mock_download_service.retry_failed.assert_called_once_with(
|
|
||||||
["item-id-3"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_retry_all_failed(authenticated_client, mock_download_service):
|
|
||||||
"""Test retrying all failed items with empty list."""
|
|
||||||
request_data = {"item_ids": []}
|
|
||||||
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/api/queue/retry", json=request_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Should call retry_failed with None to retry all
|
|
||||||
mock_download_service.retry_failed.assert_called_once_with(None)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_queue_endpoints_require_auth():
|
|
||||||
"""Test that all queue endpoints require authentication."""
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(
|
|
||||||
transport=transport, base_url="http://test"
|
|
||||||
) as client:
|
|
||||||
# Test all endpoints without auth
|
|
||||||
endpoints = [
|
|
||||||
("GET", "/api/queue/status"),
|
|
||||||
("POST", "/api/queue/add"),
|
|
||||||
("DELETE", "/api/queue/item-1"),
|
|
||||||
("POST", "/api/queue/start"),
|
|
||||||
("POST", "/api/queue/stop"),
|
|
||||||
("POST", "/api/queue/pause"),
|
|
||||||
("POST", "/api/queue/resume"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for method, url in endpoints:
|
|
||||||
if method == "GET":
|
|
||||||
response = await client.get(url)
|
|
||||||
elif method == "POST":
|
|
||||||
response = await client.post(url, json={})
|
|
||||||
elif method == "DELETE":
|
|
||||||
response = await client.delete(url)
|
|
||||||
|
|
||||||
assert response.status_code == 401, (
|
|
||||||
f"{method} {url} should require auth"
|
|
||||||
)
|
|
||||||
@ -1,470 +0,0 @@
|
|||||||
"""Integration tests for WebSocket integration with core services.
|
|
||||||
|
|
||||||
This module tests the integration between WebSocket broadcasting and
|
|
||||||
core services (DownloadService, AnimeService, ProgressService) to ensure
|
|
||||||
real-time updates are properly broadcasted to connected clients.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.models.download import (
|
|
||||||
DownloadPriority,
|
|
||||||
DownloadStatus,
|
|
||||||
EpisodeIdentifier,
|
|
||||||
)
|
|
||||||
from src.server.services.anime_service import AnimeService
|
|
||||||
from src.server.services.download_service import DownloadService
|
|
||||||
from src.server.services.progress_service import ProgressService, ProgressType
|
|
||||||
from src.server.services.websocket_service import WebSocketService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_series_app():
|
|
||||||
"""Mock SeriesApp for testing."""
|
|
||||||
app = Mock()
|
|
||||||
app.series_list = []
|
|
||||||
app.search = Mock(return_value=[])
|
|
||||||
app.ReScan = Mock()
|
|
||||||
app.download = Mock(return_value=True)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def progress_service():
|
|
||||||
"""Create a ProgressService instance for testing."""
|
|
||||||
return ProgressService()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def websocket_service():
|
|
||||||
"""Create a WebSocketService instance for testing."""
|
|
||||||
return WebSocketService()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def anime_service(mock_series_app, progress_service):
|
|
||||||
"""Create an AnimeService with mocked dependencies."""
|
|
||||||
with patch("src.server.services.anime_service.SeriesApp", return_value=mock_series_app):
|
|
||||||
service = AnimeService(
|
|
||||||
directory="/test/anime",
|
|
||||||
progress_service=progress_service,
|
|
||||||
)
|
|
||||||
yield service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def download_service(anime_service, progress_service):
|
|
||||||
"""Create a DownloadService with dependencies."""
|
|
||||||
service = DownloadService(
|
|
||||||
anime_service=anime_service,
|
|
||||||
max_concurrent_downloads=2,
|
|
||||||
progress_service=progress_service,
|
|
||||||
persistence_path="/tmp/test_queue.json",
|
|
||||||
)
|
|
||||||
yield service
|
|
||||||
await service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDownloadIntegration:
|
|
||||||
"""Test WebSocket integration with DownloadService."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_progress_broadcast(
|
|
||||||
self, download_service, websocket_service
|
|
||||||
):
|
|
||||||
"""Test that download progress updates are broadcasted."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
"""Capture broadcast calls."""
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Add item to queue
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="test_serie",
|
|
||||||
serie_name="Test Anime",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.HIGH,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(item_ids) == 1
|
|
||||||
assert len(broadcasts) == 1
|
|
||||||
assert broadcasts[0]["type"] == "queue_status"
|
|
||||||
assert broadcasts[0]["data"]["action"] == "items_added"
|
|
||||||
assert item_ids[0] in broadcasts[0]["data"]["added_ids"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_operations_broadcast(
|
|
||||||
self, download_service
|
|
||||||
):
|
|
||||||
"""Test that queue operations broadcast status updates."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Add items
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="test",
|
|
||||||
serie_name="Test",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=i) for i in range(1, 4)],
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove items
|
|
||||||
removed = await download_service.remove_from_queue([item_ids[0]])
|
|
||||||
assert len(removed) == 1
|
|
||||||
|
|
||||||
# Check broadcasts
|
|
||||||
add_broadcast = next(
|
|
||||||
b for b in broadcasts
|
|
||||||
if b["data"].get("action") == "items_added"
|
|
||||||
)
|
|
||||||
remove_broadcast = next(
|
|
||||||
b for b in broadcasts
|
|
||||||
if b["data"].get("action") == "items_removed"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert add_broadcast["type"] == "queue_status"
|
|
||||||
assert len(add_broadcast["data"]["added_ids"]) == 3
|
|
||||||
|
|
||||||
assert remove_broadcast["type"] == "queue_status"
|
|
||||||
assert item_ids[0] in remove_broadcast["data"]["removed_ids"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_start_stop_broadcast(
|
|
||||||
self, download_service
|
|
||||||
):
|
|
||||||
"""Test that start/stop operations broadcast updates."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Start queue
|
|
||||||
await download_service.start()
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
# Stop queue
|
|
||||||
await download_service.stop()
|
|
||||||
|
|
||||||
# Find start/stop broadcasts
|
|
||||||
start_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_started"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
stop_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_stopped"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert start_broadcast is not None
|
|
||||||
assert start_broadcast["data"]["is_running"] is True
|
|
||||||
|
|
||||||
assert stop_broadcast is not None
|
|
||||||
assert stop_broadcast["data"]["is_running"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_pause_resume_broadcast(
|
|
||||||
self, download_service
|
|
||||||
):
|
|
||||||
"""Test that pause/resume operations broadcast updates."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Pause queue
|
|
||||||
await download_service.pause_queue()
|
|
||||||
|
|
||||||
# Resume queue
|
|
||||||
await download_service.resume_queue()
|
|
||||||
|
|
||||||
# Find pause/resume broadcasts
|
|
||||||
pause_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_paused"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
resume_broadcast = next(
|
|
||||||
(b for b in broadcasts if b["type"] == "queue_resumed"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert pause_broadcast is not None
|
|
||||||
assert pause_broadcast["data"]["is_paused"] is True
|
|
||||||
|
|
||||||
assert resume_broadcast is not None
|
|
||||||
assert resume_broadcast["data"]["is_paused"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_clear_completed_broadcast(
|
|
||||||
self, download_service
|
|
||||||
):
|
|
||||||
"""Test that clearing completed items broadcasts update."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(update_type: str, data: dict):
|
|
||||||
broadcasts.append({"type": update_type, "data": data})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Manually add a completed item to test
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.server.models.download import DownloadItem
|
|
||||||
|
|
||||||
completed_item = DownloadItem(
|
|
||||||
id="test_completed",
|
|
||||||
serie_id="test",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=EpisodeIdentifier(season=1, episode=1),
|
|
||||||
status=DownloadStatus.COMPLETED,
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
added_at=datetime.utcnow(),
|
|
||||||
)
|
|
||||||
download_service._completed_items.append(completed_item)
|
|
||||||
|
|
||||||
# Clear completed
|
|
||||||
count = await download_service.clear_completed()
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
# Find clear broadcast
|
|
||||||
clear_broadcast = next(
|
|
||||||
(
|
|
||||||
b for b in broadcasts
|
|
||||||
if b["data"].get("action") == "completed_cleared"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert clear_broadcast is not None
|
|
||||||
assert clear_broadcast["data"]["cleared_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketScanIntegration:
|
|
||||||
"""Test WebSocket integration with AnimeService scan operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_progress_broadcast(
|
|
||||||
self, anime_service, progress_service, mock_series_app
|
|
||||||
):
|
|
||||||
"""Test that scan progress updates are broadcasted."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(message_type: str, data: dict, room: str):
|
|
||||||
"""Capture broadcast calls."""
|
|
||||||
broadcasts.append({
|
|
||||||
"type": message_type,
|
|
||||||
"data": data,
|
|
||||||
"room": room,
|
|
||||||
})
|
|
||||||
|
|
||||||
progress_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Mock scan callback to simulate progress
|
|
||||||
def mock_scan_callback(callback):
|
|
||||||
"""Simulate scan progress."""
|
|
||||||
if callback:
|
|
||||||
callback({"current": 5, "total": 10, "message": "Scanning..."})
|
|
||||||
callback({"current": 10, "total": 10, "message": "Complete"})
|
|
||||||
|
|
||||||
mock_series_app.ReScan = mock_scan_callback
|
|
||||||
|
|
||||||
# Run scan
|
|
||||||
await anime_service.rescan()
|
|
||||||
|
|
||||||
# Verify broadcasts were made
|
|
||||||
assert len(broadcasts) >= 2 # At least start and complete
|
|
||||||
|
|
||||||
# Check for scan progress broadcasts
|
|
||||||
scan_broadcasts = [
|
|
||||||
b for b in broadcasts if b["room"] == "scan_progress"
|
|
||||||
]
|
|
||||||
assert len(scan_broadcasts) >= 2
|
|
||||||
|
|
||||||
# Verify start broadcast
|
|
||||||
start_broadcast = scan_broadcasts[0]
|
|
||||||
assert start_broadcast["data"]["status"] == "started"
|
|
||||||
assert start_broadcast["data"]["type"] == ProgressType.SCAN.value
|
|
||||||
|
|
||||||
# Verify completion broadcast
|
|
||||||
complete_broadcast = scan_broadcasts[-1]
|
|
||||||
assert complete_broadcast["data"]["status"] == "completed"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_failure_broadcast(
|
|
||||||
self, anime_service, progress_service, mock_series_app
|
|
||||||
):
|
|
||||||
"""Test that scan failures are broadcasted."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(message_type: str, data: dict, room: str):
|
|
||||||
broadcasts.append({
|
|
||||||
"type": message_type,
|
|
||||||
"data": data,
|
|
||||||
"room": room,
|
|
||||||
})
|
|
||||||
|
|
||||||
progress_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Mock scan to raise error
|
|
||||||
def mock_scan_error(callback):
|
|
||||||
raise RuntimeError("Scan failed")
|
|
||||||
|
|
||||||
mock_series_app.ReScan = mock_scan_error
|
|
||||||
|
|
||||||
# Run scan (should fail)
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
await anime_service.rescan()
|
|
||||||
|
|
||||||
# Verify failure broadcast
|
|
||||||
scan_broadcasts = [
|
|
||||||
b for b in broadcasts if b["room"] == "scan_progress"
|
|
||||||
]
|
|
||||||
assert len(scan_broadcasts) >= 2 # Start and fail
|
|
||||||
|
|
||||||
# Verify failure broadcast
|
|
||||||
fail_broadcast = scan_broadcasts[-1]
|
|
||||||
assert fail_broadcast["data"]["status"] == "failed"
|
|
||||||
# Verify error message or failed status
|
|
||||||
is_error = "error" in fail_broadcast["data"]["message"].lower()
|
|
||||||
is_failed = fail_broadcast["data"]["status"] == "failed"
|
|
||||||
assert is_error or is_failed
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketProgressIntegration:
|
|
||||||
"""Test WebSocket integration with ProgressService."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_progress_lifecycle_broadcast(
|
|
||||||
self, progress_service
|
|
||||||
):
|
|
||||||
"""Test that progress lifecycle events are broadcasted."""
|
|
||||||
broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def mock_broadcast(message_type: str, data: dict, room: str):
|
|
||||||
broadcasts.append({
|
|
||||||
"type": message_type,
|
|
||||||
"data": data,
|
|
||||||
"room": room,
|
|
||||||
})
|
|
||||||
|
|
||||||
progress_service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
# Start progress
|
|
||||||
await progress_service.start_progress(
|
|
||||||
progress_id="test_progress",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test Download",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
await progress_service.update_progress(
|
|
||||||
progress_id="test_progress",
|
|
||||||
current=50,
|
|
||||||
force_broadcast=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Complete progress
|
|
||||||
await progress_service.complete_progress(
|
|
||||||
progress_id="test_progress",
|
|
||||||
message="Download complete",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify broadcasts
|
|
||||||
assert len(broadcasts) == 3
|
|
||||||
|
|
||||||
start_broadcast = broadcasts[0]
|
|
||||||
assert start_broadcast["data"]["status"] == "started"
|
|
||||||
assert start_broadcast["room"] == "download_progress"
|
|
||||||
|
|
||||||
update_broadcast = broadcasts[1]
|
|
||||||
assert update_broadcast["data"]["status"] == "in_progress"
|
|
||||||
assert update_broadcast["data"]["percent"] == 50.0
|
|
||||||
|
|
||||||
complete_broadcast = broadcasts[2]
|
|
||||||
assert complete_broadcast["data"]["status"] == "completed"
|
|
||||||
assert complete_broadcast["data"]["percent"] == 100.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketEndToEnd:
|
|
||||||
"""End-to-end integration tests with all services."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_download_flow_with_broadcasts(
|
|
||||||
self, download_service, anime_service, progress_service
|
|
||||||
):
|
|
||||||
"""Test complete download flow with all broadcasts."""
|
|
||||||
all_broadcasts: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def capture_download_broadcast(update_type: str, data: dict):
|
|
||||||
all_broadcasts.append({
|
|
||||||
"source": "download",
|
|
||||||
"type": update_type,
|
|
||||||
"data": data,
|
|
||||||
})
|
|
||||||
|
|
||||||
async def capture_progress_broadcast(
|
|
||||||
message_type: str, data: dict, room: str
|
|
||||||
):
|
|
||||||
all_broadcasts.append({
|
|
||||||
"source": "progress",
|
|
||||||
"type": message_type,
|
|
||||||
"data": data,
|
|
||||||
"room": room,
|
|
||||||
})
|
|
||||||
|
|
||||||
download_service.set_broadcast_callback(capture_download_broadcast)
|
|
||||||
progress_service.set_broadcast_callback(capture_progress_broadcast)
|
|
||||||
|
|
||||||
# Add items to queue
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="test",
|
|
||||||
serie_name="Test Anime",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.HIGH,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start queue
|
|
||||||
await download_service.start()
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
# Pause queue
|
|
||||||
await download_service.pause_queue()
|
|
||||||
|
|
||||||
# Resume queue
|
|
||||||
await download_service.resume_queue()
|
|
||||||
|
|
||||||
# Stop queue
|
|
||||||
await download_service.stop()
|
|
||||||
|
|
||||||
# Verify we received broadcasts from both services
|
|
||||||
download_broadcasts = [
|
|
||||||
b for b in all_broadcasts if b["source"] == "download"
|
|
||||||
]
|
|
||||||
|
|
||||||
assert len(download_broadcasts) >= 4 # add, start, pause, resume, stop
|
|
||||||
assert len(item_ids) == 1
|
|
||||||
|
|
||||||
# Verify queue status broadcasts
|
|
||||||
queue_status_broadcasts = [
|
|
||||||
b for b in download_broadcasts if b["type"] == "queue_status"
|
|
||||||
]
|
|
||||||
assert len(queue_status_broadcasts) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from src.server.models.anime import (
|
|
||||||
AnimeSeriesResponse,
|
|
||||||
EpisodeInfo,
|
|
||||||
MissingEpisodeInfo,
|
|
||||||
SearchRequest,
|
|
||||||
SearchResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from src.server.models.anime import (
|
|
||||||
AnimeSeriesResponse,
|
|
||||||
EpisodeInfo,
|
|
||||||
MissingEpisodeInfo,
|
|
||||||
SearchRequest,
|
|
||||||
SearchResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_missing_empty(tmp_path):
|
|
||||||
svc = AnimeService(directory=str(tmp_path))
|
|
||||||
# SeriesApp may return empty list depending on filesystem; ensure it returns a list
|
|
||||||
result = await svc.list_missing()
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_empty_query(tmp_path):
|
|
||||||
svc = AnimeService(directory=str(tmp_path))
|
|
||||||
res = await svc.search("")
|
|
||||||
assert res == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rescan_and_cache_clear(tmp_path):
|
|
||||||
svc = AnimeService(directory=str(tmp_path))
|
|
||||||
# calling rescan should not raise
|
|
||||||
await svc.rescan()
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.models.config import (
|
|
||||||
AppConfig,
|
|
||||||
ConfigUpdate,
|
|
||||||
LoggingConfig,
|
|
||||||
SchedulerConfig,
|
|
||||||
ValidationResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_defaults_and_validation():
|
|
||||||
sched = SchedulerConfig()
|
|
||||||
assert sched.enabled is True
|
|
||||||
assert sched.interval_minutes == 60
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
SchedulerConfig(interval_minutes=0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_logging_config_defaults_and_values():
|
|
||||||
log = LoggingConfig()
|
|
||||||
assert log.level == "INFO"
|
|
||||||
assert log.file is None
|
|
||||||
assert log.backup_count == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_appconfig_and_config_update_apply_to():
|
|
||||||
base = AppConfig()
|
|
||||||
|
|
||||||
upd = ConfigUpdate(
|
|
||||||
scheduler=SchedulerConfig(enabled=False, interval_minutes=30)
|
|
||||||
)
|
|
||||||
new = upd.apply_to(base)
|
|
||||||
assert isinstance(new, AppConfig)
|
|
||||||
assert new.scheduler.enabled is False
|
|
||||||
assert new.scheduler.interval_minutes == 30
|
|
||||||
|
|
||||||
upd2 = ConfigUpdate(other={"b": 2})
|
|
||||||
new2 = upd2.apply_to(base)
|
|
||||||
assert new2.other.get("b") == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_and_validation():
|
|
||||||
cfg = AppConfig()
|
|
||||||
# default backups disabled -> valid
|
|
||||||
res: ValidationResult = cfg.validate()
|
|
||||||
assert res.valid is True
|
|
||||||
|
|
||||||
# enable backups but leave path empty -> invalid
|
|
||||||
cfg.backup.enabled = True
|
|
||||||
cfg.backup.path = ""
|
|
||||||
res2 = cfg.validate()
|
|
||||||
assert res2.valid is False
|
|
||||||
assert any("backup.path" in e for e in res2.errors)
|
|
||||||
@ -1,550 +0,0 @@
|
|||||||
"""Unit tests for download queue Pydantic models.
|
|
||||||
|
|
||||||
This module tests all download-related models including validation,
|
|
||||||
serialization, and field constraints.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from src.server.models.download import (
|
|
||||||
DownloadItem,
|
|
||||||
DownloadPriority,
|
|
||||||
DownloadProgress,
|
|
||||||
DownloadRequest,
|
|
||||||
DownloadResponse,
|
|
||||||
DownloadStatus,
|
|
||||||
EpisodeIdentifier,
|
|
||||||
QueueOperationRequest,
|
|
||||||
QueueReorderRequest,
|
|
||||||
QueueStats,
|
|
||||||
QueueStatus,
|
|
||||||
QueueStatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadStatus:
|
|
||||||
"""Test DownloadStatus enum."""
|
|
||||||
|
|
||||||
def test_all_statuses_exist(self):
|
|
||||||
"""Test that all expected statuses are defined."""
|
|
||||||
assert DownloadStatus.PENDING == "pending"
|
|
||||||
assert DownloadStatus.DOWNLOADING == "downloading"
|
|
||||||
assert DownloadStatus.PAUSED == "paused"
|
|
||||||
assert DownloadStatus.COMPLETED == "completed"
|
|
||||||
assert DownloadStatus.FAILED == "failed"
|
|
||||||
assert DownloadStatus.CANCELLED == "cancelled"
|
|
||||||
|
|
||||||
def test_status_values(self):
|
|
||||||
"""Test that status values are lowercase strings."""
|
|
||||||
for status in DownloadStatus:
|
|
||||||
assert isinstance(status.value, str)
|
|
||||||
assert status.value.islower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadPriority:
|
|
||||||
"""Test DownloadPriority enum."""
|
|
||||||
|
|
||||||
def test_all_priorities_exist(self):
|
|
||||||
"""Test that all expected priorities are defined."""
|
|
||||||
assert DownloadPriority.LOW == "low"
|
|
||||||
assert DownloadPriority.NORMAL == "normal"
|
|
||||||
assert DownloadPriority.HIGH == "high"
|
|
||||||
|
|
||||||
def test_priority_values(self):
|
|
||||||
"""Test that priority values are lowercase strings."""
|
|
||||||
for priority in DownloadPriority:
|
|
||||||
assert isinstance(priority.value, str)
|
|
||||||
assert priority.value.islower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestEpisodeIdentifier:
|
|
||||||
"""Test EpisodeIdentifier model."""
|
|
||||||
|
|
||||||
def test_valid_episode_identifier(self):
|
|
||||||
"""Test creating a valid episode identifier."""
|
|
||||||
episode = EpisodeIdentifier(
|
|
||||||
season=1,
|
|
||||||
episode=5,
|
|
||||||
title="Test Episode"
|
|
||||||
)
|
|
||||||
assert episode.season == 1
|
|
||||||
assert episode.episode == 5
|
|
||||||
assert episode.title == "Test Episode"
|
|
||||||
|
|
||||||
def test_episode_identifier_without_title(self):
|
|
||||||
"""Test creating episode identifier without title."""
|
|
||||||
episode = EpisodeIdentifier(season=2, episode=10)
|
|
||||||
assert episode.season == 2
|
|
||||||
assert episode.episode == 10
|
|
||||||
assert episode.title is None
|
|
||||||
|
|
||||||
def test_invalid_season_number(self):
|
|
||||||
"""Test that season must be positive."""
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
EpisodeIdentifier(season=0, episode=1)
|
|
||||||
errors = exc_info.value.errors()
|
|
||||||
assert any("season" in str(e["loc"]) for e in errors)
|
|
||||||
|
|
||||||
def test_invalid_episode_number(self):
|
|
||||||
"""Test that episode must be positive."""
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
|
||||||
EpisodeIdentifier(season=1, episode=0)
|
|
||||||
errors = exc_info.value.errors()
|
|
||||||
assert any("episode" in str(e["loc"]) for e in errors)
|
|
||||||
|
|
||||||
def test_negative_season_rejected(self):
|
|
||||||
"""Test that negative season is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
EpisodeIdentifier(season=-1, episode=1)
|
|
||||||
|
|
||||||
def test_negative_episode_rejected(self):
|
|
||||||
"""Test that negative episode is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
EpisodeIdentifier(season=1, episode=-1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadProgress:
|
|
||||||
"""Test DownloadProgress model."""
|
|
||||||
|
|
||||||
def test_valid_progress(self):
|
|
||||||
"""Test creating valid progress information."""
|
|
||||||
progress = DownloadProgress(
|
|
||||||
percent=45.5,
|
|
||||||
downloaded_mb=100.0,
|
|
||||||
total_mb=220.0,
|
|
||||||
speed_mbps=5.5,
|
|
||||||
eta_seconds=120
|
|
||||||
)
|
|
||||||
assert progress.percent == 45.5
|
|
||||||
assert progress.downloaded_mb == 100.0
|
|
||||||
assert progress.total_mb == 220.0
|
|
||||||
assert progress.speed_mbps == 5.5
|
|
||||||
assert progress.eta_seconds == 120
|
|
||||||
|
|
||||||
def test_progress_defaults(self):
|
|
||||||
"""Test default values for progress."""
|
|
||||||
progress = DownloadProgress()
|
|
||||||
assert progress.percent == 0.0
|
|
||||||
assert progress.downloaded_mb == 0.0
|
|
||||||
assert progress.total_mb is None
|
|
||||||
assert progress.speed_mbps is None
|
|
||||||
assert progress.eta_seconds is None
|
|
||||||
|
|
||||||
def test_percent_range_validation(self):
|
|
||||||
"""Test that percent must be between 0 and 100."""
|
|
||||||
# Valid boundary values
|
|
||||||
DownloadProgress(percent=0.0)
|
|
||||||
DownloadProgress(percent=100.0)
|
|
||||||
|
|
||||||
# Invalid values
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(percent=-0.1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(percent=100.1)
|
|
||||||
|
|
||||||
def test_negative_downloaded_mb_rejected(self):
|
|
||||||
"""Test that negative downloaded_mb is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(downloaded_mb=-1.0)
|
|
||||||
|
|
||||||
def test_negative_total_mb_rejected(self):
|
|
||||||
"""Test that negative total_mb is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(total_mb=-1.0)
|
|
||||||
|
|
||||||
def test_negative_speed_rejected(self):
|
|
||||||
"""Test that negative speed is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(speed_mbps=-1.0)
|
|
||||||
|
|
||||||
def test_negative_eta_rejected(self):
|
|
||||||
"""Test that negative ETA is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadProgress(eta_seconds=-1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadItem:
|
|
||||||
"""Test DownloadItem model."""
|
|
||||||
|
|
||||||
def test_valid_download_item(self):
|
|
||||||
"""Test creating a valid download item."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=5)
|
|
||||||
item = DownloadItem(
|
|
||||||
id="download_123",
|
|
||||||
serie_id="serie_456",
|
|
||||||
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_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."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode
|
|
||||||
)
|
|
||||||
assert item.status == DownloadStatus.PENDING
|
|
||||||
assert item.priority == DownloadPriority.NORMAL
|
|
||||||
assert item.started_at is None
|
|
||||||
assert item.completed_at is None
|
|
||||||
assert item.progress is None
|
|
||||||
assert item.error is None
|
|
||||||
assert item.retry_count == 0
|
|
||||||
assert item.source_url is None
|
|
||||||
|
|
||||||
def test_download_item_with_progress(self):
|
|
||||||
"""Test download item with progress information."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
progress = DownloadProgress(percent=50.0, downloaded_mb=100.0)
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode,
|
|
||||||
progress=progress
|
|
||||||
)
|
|
||||||
assert item.progress is not None
|
|
||||||
assert item.progress.percent == 50.0
|
|
||||||
|
|
||||||
def test_download_item_with_timestamps(self):
|
|
||||||
"""Test download item with timestamp fields."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
now = datetime.utcnow()
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode,
|
|
||||||
started_at=now,
|
|
||||||
completed_at=now + timedelta(minutes=5)
|
|
||||||
)
|
|
||||||
assert item.started_at == now
|
|
||||||
assert item.completed_at == now + timedelta(minutes=5)
|
|
||||||
|
|
||||||
def test_empty_serie_name_rejected(self):
|
|
||||||
"""Test that empty serie name is rejected."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="",
|
|
||||||
episode=episode
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_negative_retry_count_rejected(self):
|
|
||||||
"""Test that negative retry count is rejected."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode,
|
|
||||||
retry_count=-1
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_added_at_auto_generated(self):
|
|
||||||
"""Test that added_at is automatically set."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
before = datetime.utcnow()
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode
|
|
||||||
)
|
|
||||||
after = datetime.utcnow()
|
|
||||||
assert before <= item.added_at <= after
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueStatus:
|
|
||||||
"""Test QueueStatus model."""
|
|
||||||
|
|
||||||
def test_valid_queue_status(self):
|
|
||||||
"""Test creating valid queue status."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test",
|
|
||||||
episode=episode
|
|
||||||
)
|
|
||||||
status = QueueStatus(
|
|
||||||
is_running=True,
|
|
||||||
is_paused=False,
|
|
||||||
active_downloads=[item],
|
|
||||||
pending_queue=[item],
|
|
||||||
completed_downloads=[],
|
|
||||||
failed_downloads=[]
|
|
||||||
)
|
|
||||||
assert status.is_running is True
|
|
||||||
assert status.is_paused is False
|
|
||||||
assert len(status.active_downloads) == 1
|
|
||||||
assert len(status.pending_queue) == 1
|
|
||||||
|
|
||||||
def test_queue_status_defaults(self):
|
|
||||||
"""Test default values for queue status."""
|
|
||||||
status = QueueStatus()
|
|
||||||
assert status.is_running is False
|
|
||||||
assert status.is_paused is False
|
|
||||||
assert status.active_downloads == []
|
|
||||||
assert status.pending_queue == []
|
|
||||||
assert status.completed_downloads == []
|
|
||||||
assert status.failed_downloads == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueStats:
|
|
||||||
"""Test QueueStats model."""
|
|
||||||
|
|
||||||
def test_valid_queue_stats(self):
|
|
||||||
"""Test creating valid queue statistics."""
|
|
||||||
stats = QueueStats(
|
|
||||||
total_items=10,
|
|
||||||
pending_count=3,
|
|
||||||
active_count=2,
|
|
||||||
completed_count=4,
|
|
||||||
failed_count=1,
|
|
||||||
total_downloaded_mb=500.5,
|
|
||||||
average_speed_mbps=5.0,
|
|
||||||
estimated_time_remaining=120
|
|
||||||
)
|
|
||||||
assert stats.total_items == 10
|
|
||||||
assert stats.pending_count == 3
|
|
||||||
assert stats.active_count == 2
|
|
||||||
assert stats.completed_count == 4
|
|
||||||
assert stats.failed_count == 1
|
|
||||||
assert stats.total_downloaded_mb == 500.5
|
|
||||||
assert stats.average_speed_mbps == 5.0
|
|
||||||
assert stats.estimated_time_remaining == 120
|
|
||||||
|
|
||||||
def test_queue_stats_defaults(self):
|
|
||||||
"""Test default values for queue stats."""
|
|
||||||
stats = QueueStats()
|
|
||||||
assert stats.total_items == 0
|
|
||||||
assert stats.pending_count == 0
|
|
||||||
assert stats.active_count == 0
|
|
||||||
assert stats.completed_count == 0
|
|
||||||
assert stats.failed_count == 0
|
|
||||||
assert stats.total_downloaded_mb == 0.0
|
|
||||||
assert stats.average_speed_mbps is None
|
|
||||||
assert stats.estimated_time_remaining is None
|
|
||||||
|
|
||||||
def test_negative_counts_rejected(self):
|
|
||||||
"""Test that negative counts are rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(total_items=-1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(pending_count=-1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(active_count=-1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(completed_count=-1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(failed_count=-1)
|
|
||||||
|
|
||||||
def test_negative_speed_rejected(self):
|
|
||||||
"""Test that negative speed is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(average_speed_mbps=-1.0)
|
|
||||||
|
|
||||||
def test_negative_eta_rejected(self):
|
|
||||||
"""Test that negative ETA is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueStats(estimated_time_remaining=-1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadRequest:
|
|
||||||
"""Test DownloadRequest model."""
|
|
||||||
|
|
||||||
def test_valid_download_request(self):
|
|
||||||
"""Test creating a valid download request."""
|
|
||||||
episode1 = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
episode2 = EpisodeIdentifier(season=1, episode=2)
|
|
||||||
request = DownloadRequest(
|
|
||||||
serie_id="serie_123",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[episode1, episode2],
|
|
||||||
priority=DownloadPriority.HIGH
|
|
||||||
)
|
|
||||||
assert request.serie_id == "serie_123"
|
|
||||||
assert request.serie_name == "Test Series"
|
|
||||||
assert len(request.episodes) == 2
|
|
||||||
assert request.priority == DownloadPriority.HIGH
|
|
||||||
|
|
||||||
def test_download_request_default_priority(self):
|
|
||||||
"""Test default priority for download request."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
request = DownloadRequest(
|
|
||||||
serie_id="serie_123",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[episode]
|
|
||||||
)
|
|
||||||
assert request.priority == DownloadPriority.NORMAL
|
|
||||||
|
|
||||||
def test_empty_episodes_list_rejected(self):
|
|
||||||
"""Test that empty episodes list is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadRequest(
|
|
||||||
serie_id="serie_123",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_serie_name_rejected(self):
|
|
||||||
"""Test that empty serie name is rejected."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
DownloadRequest(
|
|
||||||
serie_id="serie_123",
|
|
||||||
serie_name="",
|
|
||||||
episodes=[episode]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadResponse:
|
|
||||||
"""Test DownloadResponse model."""
|
|
||||||
|
|
||||||
def test_valid_download_response(self):
|
|
||||||
"""Test creating a valid download response."""
|
|
||||||
response = DownloadResponse(
|
|
||||||
status="success",
|
|
||||||
message="Added 2 episodes to queue",
|
|
||||||
added_items=["item1", "item2"],
|
|
||||||
failed_items=[]
|
|
||||||
)
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.message == "Added 2 episodes to queue"
|
|
||||||
assert len(response.added_items) == 2
|
|
||||||
assert response.failed_items == []
|
|
||||||
|
|
||||||
def test_download_response_defaults(self):
|
|
||||||
"""Test default values for download response."""
|
|
||||||
response = DownloadResponse(
|
|
||||||
status="success",
|
|
||||||
message="Test message"
|
|
||||||
)
|
|
||||||
assert response.added_items == []
|
|
||||||
assert response.failed_items == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueOperationRequest:
|
|
||||||
"""Test QueueOperationRequest model."""
|
|
||||||
|
|
||||||
def test_valid_operation_request(self):
|
|
||||||
"""Test creating a valid operation request."""
|
|
||||||
request = QueueOperationRequest(
|
|
||||||
item_ids=["item1", "item2", "item3"]
|
|
||||||
)
|
|
||||||
assert len(request.item_ids) == 3
|
|
||||||
assert "item1" in request.item_ids
|
|
||||||
|
|
||||||
def test_empty_item_ids_rejected(self):
|
|
||||||
"""Test that empty item_ids list is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueOperationRequest(item_ids=[])
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueReorderRequest:
|
|
||||||
"""Test QueueReorderRequest model."""
|
|
||||||
|
|
||||||
def test_valid_reorder_request(self):
|
|
||||||
"""Test creating a valid reorder request."""
|
|
||||||
request = QueueReorderRequest(
|
|
||||||
item_id="item_123",
|
|
||||||
new_position=5
|
|
||||||
)
|
|
||||||
assert request.item_id == "item_123"
|
|
||||||
assert request.new_position == 5
|
|
||||||
|
|
||||||
def test_zero_position_allowed(self):
|
|
||||||
"""Test that position zero is allowed."""
|
|
||||||
request = QueueReorderRequest(
|
|
||||||
item_id="item_123",
|
|
||||||
new_position=0
|
|
||||||
)
|
|
||||||
assert request.new_position == 0
|
|
||||||
|
|
||||||
def test_negative_position_rejected(self):
|
|
||||||
"""Test that negative position is rejected."""
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
QueueReorderRequest(
|
|
||||||
item_id="item_123",
|
|
||||||
new_position=-1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueStatusResponse:
|
|
||||||
"""Test QueueStatusResponse model."""
|
|
||||||
|
|
||||||
def test_valid_status_response(self):
|
|
||||||
"""Test creating a valid status response."""
|
|
||||||
status = QueueStatus()
|
|
||||||
stats = QueueStats()
|
|
||||||
response = QueueStatusResponse(
|
|
||||||
status=status,
|
|
||||||
statistics=stats
|
|
||||||
)
|
|
||||||
assert response.status is not None
|
|
||||||
assert response.statistics is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestModelSerialization:
|
|
||||||
"""Test model serialization and deserialization."""
|
|
||||||
|
|
||||||
def test_download_item_to_dict(self):
|
|
||||||
"""Test serializing download item to dict."""
|
|
||||||
episode = EpisodeIdentifier(season=1, episode=5, title="Test")
|
|
||||||
item = DownloadItem(
|
|
||||||
id="test_id",
|
|
||||||
serie_id="serie_id",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episode=episode
|
|
||||||
)
|
|
||||||
data = item.model_dump()
|
|
||||||
assert data["id"] == "test_id"
|
|
||||||
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."""
|
|
||||||
data = {
|
|
||||||
"id": "test_id",
|
|
||||||
"serie_id": "serie_id",
|
|
||||||
"serie_name": "Test Series",
|
|
||||||
"episode": {
|
|
||||||
"season": 1,
|
|
||||||
"episode": 5,
|
|
||||||
"title": "Test Episode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item = DownloadItem(**data)
|
|
||||||
assert item.id == "test_id"
|
|
||||||
assert item.serie_name == "Test Series"
|
|
||||||
assert item.episode.season == 1
|
|
||||||
|
|
||||||
def test_queue_status_to_json(self):
|
|
||||||
"""Test serializing queue status to JSON."""
|
|
||||||
status = QueueStatus(is_running=True)
|
|
||||||
json_str = status.model_dump_json()
|
|
||||||
assert '"is_running":true' in json_str.lower()
|
|
||||||
|
|
||||||
def test_queue_stats_from_json(self):
|
|
||||||
"""Test deserializing queue stats from JSON."""
|
|
||||||
json_str = '{"total_items": 5, "pending_count": 3}'
|
|
||||||
stats = QueueStats.model_validate_json(json_str)
|
|
||||||
assert stats.total_items == 5
|
|
||||||
assert stats.pending_count == 3
|
|
||||||
@ -1,491 +0,0 @@
|
|||||||
"""Unit tests for the download queue service.
|
|
||||||
|
|
||||||
Tests cover queue management, priority handling, persistence,
|
|
||||||
concurrent downloads, and error scenarios.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.models.download import (
|
|
||||||
DownloadItem,
|
|
||||||
DownloadPriority,
|
|
||||||
DownloadStatus,
|
|
||||||
EpisodeIdentifier,
|
|
||||||
)
|
|
||||||
from src.server.services.anime_service import AnimeService
|
|
||||||
from src.server.services.download_service import DownloadService, DownloadServiceError
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_anime_service():
|
|
||||||
"""Create a mock AnimeService."""
|
|
||||||
service = MagicMock(spec=AnimeService)
|
|
||||||
service.download = AsyncMock(return_value=True)
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_persistence_path(tmp_path):
|
|
||||||
"""Create a temporary persistence path."""
|
|
||||||
return str(tmp_path / "test_queue.json")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def download_service(mock_anime_service, temp_persistence_path):
|
|
||||||
"""Create a DownloadService instance for testing."""
|
|
||||||
return DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
max_concurrent_downloads=2,
|
|
||||||
max_retries=3,
|
|
||||||
persistence_path=temp_persistence_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadServiceInitialization:
|
|
||||||
"""Test download service initialization."""
|
|
||||||
|
|
||||||
def test_initialization_creates_queues(
|
|
||||||
self, mock_anime_service, temp_persistence_path
|
|
||||||
):
|
|
||||||
"""Test that initialization creates empty queues."""
|
|
||||||
service = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
persistence_path=temp_persistence_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(service._pending_queue) == 0
|
|
||||||
assert len(service._active_downloads) == 0
|
|
||||||
assert len(service._completed_items) == 0
|
|
||||||
assert len(service._failed_items) == 0
|
|
||||||
assert service._is_running is False
|
|
||||||
assert service._is_paused is False
|
|
||||||
|
|
||||||
def test_initialization_loads_persisted_queue(
|
|
||||||
self, mock_anime_service, temp_persistence_path
|
|
||||||
):
|
|
||||||
"""Test that initialization loads persisted queue state."""
|
|
||||||
# Create a persisted queue file
|
|
||||||
persistence_file = Path(temp_persistence_path)
|
|
||||||
persistence_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
test_data = {
|
|
||||||
"pending": [
|
|
||||||
{
|
|
||||||
"id": "test-id-1",
|
|
||||||
"serie_id": "series-1",
|
|
||||||
"serie_name": "Test Series",
|
|
||||||
"episode": {"season": 1, "episode": 1, "title": None},
|
|
||||||
"status": "pending",
|
|
||||||
"priority": "normal",
|
|
||||||
"added_at": datetime.utcnow().isoformat(),
|
|
||||||
"started_at": None,
|
|
||||||
"completed_at": None,
|
|
||||||
"progress": None,
|
|
||||||
"error": None,
|
|
||||||
"retry_count": 0,
|
|
||||||
"source_url": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"active": [],
|
|
||||||
"failed": [],
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(persistence_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(test_data, f)
|
|
||||||
|
|
||||||
service = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
persistence_path=temp_persistence_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(service._pending_queue) == 1
|
|
||||||
assert service._pending_queue[0].id == "test-id-1"
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueManagement:
|
|
||||||
"""Test queue management operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_to_queue_single_episode(self, download_service):
|
|
||||||
"""Test adding a single episode to queue."""
|
|
||||||
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
|
||||||
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=episodes,
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(item_ids) == 1
|
|
||||||
assert len(download_service._pending_queue) == 1
|
|
||||||
assert download_service._pending_queue[0].serie_id == "series-1"
|
|
||||||
assert (
|
|
||||||
download_service._pending_queue[0].status
|
|
||||||
== DownloadStatus.PENDING
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_to_queue_multiple_episodes(self, download_service):
|
|
||||||
"""Test adding multiple episodes to queue."""
|
|
||||||
episodes = [
|
|
||||||
EpisodeIdentifier(season=1, episode=1),
|
|
||||||
EpisodeIdentifier(season=1, episode=2),
|
|
||||||
EpisodeIdentifier(season=1, episode=3),
|
|
||||||
]
|
|
||||||
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=episodes,
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(item_ids) == 3
|
|
||||||
assert len(download_service._pending_queue) == 3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_high_priority_to_front(self, download_service):
|
|
||||||
"""Test that high priority items are added to front of queue."""
|
|
||||||
# Add normal priority item
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add high priority item
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-2",
|
|
||||||
serie_name="Priority Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
priority=DownloadPriority.HIGH,
|
|
||||||
)
|
|
||||||
|
|
||||||
# High priority should be at front
|
|
||||||
assert download_service._pending_queue[0].serie_id == "series-2"
|
|
||||||
assert download_service._pending_queue[1].serie_id == "series-1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remove_from_pending_queue(self, download_service):
|
|
||||||
"""Test removing items from pending queue."""
|
|
||||||
item_ids = await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
removed_ids = await download_service.remove_from_queue(item_ids)
|
|
||||||
|
|
||||||
assert len(removed_ids) == 1
|
|
||||||
assert removed_ids[0] == item_ids[0]
|
|
||||||
assert len(download_service._pending_queue) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reorder_queue(self, download_service):
|
|
||||||
"""Test reordering items in queue."""
|
|
||||||
# Add three items
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Series 1",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-2",
|
|
||||||
serie_name="Series 2",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-3",
|
|
||||||
serie_name="Series 3",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move last item to position 0
|
|
||||||
item_to_move = download_service._pending_queue[2].id
|
|
||||||
success = await download_service.reorder_queue(item_to_move, 0)
|
|
||||||
|
|
||||||
assert success is True
|
|
||||||
assert download_service._pending_queue[0].id == item_to_move
|
|
||||||
assert download_service._pending_queue[0].serie_id == "series-3"
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueStatus:
|
|
||||||
"""Test queue status reporting."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_queue_status(self, download_service):
|
|
||||||
"""Test getting queue status."""
|
|
||||||
# Add items to queue
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[
|
|
||||||
EpisodeIdentifier(season=1, episode=1),
|
|
||||||
EpisodeIdentifier(season=1, episode=2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
status = await download_service.get_queue_status()
|
|
||||||
|
|
||||||
assert status.is_running is False
|
|
||||||
assert status.is_paused is False
|
|
||||||
assert len(status.pending_queue) == 2
|
|
||||||
assert len(status.active_downloads) == 0
|
|
||||||
assert len(status.completed_downloads) == 0
|
|
||||||
assert len(status.failed_downloads) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_queue_stats(self, download_service):
|
|
||||||
"""Test getting queue statistics."""
|
|
||||||
# Add items
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[
|
|
||||||
EpisodeIdentifier(season=1, episode=1),
|
|
||||||
EpisodeIdentifier(season=1, episode=2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = await download_service.get_queue_stats()
|
|
||||||
|
|
||||||
assert stats.total_items == 2
|
|
||||||
assert stats.pending_count == 2
|
|
||||||
assert stats.active_count == 0
|
|
||||||
assert stats.completed_count == 0
|
|
||||||
assert stats.failed_count == 0
|
|
||||||
assert stats.total_downloaded_mb == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueueControl:
|
|
||||||
"""Test queue control operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pause_queue(self, download_service):
|
|
||||||
"""Test pausing the queue."""
|
|
||||||
await download_service.pause_queue()
|
|
||||||
assert download_service._is_paused is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resume_queue(self, download_service):
|
|
||||||
"""Test resuming the queue."""
|
|
||||||
await download_service.pause_queue()
|
|
||||||
await download_service.resume_queue()
|
|
||||||
assert download_service._is_paused is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_clear_completed(self, download_service):
|
|
||||||
"""Test clearing completed downloads."""
|
|
||||||
# Manually add completed item
|
|
||||||
completed_item = DownloadItem(
|
|
||||||
id="completed-1",
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episode=EpisodeIdentifier(season=1, episode=1),
|
|
||||||
status=DownloadStatus.COMPLETED,
|
|
||||||
)
|
|
||||||
download_service._completed_items.append(completed_item)
|
|
||||||
|
|
||||||
count = await download_service.clear_completed()
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
assert len(download_service._completed_items) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPersistence:
|
|
||||||
"""Test queue persistence functionality."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_persistence(self, download_service):
|
|
||||||
"""Test that queue state is persisted to disk."""
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Persistence file should exist
|
|
||||||
persistence_path = Path(download_service._persistence_path)
|
|
||||||
assert persistence_path.exists()
|
|
||||||
|
|
||||||
# Check file contents
|
|
||||||
with open(persistence_path, "r") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
assert len(data["pending"]) == 1
|
|
||||||
assert data["pending"][0]["serie_id"] == "series-1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_queue_recovery_after_restart(
|
|
||||||
self, mock_anime_service, temp_persistence_path
|
|
||||||
):
|
|
||||||
"""Test that queue is recovered after service restart."""
|
|
||||||
# Create and populate first service
|
|
||||||
service1 = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
persistence_path=temp_persistence_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
await service1.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[
|
|
||||||
EpisodeIdentifier(season=1, episode=1),
|
|
||||||
EpisodeIdentifier(season=1, episode=2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new service with same persistence path
|
|
||||||
service2 = DownloadService(
|
|
||||||
anime_service=mock_anime_service,
|
|
||||||
persistence_path=temp_persistence_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should recover pending items
|
|
||||||
assert len(service2._pending_queue) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestRetryLogic:
|
|
||||||
"""Test retry logic for failed downloads."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retry_failed_items(self, download_service):
|
|
||||||
"""Test retrying failed downloads."""
|
|
||||||
# Manually add failed item
|
|
||||||
failed_item = DownloadItem(
|
|
||||||
id="failed-1",
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episode=EpisodeIdentifier(season=1, episode=1),
|
|
||||||
status=DownloadStatus.FAILED,
|
|
||||||
retry_count=0,
|
|
||||||
error="Test error",
|
|
||||||
)
|
|
||||||
download_service._failed_items.append(failed_item)
|
|
||||||
|
|
||||||
retried_ids = await download_service.retry_failed()
|
|
||||||
|
|
||||||
assert len(retried_ids) == 1
|
|
||||||
assert len(download_service._failed_items) == 0
|
|
||||||
assert len(download_service._pending_queue) == 1
|
|
||||||
assert download_service._pending_queue[0].retry_count == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_max_retries_not_exceeded(self, download_service):
|
|
||||||
"""Test that items with max retries are not retried."""
|
|
||||||
# Create item with max retries
|
|
||||||
failed_item = DownloadItem(
|
|
||||||
id="failed-1",
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episode=EpisodeIdentifier(season=1, episode=1),
|
|
||||||
status=DownloadStatus.FAILED,
|
|
||||||
retry_count=3, # Max retries
|
|
||||||
error="Test error",
|
|
||||||
)
|
|
||||||
download_service._failed_items.append(failed_item)
|
|
||||||
|
|
||||||
retried_ids = await download_service.retry_failed()
|
|
||||||
|
|
||||||
assert len(retried_ids) == 0
|
|
||||||
assert len(download_service._failed_items) == 1
|
|
||||||
assert len(download_service._pending_queue) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestBroadcastCallbacks:
|
|
||||||
"""Test WebSocket broadcast functionality."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_broadcast_callback(self, download_service):
|
|
||||||
"""Test setting broadcast callback."""
|
|
||||||
mock_callback = AsyncMock()
|
|
||||||
download_service.set_broadcast_callback(mock_callback)
|
|
||||||
|
|
||||||
assert download_service._broadcast_callback == mock_callback
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_on_queue_update(self, download_service):
|
|
||||||
"""Test that broadcasts are sent on queue updates."""
|
|
||||||
mock_callback = AsyncMock()
|
|
||||||
download_service.set_broadcast_callback(mock_callback)
|
|
||||||
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Allow async callback to execute
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
# Verify callback was called
|
|
||||||
mock_callback.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestServiceLifecycle:
|
|
||||||
"""Test service start and stop operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_service(self, download_service):
|
|
||||||
"""Test starting the service."""
|
|
||||||
await download_service.start()
|
|
||||||
assert download_service._is_running is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_service(self, download_service):
|
|
||||||
"""Test stopping the service."""
|
|
||||||
await download_service.start()
|
|
||||||
await download_service.stop()
|
|
||||||
assert download_service._is_running is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_already_running(self, download_service):
|
|
||||||
"""Test starting service when already running."""
|
|
||||||
await download_service.start()
|
|
||||||
await download_service.start() # Should not raise error
|
|
||||||
assert download_service._is_running is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHandling:
|
|
||||||
"""Test error handling in download service."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reorder_nonexistent_item(self, download_service):
|
|
||||||
"""Test reordering non-existent item raises error."""
|
|
||||||
with pytest.raises(DownloadServiceError):
|
|
||||||
await download_service.reorder_queue("nonexistent-id", 0)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_failure_moves_to_failed(self, download_service):
|
|
||||||
"""Test that download failures are handled correctly."""
|
|
||||||
# Mock download to fail
|
|
||||||
download_service._anime_service.download = AsyncMock(
|
|
||||||
side_effect=Exception("Download failed")
|
|
||||||
)
|
|
||||||
|
|
||||||
await download_service.add_to_queue(
|
|
||||||
serie_id="series-1",
|
|
||||||
serie_name="Test Series",
|
|
||||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process the download
|
|
||||||
item = download_service._pending_queue.popleft()
|
|
||||||
await download_service._process_download(item)
|
|
||||||
|
|
||||||
# Item should be in failed queue
|
|
||||||
assert len(download_service._failed_items) == 1
|
|
||||||
assert (
|
|
||||||
download_service._failed_items[0].status == DownloadStatus.FAILED
|
|
||||||
)
|
|
||||||
assert download_service._failed_items[0].error is not None
|
|
||||||
@ -1,499 +0,0 @@
|
|||||||
"""Unit tests for ProgressService.
|
|
||||||
|
|
||||||
This module contains comprehensive tests for the progress tracking service,
|
|
||||||
including progress lifecycle, broadcasting, error handling, and concurrency.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.services.progress_service import (
|
|
||||||
ProgressService,
|
|
||||||
ProgressServiceError,
|
|
||||||
ProgressStatus,
|
|
||||||
ProgressType,
|
|
||||||
ProgressUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProgressUpdate:
|
|
||||||
"""Test ProgressUpdate dataclass."""
|
|
||||||
|
|
||||||
def test_progress_update_creation(self):
|
|
||||||
"""Test creating a progress update."""
|
|
||||||
update = ProgressUpdate(
|
|
||||||
id="test-1",
|
|
||||||
type=ProgressType.DOWNLOAD,
|
|
||||||
status=ProgressStatus.STARTED,
|
|
||||||
title="Test Download",
|
|
||||||
message="Starting download",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.id == "test-1"
|
|
||||||
assert update.type == ProgressType.DOWNLOAD
|
|
||||||
assert update.status == ProgressStatus.STARTED
|
|
||||||
assert update.title == "Test Download"
|
|
||||||
assert update.message == "Starting download"
|
|
||||||
assert update.total == 100
|
|
||||||
assert update.current == 0
|
|
||||||
assert update.percent == 0.0
|
|
||||||
|
|
||||||
def test_progress_update_to_dict(self):
|
|
||||||
"""Test converting progress update to dictionary."""
|
|
||||||
update = ProgressUpdate(
|
|
||||||
id="test-1",
|
|
||||||
type=ProgressType.SCAN,
|
|
||||||
status=ProgressStatus.IN_PROGRESS,
|
|
||||||
title="Test Scan",
|
|
||||||
message="Scanning files",
|
|
||||||
current=50,
|
|
||||||
total=100,
|
|
||||||
metadata={"test_key": "test_value"},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = update.to_dict()
|
|
||||||
|
|
||||||
assert result["id"] == "test-1"
|
|
||||||
assert result["type"] == "scan"
|
|
||||||
assert result["status"] == "in_progress"
|
|
||||||
assert result["title"] == "Test Scan"
|
|
||||||
assert result["message"] == "Scanning files"
|
|
||||||
assert result["current"] == 50
|
|
||||||
assert result["total"] == 100
|
|
||||||
assert result["percent"] == 0.0
|
|
||||||
assert result["metadata"]["test_key"] == "test_value"
|
|
||||||
assert "started_at" in result
|
|
||||||
assert "updated_at" in result
|
|
||||||
|
|
||||||
|
|
||||||
class TestProgressService:
|
|
||||||
"""Test ProgressService class."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(self):
|
|
||||||
"""Create a fresh ProgressService instance for each test."""
|
|
||||||
return ProgressService()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_broadcast(self):
|
|
||||||
"""Create a mock broadcast callback."""
|
|
||||||
return AsyncMock()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_progress(self, service):
|
|
||||||
"""Test starting a new progress operation."""
|
|
||||||
update = await service.start_progress(
|
|
||||||
progress_id="download-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Downloading episode",
|
|
||||||
total=1000,
|
|
||||||
message="Starting...",
|
|
||||||
metadata={"episode": "S01E01"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.id == "download-1"
|
|
||||||
assert update.type == ProgressType.DOWNLOAD
|
|
||||||
assert update.status == ProgressStatus.STARTED
|
|
||||||
assert update.title == "Downloading episode"
|
|
||||||
assert update.total == 1000
|
|
||||||
assert update.message == "Starting..."
|
|
||||||
assert update.metadata["episode"] == "S01E01"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_progress_duplicate_id(self, service):
|
|
||||||
"""Test starting progress with duplicate ID raises error."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ProgressServiceError, match="already exists"):
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test Duplicate",
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_progress(self, service):
|
|
||||||
"""Test updating an existing progress operation."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
update = await service.update_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
current=50,
|
|
||||||
message="Half way",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.current == 50
|
|
||||||
assert update.total == 100
|
|
||||||
assert update.percent == 50.0
|
|
||||||
assert update.message == "Half way"
|
|
||||||
assert update.status == ProgressStatus.IN_PROGRESS
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_progress_not_found(self, service):
|
|
||||||
"""Test updating non-existent progress raises error."""
|
|
||||||
with pytest.raises(ProgressServiceError, match="not found"):
|
|
||||||
await service.update_progress(
|
|
||||||
progress_id="nonexistent",
|
|
||||||
current=50,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_progress_percentage_calculation(self, service):
|
|
||||||
"""Test progress percentage is calculated correctly."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
total=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.update_progress(progress_id="test-1", current=50)
|
|
||||||
update = await service.get_progress("test-1")
|
|
||||||
assert update.percent == 25.0
|
|
||||||
|
|
||||||
await service.update_progress(progress_id="test-1", current=100)
|
|
||||||
update = await service.get_progress("test-1")
|
|
||||||
assert update.percent == 50.0
|
|
||||||
|
|
||||||
await service.update_progress(progress_id="test-1", current=200)
|
|
||||||
update = await service.get_progress("test-1")
|
|
||||||
assert update.percent == 100.0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_complete_progress(self, service):
|
|
||||||
"""Test completing a progress operation."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Test Scan",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.update_progress(progress_id="test-1", current=50)
|
|
||||||
|
|
||||||
update = await service.complete_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
message="Scan completed successfully",
|
|
||||||
metadata={"items_found": 42},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.status == ProgressStatus.COMPLETED
|
|
||||||
assert update.percent == 100.0
|
|
||||||
assert update.current == update.total
|
|
||||||
assert update.message == "Scan completed successfully"
|
|
||||||
assert update.metadata["items_found"] == 42
|
|
||||||
|
|
||||||
# Should be moved to history
|
|
||||||
active_progress = await service.get_all_active_progress()
|
|
||||||
assert "test-1" not in active_progress
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fail_progress(self, service):
|
|
||||||
"""Test failing a progress operation."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test Download",
|
|
||||||
)
|
|
||||||
|
|
||||||
update = await service.fail_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
error_message="Network timeout",
|
|
||||||
metadata={"retry_count": 3},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.status == ProgressStatus.FAILED
|
|
||||||
assert update.message == "Network timeout"
|
|
||||||
assert update.metadata["retry_count"] == 3
|
|
||||||
|
|
||||||
# Should be moved to history
|
|
||||||
active_progress = await service.get_all_active_progress()
|
|
||||||
assert "test-1" not in active_progress
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_progress(self, service):
|
|
||||||
"""Test cancelling a progress operation."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test Download",
|
|
||||||
)
|
|
||||||
|
|
||||||
update = await service.cancel_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
message="Cancelled by user",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update.status == ProgressStatus.CANCELLED
|
|
||||||
assert update.message == "Cancelled by user"
|
|
||||||
|
|
||||||
# Should be moved to history
|
|
||||||
active_progress = await service.get_all_active_progress()
|
|
||||||
assert "test-1" not in active_progress
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_progress(self, service):
|
|
||||||
"""Test retrieving progress by ID."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
progress = await service.get_progress("test-1")
|
|
||||||
assert progress is not None
|
|
||||||
assert progress.id == "test-1"
|
|
||||||
|
|
||||||
# Test non-existent progress
|
|
||||||
progress = await service.get_progress("nonexistent")
|
|
||||||
assert progress is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_all_active_progress(self, service):
|
|
||||||
"""Test retrieving all active progress operations."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="download-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Download 1",
|
|
||||||
)
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="download-2",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Download 2",
|
|
||||||
)
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="scan-1",
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Scan 1",
|
|
||||||
)
|
|
||||||
|
|
||||||
all_progress = await service.get_all_active_progress()
|
|
||||||
assert len(all_progress) == 3
|
|
||||||
assert "download-1" in all_progress
|
|
||||||
assert "download-2" in all_progress
|
|
||||||
assert "scan-1" in all_progress
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_all_active_progress_filtered(self, service):
|
|
||||||
"""Test retrieving active progress filtered by type."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="download-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Download 1",
|
|
||||||
)
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="download-2",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Download 2",
|
|
||||||
)
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="scan-1",
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Scan 1",
|
|
||||||
)
|
|
||||||
|
|
||||||
download_progress = await service.get_all_active_progress(
|
|
||||||
progress_type=ProgressType.DOWNLOAD
|
|
||||||
)
|
|
||||||
assert len(download_progress) == 2
|
|
||||||
assert "download-1" in download_progress
|
|
||||||
assert "download-2" in download_progress
|
|
||||||
assert "scan-1" not in download_progress
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_history_management(self, service):
|
|
||||||
"""Test progress history is maintained with size limit."""
|
|
||||||
# Start and complete multiple progress operations
|
|
||||||
for i in range(60): # More than max_history_size (50)
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id=f"test-{i}",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title=f"Test {i}",
|
|
||||||
)
|
|
||||||
await service.complete_progress(
|
|
||||||
progress_id=f"test-{i}",
|
|
||||||
message="Completed",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check that oldest entries were removed
|
|
||||||
history = service._history
|
|
||||||
assert len(history) <= 50
|
|
||||||
|
|
||||||
# Most recent should be in history
|
|
||||||
recent_progress = await service.get_progress("test-59")
|
|
||||||
assert recent_progress is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_callback(self, service, mock_broadcast):
|
|
||||||
"""Test broadcast callback is invoked correctly."""
|
|
||||||
service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify callback was called for start
|
|
||||||
mock_broadcast.assert_called_once()
|
|
||||||
call_args = mock_broadcast.call_args
|
|
||||||
assert call_args[1]["message_type"] == "download_progress"
|
|
||||||
assert call_args[1]["room"] == "download_progress"
|
|
||||||
assert "test-1" in str(call_args[1]["data"])
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_on_update(self, service, mock_broadcast):
|
|
||||||
"""Test broadcast on progress update."""
|
|
||||||
service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
mock_broadcast.reset_mock()
|
|
||||||
|
|
||||||
# Update with significant change (>1%)
|
|
||||||
await service.update_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
current=50,
|
|
||||||
force_broadcast=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have been called
|
|
||||||
assert mock_broadcast.call_count >= 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_on_complete(self, service, mock_broadcast):
|
|
||||||
"""Test broadcast on progress completion."""
|
|
||||||
service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.SCAN,
|
|
||||||
title="Test",
|
|
||||||
)
|
|
||||||
mock_broadcast.reset_mock()
|
|
||||||
|
|
||||||
await service.complete_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
message="Done",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have been called
|
|
||||||
mock_broadcast.assert_called_once()
|
|
||||||
call_args = mock_broadcast.call_args
|
|
||||||
assert "completed" in str(call_args[1]["data"]).lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_on_failure(self, service, mock_broadcast):
|
|
||||||
"""Test broadcast on progress failure."""
|
|
||||||
service.set_broadcast_callback(mock_broadcast)
|
|
||||||
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
)
|
|
||||||
mock_broadcast.reset_mock()
|
|
||||||
|
|
||||||
await service.fail_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
error_message="Test error",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have been called
|
|
||||||
mock_broadcast.assert_called_once()
|
|
||||||
call_args = mock_broadcast.call_args
|
|
||||||
assert "failed" in str(call_args[1]["data"]).lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_clear_history(self, service):
|
|
||||||
"""Test clearing progress history."""
|
|
||||||
# Create and complete some progress
|
|
||||||
for i in range(5):
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id=f"test-{i}",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title=f"Test {i}",
|
|
||||||
)
|
|
||||||
await service.complete_progress(
|
|
||||||
progress_id=f"test-{i}",
|
|
||||||
message="Done",
|
|
||||||
)
|
|
||||||
|
|
||||||
# History should not be empty
|
|
||||||
assert len(service._history) > 0
|
|
||||||
|
|
||||||
# Clear history
|
|
||||||
await service.clear_history()
|
|
||||||
|
|
||||||
# History should now be empty
|
|
||||||
assert len(service._history) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_progress_operations(self, service):
|
|
||||||
"""Test handling multiple concurrent progress operations."""
|
|
||||||
|
|
||||||
async def create_and_complete_progress(id_num: int):
|
|
||||||
"""Helper to create and complete a progress."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id=f"test-{id_num}",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title=f"Test {id_num}",
|
|
||||||
total=100,
|
|
||||||
)
|
|
||||||
for i in range(0, 101, 10):
|
|
||||||
await service.update_progress(
|
|
||||||
progress_id=f"test-{id_num}",
|
|
||||||
current=i,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
await service.complete_progress(
|
|
||||||
progress_id=f"test-{id_num}",
|
|
||||||
message="Done",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run multiple concurrent operations
|
|
||||||
tasks = [create_and_complete_progress(i) for i in range(10)]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
# All should be in history
|
|
||||||
for i in range(10):
|
|
||||||
progress = await service.get_progress(f"test-{i}")
|
|
||||||
assert progress is not None
|
|
||||||
assert progress.status == ProgressStatus.COMPLETED
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_with_metadata(self, service):
|
|
||||||
"""Test updating progress with metadata."""
|
|
||||||
await service.start_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
progress_type=ProgressType.DOWNLOAD,
|
|
||||||
title="Test",
|
|
||||||
metadata={"initial": "value"},
|
|
||||||
)
|
|
||||||
|
|
||||||
await service.update_progress(
|
|
||||||
progress_id="test-1",
|
|
||||||
current=50,
|
|
||||||
metadata={"additional": "data", "speed": 1.5},
|
|
||||||
)
|
|
||||||
|
|
||||||
progress = await service.get_progress("test-1")
|
|
||||||
assert progress.metadata["initial"] == "value"
|
|
||||||
assert progress.metadata["additional"] == "data"
|
|
||||||
assert progress.metadata["speed"] == 1.5
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for template helper utilities.
|
|
||||||
|
|
||||||
This module tests the template helper functions.
|
|
||||||
"""
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.server.utils.template_helpers import (
|
|
||||||
get_base_context,
|
|
||||||
list_available_templates,
|
|
||||||
validate_template_exists,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateHelpers:
|
|
||||||
"""Test template helper utilities."""
|
|
||||||
|
|
||||||
def test_get_base_context(self):
|
|
||||||
"""Test that base context is created correctly."""
|
|
||||||
request = Mock()
|
|
||||||
context = get_base_context(request, "Test Title")
|
|
||||||
|
|
||||||
assert "request" in context
|
|
||||||
assert context["request"] == request
|
|
||||||
assert context["title"] == "Test Title"
|
|
||||||
assert context["app_name"] == "Aniworld Download Manager"
|
|
||||||
assert context["version"] == "1.0.0"
|
|
||||||
|
|
||||||
def test_get_base_context_default_title(self):
|
|
||||||
"""Test that default title is used."""
|
|
||||||
request = Mock()
|
|
||||||
context = get_base_context(request)
|
|
||||||
|
|
||||||
assert context["title"] == "Aniworld"
|
|
||||||
|
|
||||||
def test_validate_template_exists_true(self):
|
|
||||||
"""Test template validation for existing template."""
|
|
||||||
# index.html should exist
|
|
||||||
exists = validate_template_exists("index.html")
|
|
||||||
assert exists is True
|
|
||||||
|
|
||||||
def test_validate_template_exists_false(self):
|
|
||||||
"""Test template validation for non-existing template."""
|
|
||||||
exists = validate_template_exists("nonexistent.html")
|
|
||||||
assert exists is False
|
|
||||||
|
|
||||||
def test_list_available_templates(self):
|
|
||||||
"""Test listing available templates."""
|
|
||||||
templates = list_available_templates()
|
|
||||||
|
|
||||||
# Should be a list
|
|
||||||
assert isinstance(templates, list)
|
|
||||||
|
|
||||||
# Should contain at least the main templates
|
|
||||||
expected_templates = [
|
|
||||||
"index.html",
|
|
||||||
"login.html",
|
|
||||||
"setup.html",
|
|
||||||
"queue.html",
|
|
||||||
"error.html"
|
|
||||||
]
|
|
||||||
for expected in expected_templates:
|
|
||||||
assert expected in templates, (
|
|
||||||
f"{expected} not found in templates list"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_list_available_templates_only_html(self):
|
|
||||||
"""Test that only HTML files are listed."""
|
|
||||||
templates = list_available_templates()
|
|
||||||
|
|
||||||
for template in templates:
|
|
||||||
assert template.endswith(".html")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("template_name", [
|
|
||||||
"index.html",
|
|
||||||
"login.html",
|
|
||||||
"setup.html",
|
|
||||||
"queue.html",
|
|
||||||
"error.html"
|
|
||||||
])
|
|
||||||
def test_all_required_templates_exist(self, template_name):
|
|
||||||
"""Test that all required templates exist."""
|
|
||||||
assert validate_template_exists(template_name), \
|
|
||||||
f"Required template {template_name} does not exist"
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for template integration and rendering.
|
|
||||||
|
|
||||||
This module tests that all HTML templates are properly integrated with FastAPI
|
|
||||||
and can be rendered correctly.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.server.fastapi_app import app
|
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateIntegration:
|
|
||||||
"""Test template integration with FastAPI."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(self):
|
|
||||||
"""Create test client."""
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
def test_index_template_renders(self, client):
|
|
||||||
"""Test that index.html renders successfully."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
assert b"AniWorld Manager" in response.content
|
|
||||||
assert b"/static/css/styles.css" in response.content
|
|
||||||
|
|
||||||
def test_login_template_renders(self, client):
|
|
||||||
"""Test that login.html renders successfully."""
|
|
||||||
response = client.get("/login")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
assert b"Login" in response.content
|
|
||||||
assert b"/static/css/styles.css" in response.content
|
|
||||||
|
|
||||||
def test_setup_template_renders(self, client):
|
|
||||||
"""Test that setup.html renders successfully."""
|
|
||||||
response = client.get("/setup")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
assert b"Setup" in response.content
|
|
||||||
assert b"/static/css/styles.css" in response.content
|
|
||||||
|
|
||||||
def test_queue_template_renders(self, client):
|
|
||||||
"""Test that queue.html renders successfully."""
|
|
||||||
response = client.get("/queue")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
assert b"Download Queue" in response.content
|
|
||||||
assert b"/static/css/styles.css" in response.content
|
|
||||||
|
|
||||||
def test_error_template_404(self, client):
|
|
||||||
"""Test that 404 error page renders correctly."""
|
|
||||||
response = client.get("/nonexistent-page")
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.headers["content-type"].startswith("text/html")
|
|
||||||
assert b"Error 404" in response.content or b"404" in response.content
|
|
||||||
|
|
||||||
def test_static_css_accessible(self, client):
|
|
||||||
"""Test that static CSS files are accessible."""
|
|
||||||
response = client.get("/static/css/styles.css")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "text/css" in response.headers.get("content-type", "")
|
|
||||||
|
|
||||||
def test_static_js_accessible(self, client):
|
|
||||||
"""Test that static JavaScript files are accessible."""
|
|
||||||
response = client.get("/static/js/app.js")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
def test_templates_include_theme_switching(self, client):
|
|
||||||
"""Test that templates include theme switching functionality."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
# Check for theme toggle button
|
|
||||||
assert b"theme-toggle" in response.content
|
|
||||||
# Check for data-theme attribute
|
|
||||||
assert b'data-theme="light"' in response.content
|
|
||||||
|
|
||||||
def test_templates_include_responsive_meta(self, client):
|
|
||||||
"""Test that templates include responsive viewport meta tag."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert b'name="viewport"' in response.content
|
|
||||||
assert b"width=device-width" in response.content
|
|
||||||
|
|
||||||
def test_templates_include_font_awesome(self, client):
|
|
||||||
"""Test that templates include Font Awesome icons."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert b"font-awesome" in response.content.lower()
|
|
||||||
|
|
||||||
def test_all_templates_have_correct_structure(self, client):
|
|
||||||
"""Test that all templates have correct HTML structure."""
|
|
||||||
pages = ["/", "/login", "/setup", "/queue"]
|
|
||||||
|
|
||||||
for page in pages:
|
|
||||||
response = client.get(page)
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.content
|
|
||||||
|
|
||||||
# Check for essential HTML elements
|
|
||||||
assert b"<!DOCTYPE html>" in content
|
|
||||||
assert b"<html" in content
|
|
||||||
assert b"<head>" in content
|
|
||||||
assert b"<body>" in content
|
|
||||||
assert b"</html>" in content
|
|
||||||
|
|
||||||
def test_templates_load_required_javascript(self, client):
|
|
||||||
"""Test that index template loads all required JavaScript files."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.content
|
|
||||||
|
|
||||||
# Check for main app.js
|
|
||||||
assert b"/static/js/app.js" in content
|
|
||||||
|
|
||||||
# Check for localization.js
|
|
||||||
assert b"/static/js/localization.js" in content
|
|
||||||
|
|
||||||
def test_templates_load_ux_features_css(self, client):
|
|
||||||
"""Test that templates load UX features CSS."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert b"/static/css/ux_features.css" in response.content
|
|
||||||
|
|
||||||
def test_queue_template_has_websocket_script(self, client):
|
|
||||||
"""Test that queue template includes WebSocket support."""
|
|
||||||
response = client.get("/queue")
|
|
||||||
assert response.status_code == 200
|
|
||||||
# Check for socket.io or WebSocket implementation
|
|
||||||
assert (
|
|
||||||
b"socket.io" in response.content or
|
|
||||||
b"WebSocket" in response.content
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_index_includes_search_functionality(self, client):
|
|
||||||
"""Test that index page includes search functionality."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.content
|
|
||||||
|
|
||||||
assert b"search-input" in content
|
|
||||||
assert b"search-btn" in content
|
|
||||||
|
|
||||||
def test_templates_accessibility_features(self, client):
|
|
||||||
"""Test that templates include accessibility features."""
|
|
||||||
response = client.get("/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.content
|
|
||||||
|
|
||||||
# Check for ARIA labels or roles
|
|
||||||
assert b"aria-" in content or b"role=" in content
|
|
||||||
@ -1,423 +0,0 @@
|
|||||||
"""Unit tests for WebSocket service."""
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import WebSocket
|
|
||||||
|
|
||||||
from src.server.services.websocket_service import (
|
|
||||||
ConnectionManager,
|
|
||||||
WebSocketService,
|
|
||||||
get_websocket_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConnectionManager:
|
|
||||||
"""Test cases for ConnectionManager class."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def manager(self):
|
|
||||||
"""Create a ConnectionManager instance for testing."""
|
|
||||||
return ConnectionManager()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_websocket(self):
|
|
||||||
"""Create a mock WebSocket instance."""
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
return ws
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect(self, manager, mock_websocket):
|
|
||||||
"""Test connecting a WebSocket client."""
|
|
||||||
connection_id = "test-conn-1"
|
|
||||||
metadata = {"user_id": "user123"}
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id, metadata)
|
|
||||||
|
|
||||||
mock_websocket.accept.assert_called_once()
|
|
||||||
assert connection_id in manager._active_connections
|
|
||||||
assert manager._connection_metadata[connection_id] == metadata
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_without_metadata(self, manager, mock_websocket):
|
|
||||||
"""Test connecting without metadata."""
|
|
||||||
connection_id = "test-conn-2"
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
|
|
||||||
assert connection_id in manager._active_connections
|
|
||||||
assert manager._connection_metadata[connection_id] == {}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect(self, manager, mock_websocket):
|
|
||||||
"""Test disconnecting a WebSocket client."""
|
|
||||||
connection_id = "test-conn-3"
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
|
|
||||||
await manager.disconnect(connection_id)
|
|
||||||
|
|
||||||
assert connection_id not in manager._active_connections
|
|
||||||
assert connection_id not in manager._connection_metadata
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_join_room(self, manager, mock_websocket):
|
|
||||||
"""Test joining a room."""
|
|
||||||
connection_id = "test-conn-4"
|
|
||||||
room = "downloads"
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
await manager.join_room(connection_id, room)
|
|
||||||
|
|
||||||
assert connection_id in manager._rooms[room]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_join_room_inactive_connection(self, manager):
|
|
||||||
"""Test joining a room with inactive connection."""
|
|
||||||
connection_id = "inactive-conn"
|
|
||||||
room = "downloads"
|
|
||||||
|
|
||||||
# Should not raise error, just log warning
|
|
||||||
await manager.join_room(connection_id, room)
|
|
||||||
|
|
||||||
assert connection_id not in manager._rooms.get(room, set())
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_leave_room(self, manager, mock_websocket):
|
|
||||||
"""Test leaving a room."""
|
|
||||||
connection_id = "test-conn-5"
|
|
||||||
room = "downloads"
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
await manager.join_room(connection_id, room)
|
|
||||||
await manager.leave_room(connection_id, room)
|
|
||||||
|
|
||||||
assert connection_id not in manager._rooms.get(room, set())
|
|
||||||
assert room not in manager._rooms # Empty room should be removed
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect_removes_from_all_rooms(
|
|
||||||
self, manager, mock_websocket
|
|
||||||
):
|
|
||||||
"""Test that disconnect removes connection from all rooms."""
|
|
||||||
connection_id = "test-conn-6"
|
|
||||||
rooms = ["room1", "room2", "room3"]
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
for room in rooms:
|
|
||||||
await manager.join_room(connection_id, room)
|
|
||||||
|
|
||||||
await manager.disconnect(connection_id)
|
|
||||||
|
|
||||||
for room in rooms:
|
|
||||||
assert connection_id not in manager._rooms.get(room, set())
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_personal_message(self, manager, mock_websocket):
|
|
||||||
"""Test sending a personal message to a connection."""
|
|
||||||
connection_id = "test-conn-7"
|
|
||||||
message = {"type": "test", "data": {"value": 123}}
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id)
|
|
||||||
await manager.send_personal_message(message, connection_id)
|
|
||||||
|
|
||||||
mock_websocket.send_json.assert_called_once_with(message)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_personal_message_inactive_connection(
|
|
||||||
self, manager, mock_websocket
|
|
||||||
):
|
|
||||||
"""Test sending message to inactive connection."""
|
|
||||||
connection_id = "inactive-conn"
|
|
||||||
message = {"type": "test", "data": {}}
|
|
||||||
|
|
||||||
# Should not raise error, just log warning
|
|
||||||
await manager.send_personal_message(message, connection_id)
|
|
||||||
|
|
||||||
mock_websocket.send_json.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast(self, manager):
|
|
||||||
"""Test broadcasting to all connections."""
|
|
||||||
connections = {}
|
|
||||||
for i in range(3):
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
conn_id = f"conn-{i}"
|
|
||||||
await manager.connect(ws, conn_id)
|
|
||||||
connections[conn_id] = ws
|
|
||||||
|
|
||||||
message = {"type": "broadcast", "data": {"value": 456}}
|
|
||||||
await manager.broadcast(message)
|
|
||||||
|
|
||||||
for ws in connections.values():
|
|
||||||
ws.send_json.assert_called_once_with(message)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_with_exclusion(self, manager):
|
|
||||||
"""Test broadcasting with excluded connections."""
|
|
||||||
connections = {}
|
|
||||||
for i in range(3):
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
conn_id = f"conn-{i}"
|
|
||||||
await manager.connect(ws, conn_id)
|
|
||||||
connections[conn_id] = ws
|
|
||||||
|
|
||||||
exclude = {"conn-1"}
|
|
||||||
message = {"type": "broadcast", "data": {"value": 789}}
|
|
||||||
await manager.broadcast(message, exclude=exclude)
|
|
||||||
|
|
||||||
connections["conn-0"].send_json.assert_called_once_with(message)
|
|
||||||
connections["conn-1"].send_json.assert_not_called()
|
|
||||||
connections["conn-2"].send_json.assert_called_once_with(message)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_to_room(self, manager):
|
|
||||||
"""Test broadcasting to a specific room."""
|
|
||||||
# Setup connections
|
|
||||||
room_members = {}
|
|
||||||
non_members = {}
|
|
||||||
|
|
||||||
for i in range(2):
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
conn_id = f"member-{i}"
|
|
||||||
await manager.connect(ws, conn_id)
|
|
||||||
await manager.join_room(conn_id, "downloads")
|
|
||||||
room_members[conn_id] = ws
|
|
||||||
|
|
||||||
for i in range(2):
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
conn_id = f"non-member-{i}"
|
|
||||||
await manager.connect(ws, conn_id)
|
|
||||||
non_members[conn_id] = ws
|
|
||||||
|
|
||||||
message = {"type": "room_broadcast", "data": {"room": "downloads"}}
|
|
||||||
await manager.broadcast_to_room(message, "downloads")
|
|
||||||
|
|
||||||
# Room members should receive message
|
|
||||||
for ws in room_members.values():
|
|
||||||
ws.send_json.assert_called_once_with(message)
|
|
||||||
|
|
||||||
# Non-members should not receive message
|
|
||||||
for ws in non_members.values():
|
|
||||||
ws.send_json.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_connection_count(self, manager, mock_websocket):
|
|
||||||
"""Test getting connection count."""
|
|
||||||
assert await manager.get_connection_count() == 0
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, "conn-1")
|
|
||||||
assert await manager.get_connection_count() == 1
|
|
||||||
|
|
||||||
ws2 = AsyncMock(spec=WebSocket)
|
|
||||||
ws2.accept = AsyncMock()
|
|
||||||
await manager.connect(ws2, "conn-2")
|
|
||||||
assert await manager.get_connection_count() == 2
|
|
||||||
|
|
||||||
await manager.disconnect("conn-1")
|
|
||||||
assert await manager.get_connection_count() == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_room_members(self, manager, mock_websocket):
|
|
||||||
"""Test getting room members."""
|
|
||||||
room = "test-room"
|
|
||||||
assert await manager.get_room_members(room) == []
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, "conn-1")
|
|
||||||
await manager.join_room("conn-1", room)
|
|
||||||
|
|
||||||
members = await manager.get_room_members(room)
|
|
||||||
assert "conn-1" in members
|
|
||||||
assert len(members) == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_connection_metadata(self, manager, mock_websocket):
|
|
||||||
"""Test getting connection metadata."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
metadata = {"user_id": "user123", "ip": "127.0.0.1"}
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id, metadata)
|
|
||||||
|
|
||||||
result = await manager.get_connection_metadata(connection_id)
|
|
||||||
assert result == metadata
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_connection_metadata(self, manager, mock_websocket):
|
|
||||||
"""Test updating connection metadata."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
initial_metadata = {"user_id": "user123"}
|
|
||||||
update = {"session_id": "session456"}
|
|
||||||
|
|
||||||
await manager.connect(mock_websocket, connection_id, initial_metadata)
|
|
||||||
await manager.update_connection_metadata(connection_id, update)
|
|
||||||
|
|
||||||
result = await manager.get_connection_metadata(connection_id)
|
|
||||||
assert result["user_id"] == "user123"
|
|
||||||
assert result["session_id"] == "session456"
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketService:
|
|
||||||
"""Test cases for WebSocketService class."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(self):
|
|
||||||
"""Create a WebSocketService instance for testing."""
|
|
||||||
return WebSocketService()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_websocket(self):
|
|
||||||
"""Create a mock WebSocket instance."""
|
|
||||||
ws = AsyncMock(spec=WebSocket)
|
|
||||||
ws.accept = AsyncMock()
|
|
||||||
ws.send_json = AsyncMock()
|
|
||||||
return ws
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect(self, service, mock_websocket):
|
|
||||||
"""Test connecting a client."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
user_id = "user123"
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id, user_id)
|
|
||||||
|
|
||||||
mock_websocket.accept.assert_called_once()
|
|
||||||
assert connection_id in service._manager._active_connections
|
|
||||||
metadata = await service._manager.get_connection_metadata(
|
|
||||||
connection_id
|
|
||||||
)
|
|
||||||
assert metadata["user_id"] == user_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect(self, service, mock_websocket):
|
|
||||||
"""Test disconnecting a client."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service.disconnect(connection_id)
|
|
||||||
|
|
||||||
assert connection_id not in service._manager._active_connections
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_download_progress(self, service, mock_websocket):
|
|
||||||
"""Test broadcasting download progress."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
download_id = "download123"
|
|
||||||
progress_data = {
|
|
||||||
"percent": 50.0,
|
|
||||||
"speed_mbps": 2.5,
|
|
||||||
"eta_seconds": 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service._manager.join_room(connection_id, "downloads")
|
|
||||||
await service.broadcast_download_progress(download_id, progress_data)
|
|
||||||
|
|
||||||
# Verify message was sent
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
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"]["percent"] == 50.0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_download_complete(self, service, mock_websocket):
|
|
||||||
"""Test broadcasting download completion."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
download_id = "download123"
|
|
||||||
result_data = {"file_path": "/path/to/file.mp4"}
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service._manager.join_room(connection_id, "downloads")
|
|
||||||
await service.broadcast_download_complete(download_id, result_data)
|
|
||||||
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
call_args = mock_websocket.send_json.call_args[0][0]
|
|
||||||
assert call_args["type"] == "download_complete"
|
|
||||||
assert call_args["data"]["download_id"] == download_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_download_failed(self, service, mock_websocket):
|
|
||||||
"""Test broadcasting download failure."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
download_id = "download123"
|
|
||||||
error_data = {"error_message": "Network error"}
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service._manager.join_room(connection_id, "downloads")
|
|
||||||
await service.broadcast_download_failed(download_id, error_data)
|
|
||||||
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
call_args = mock_websocket.send_json.call_args[0][0]
|
|
||||||
assert call_args["type"] == "download_failed"
|
|
||||||
assert call_args["data"]["download_id"] == download_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_queue_status(self, service, mock_websocket):
|
|
||||||
"""Test broadcasting queue status."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
status_data = {"active": 2, "pending": 5, "completed": 10}
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service._manager.join_room(connection_id, "downloads")
|
|
||||||
await service.broadcast_queue_status(status_data)
|
|
||||||
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
call_args = mock_websocket.send_json.call_args[0][0]
|
|
||||||
assert call_args["type"] == "queue_status"
|
|
||||||
assert call_args["data"] == status_data
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_system_message(self, service, mock_websocket):
|
|
||||||
"""Test broadcasting system message."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
message_type = "maintenance"
|
|
||||||
data = {"message": "System will be down for maintenance"}
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service.broadcast_system_message(message_type, data)
|
|
||||||
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
call_args = mock_websocket.send_json.call_args[0][0]
|
|
||||||
assert call_args["type"] == f"system_{message_type}"
|
|
||||||
assert call_args["data"] == data
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_error(self, service, mock_websocket):
|
|
||||||
"""Test sending error message."""
|
|
||||||
connection_id = "test-conn"
|
|
||||||
error_message = "Invalid request"
|
|
||||||
error_code = "INVALID_REQUEST"
|
|
||||||
|
|
||||||
await service.connect(mock_websocket, connection_id)
|
|
||||||
await service.send_error(connection_id, error_message, error_code)
|
|
||||||
|
|
||||||
assert mock_websocket.send_json.called
|
|
||||||
call_args = mock_websocket.send_json.call_args[0][0]
|
|
||||||
assert call_args["type"] == "error"
|
|
||||||
assert call_args["data"]["code"] == error_code
|
|
||||||
assert call_args["data"]["message"] == error_message
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetWebSocketService:
|
|
||||||
"""Test cases for get_websocket_service factory function."""
|
|
||||||
|
|
||||||
def test_singleton_pattern(self):
|
|
||||||
"""Test that get_websocket_service returns singleton instance."""
|
|
||||||
service1 = get_websocket_service()
|
|
||||||
service2 = get_websocket_service()
|
|
||||||
|
|
||||||
assert service1 is service2
|
|
||||||
|
|
||||||
def test_returns_websocket_service(self):
|
|
||||||
"""Test that factory returns WebSocketService instance."""
|
|
||||||
service = get_websocket_service()
|
|
||||||
|
|
||||||
assert isinstance(service, WebSocketService)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user