Compare commits
No commits in common. "9096afbaceaf4f88ac518fb1770e00175d6b1cea" and "6a695966bfc74ef2817bce4cbb708ad8e19d5db2" have entirely different histories.
9096afbace
...
6a695966bf
@ -1,5 +1,4 @@
|
||||
# Aniworld Web Application Infrastructure
|
||||
|
||||
conda activate AniWorld
|
||||
|
||||
## Project Structure
|
||||
@ -8,13 +7,7 @@ conda activate AniWorld
|
||||
/home/lukas/Volume/repo/Aniworld/
|
||||
├── src/
|
||||
│ ├── server/ # FastAPI web application
|
||||
│ │ ├── fastapi_app.py # Main FastAPI application (simplified)
|
||||
│ │ ├── main.py # FastAPI application entry point
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
│ │ │ ├── __init__.py # Controllers package
|
||||
│ │ │ ├── health_controller.py # Health check endpoints
|
||||
│ │ │ ├── page_controller.py # HTML page routes
|
||||
│ │ │ └── error_controller.py # Error handling controllers
|
||||
│ │ ├── api/ # API route handlers
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── auth.py # Authentication endpoints
|
||||
@ -34,12 +27,10 @@ conda activate AniWorld
|
||||
│ │ │ ├── config_service.py
|
||||
│ │ │ ├── anime_service.py
|
||||
│ │ │ └── download_service.py
|
||||
│ │ ├── utils/ # Utility functions
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── security.py
|
||||
│ │ │ ├── dependencies.py # Dependency injection
|
||||
│ │ │ └── templates.py # Shared Jinja2 template config
|
||||
│ │ └── web/ # Frontend assets
|
||||
│ │ ├── static/ # Static web assets
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── images/
|
||||
│ │ ├── templates/ # Jinja2 HTML templates
|
||||
│ │ │ ├── base.html
|
||||
│ │ │ ├── login.html
|
||||
@ -48,10 +39,10 @@ conda activate AniWorld
|
||||
│ │ │ ├── anime.html
|
||||
│ │ │ ├── download.html
|
||||
│ │ │ └── search.html
|
||||
│ │ └── static/ # Static web assets
|
||||
│ │ ├── css/
|
||||
│ │ ├── js/
|
||||
│ │ └── images/
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── security.py
|
||||
│ │ └── dependencies.py
|
||||
│ ├── core/ # Existing core functionality
|
||||
│ └── cli/ # Existing CLI application
|
||||
├── data/ # Application data storage
|
||||
@ -71,7 +62,6 @@ conda activate AniWorld
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
|
||||
- **FastAPI**: Modern Python web framework for building APIs
|
||||
- **Uvicorn**: ASGI server for running FastAPI applications
|
||||
- **SQLite**: Lightweight database for storing anime library and configuration
|
||||
@ -79,30 +69,19 @@ conda activate AniWorld
|
||||
- **Jinja2**: Template engine for server-side rendering
|
||||
|
||||
### Frontend
|
||||
|
||||
- **HTML5/CSS3**: Core web technologies
|
||||
- **JavaScript (Vanilla)**: Client-side interactivity
|
||||
- **Bootstrap 5**: CSS framework for responsive design
|
||||
- **HTMX**: Modern approach for dynamic web applications
|
||||
|
||||
### Security
|
||||
|
||||
- **Passlib**: Password hashing and verification
|
||||
- **python-jose**: JWT token handling
|
||||
- **bcrypt**: Secure password hashing
|
||||
|
||||
### Authentication Models & Sessions
|
||||
|
||||
- Authentication request/response Pydantic models live in `src/server/models/auth.py`.
|
||||
- Sessions are represented by `SessionModel` and can be backed by an in-memory
|
||||
store or a persistent table depending on deployment needs. JWTs are used for
|
||||
stateless authentication by default; a persistent session store may be
|
||||
configured in production to enable revocation and long-lived sessions.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Configuration**: JSON files in `data/` directory
|
||||
- **Anime Library**: SQLite database with series information
|
||||
- **Download Queue**: JSON file with current download status
|
||||
@ -111,45 +90,38 @@ conda activate AniWorld
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/login` - Master password authentication
|
||||
- `POST /api/auth/logout` - Logout and invalidate session
|
||||
- `GET /api/auth/status` - Check authentication status
|
||||
|
||||
### Configuration
|
||||
|
||||
- `GET /api/config` - Get current configuration
|
||||
- `PUT /api/config` - Update configuration
|
||||
- `POST /api/setup` - Initial setup
|
||||
|
||||
### Anime Management
|
||||
|
||||
- `GET /api/anime` - List anime with missing episodes
|
||||
- `POST /api/anime/{id}/download` - Add episodes to download queue
|
||||
- `GET /api/anime/{id}` - Get anime details
|
||||
|
||||
### Download Management
|
||||
|
||||
- `GET /api/downloads` - Get download queue status
|
||||
- `DELETE /api/downloads/{id}` - Remove from queue
|
||||
- `POST /api/downloads/priority` - Change download priority
|
||||
|
||||
### Search
|
||||
|
||||
- `GET /api/search?q={query}` - Search for anime
|
||||
- `POST /api/search/add` - Add anime to library
|
||||
|
||||
## Logging
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **INFO**: General application information
|
||||
- **WARNING**: Potential issues that don't stop execution
|
||||
- **ERROR**: Errors that affect functionality
|
||||
- **DEBUG**: Detailed debugging information (development only)
|
||||
|
||||
### Log Files
|
||||
|
||||
- `app.log`: General application logs
|
||||
- `download.log`: Download-specific operations
|
||||
- `error.log`: Error and exception logs
|
||||
@ -162,125 +134,3 @@ conda activate AniWorld
|
||||
- Rate limiting on API endpoints
|
||||
- HTTPS enforcement in production
|
||||
- Secure file path handling to prevent directory traversal
|
||||
|
||||
### Authentication Service
|
||||
|
||||
- A lightweight authentication service is provided by
|
||||
`src/server/services/auth_service.py`.
|
||||
- Uses bcrypt (passlib) to hash the master password and issues JWTs for
|
||||
stateless sessions. Tokens are signed with the `JWT_SECRET_KEY` from
|
||||
configuration and expire based on `SESSION_TIMEOUT_HOURS`.
|
||||
- Failed login attempts are tracked in-memory and a temporary lockout is
|
||||
applied after multiple failures. For multi-process deployments, move
|
||||
this state to a shared store (Redis) and persist the master password
|
||||
hash in a secure config store.
|
||||
|
||||
## Recent Infrastructure Changes
|
||||
|
||||
### Route Controller Refactoring (October 2025)
|
||||
|
||||
Restructured the FastAPI application to use a controller-based architecture for better code organization and maintainability.
|
||||
|
||||
#### Changes Made
|
||||
|
||||
1. **Created Controller Structure**:
|
||||
|
||||
- `src/server/controllers/` - New directory for route controllers
|
||||
- `src/server/controllers/__init__.py` - Controllers package initialization
|
||||
- `src/server/controllers/health_controller.py` - Health check endpoints
|
||||
- `src/server/controllers/page_controller.py` - HTML page routes
|
||||
- `src/server/controllers/error_controller.py` - Error handling controllers
|
||||
|
||||
2. **Shared Template Configuration**:
|
||||
|
||||
- `src/server/utils/templates.py` - Centralized Jinja2 template configuration
|
||||
- Fixed template path resolution for proper template loading
|
||||
|
||||
3. **Main Application Updates**:
|
||||
|
||||
- `src/server/fastapi_app.py` - Refactored to use controller routers
|
||||
- Removed direct route definitions from main file
|
||||
- Added router inclusion using `app.include_router()`
|
||||
- Simplified error handlers to delegate to controller functions
|
||||
|
||||
4. **Fixed Import Issues**:
|
||||
- Resolved circular import in `src/core/__init__.py`
|
||||
- Removed non-existent `application` module import
|
||||
|
||||
#### Controller Architecture
|
||||
|
||||
**Health Controller** (`health_controller.py`):
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
@router.get("") - Health check endpoint
|
||||
```
|
||||
|
||||
**Page Controller** (`page_controller.py`):
|
||||
|
||||
```python
|
||||
router = APIRouter(tags=["pages"])
|
||||
@router.get("/") - Main application page
|
||||
@router.get("/setup") - Setup page
|
||||
@router.get("/login") - Login page
|
||||
@router.get("/queue") - Download queue page
|
||||
```
|
||||
|
||||
**Error Controller** (`error_controller.py`):
|
||||
|
||||
```python
|
||||
async def not_found_handler() - Custom 404 error handling
|
||||
async def server_error_handler() - Custom 500 error handling
|
||||
```
|
||||
|
||||
#### Benefits of the New Structure
|
||||
|
||||
- **Separation of Concerns**: Each controller handles specific functionality
|
||||
- **Modularity**: Easy to add new controllers and routes
|
||||
- **Testability**: Controllers can be tested independently
|
||||
- **Maintainability**: Cleaner code organization and easier debugging
|
||||
- **Scalability**: Simple to extend with new features
|
||||
|
||||
#### Verified Working Endpoints
|
||||
|
||||
All endpoints tested and confirmed working:
|
||||
|
||||
- Health: `/health` → Returns `{"status": "healthy", ...}`
|
||||
- Root: `/` → Serves main application page
|
||||
- Setup: `/setup` → Serves setup page
|
||||
- Auth API: `/api/auth/*` → Endpoints for setup, login, logout and status (JWT-based)
|
||||
- Login: `/login` → Serves login page
|
||||
- Queue: `/queue` → Serves download queue page
|
||||
|
||||
#### File Structure After Refactoring
|
||||
|
||||
```
|
||||
src/server/
|
||||
├── fastapi_app.py # Main FastAPI application (simplified)
|
||||
├── controllers/ # NEW: Route controllers
|
||||
│ ├── __init__.py # Controllers package
|
||||
├── utils/
|
||||
│ ├── dependencies.py # Existing dependency injection
|
||||
│ └── templates.py # NEW: Shared Jinja2 template config
|
||||
└── web/ # Existing frontend assets
|
||||
├── templates/ # HTML templates
|
||||
└── static/ # CSS, JS, images
|
||||
```
|
||||
|
||||
### Authentication Middleware (October 2025)
|
||||
|
||||
An authentication middleware component was added to the FastAPI
|
||||
application to centralize token parsing and provide lightweight
|
||||
protection of authentication endpoints:
|
||||
|
||||
- `src/server/middleware/auth.py` implements:
|
||||
- Bearer JWT parsing and session attachment to `request.state.session`
|
||||
- A simple per-IP in-memory rate limiter applied to
|
||||
`/api/auth/login` and `/api/auth/setup` (default 5 requests/minute)
|
||||
|
||||
Notes:
|
||||
|
||||
- This is intentionally simple and designed for single-process
|
||||
deployments. For production use across multiple workers or hosts,
|
||||
replace the in-memory limiter with a distributed store (e.g. Redis)
|
||||
and add a persistent token revocation list if needed.
|
||||
|
||||
777
instructions.md
777
instructions.md
@ -15,6 +15,15 @@ The goal is to create a FastAPI-based web application that provides a modern int
|
||||
- **Type Hints**: Use comprehensive type annotations
|
||||
- **Error Handling**: Proper exception handling and logging
|
||||
|
||||
## How you work
|
||||
|
||||
1. Task the next task
|
||||
2. Process the task
|
||||
3. Make Tests.
|
||||
4. Remove task from instructions.md
|
||||
5. Commit in git
|
||||
6. goto 1.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
The tasks should be completed in the following order to ensure proper dependencies and logical progression:
|
||||
@ -32,691 +41,365 @@ The tasks should be completed in the following order to ensure proper dependenci
|
||||
11. **Deployment and Configuration** - Production setup
|
||||
12. **Documentation and Error Handling** - Final documentation and error handling
|
||||
|
||||
# make the following steps for each task or subtask. make sure you do not miss one
|
||||
|
||||
1. Task the next task
|
||||
2. Process the task
|
||||
3. Make Tests.
|
||||
4. Remove task from instructions.md.
|
||||
5. Update infrastructure.md, but only add text that belongs to a infrastructure doc.
|
||||
6. Commit in git
|
||||
|
||||
## Core Tasks
|
||||
|
||||
### 1. Project Structure Setup
|
||||
|
||||
#### [] Create main FastAPI application structure
|
||||
|
||||
- Create `src/server/main.py`
|
||||
- Configure FastAPI app with CORS, middleware
|
||||
- Set up static file serving for existing frontend assets
|
||||
- Configure Jinja2 templates
|
||||
- Add health check endpoint
|
||||
|
||||
#### [] Set up dependency injection system
|
||||
|
||||
- Create `src/server/utils/dependencies.py`
|
||||
- Implement SeriesApp dependency injection
|
||||
- Add database session dependency
|
||||
- Create authentication dependency
|
||||
|
||||
#### [] Configure logging system
|
||||
|
||||
- Create `src/server/utils/logging.py`
|
||||
- Set up structured logging with multiple handlers
|
||||
- Configure log rotation and cleanup
|
||||
- Add request/response logging middleware
|
||||
|
||||
### 2. Authentication System
|
||||
|
||||
#### [] Implement authentication models
|
||||
|
||||
- Create `src/server/models/auth.py`
|
||||
- Define LoginRequest, LoginResponse models
|
||||
- Add SetupRequest, AuthStatus models
|
||||
- Include session management models
|
||||
|
||||
#### [] Create authentication service
|
||||
|
||||
- Create `src/server/services/auth_service.py`
|
||||
- Implement master password setup/validation
|
||||
- Add session management with JWT tokens
|
||||
- Include failed attempt tracking and lockout
|
||||
- Add password strength validation
|
||||
|
||||
#### [] Implement authentication API endpoints
|
||||
|
||||
- Create `src/server/api/auth.py`
|
||||
- Add POST `/api/auth/setup` - initial setup
|
||||
- Add POST `/api/auth/login` - login endpoint
|
||||
- Add POST `/api/auth/logout` - logout endpoint
|
||||
- Add GET `/api/auth/status` - authentication status
|
||||
|
||||
#### [] Create authentication middleware
|
||||
|
||||
- Create `src/server/middleware/auth.py`
|
||||
- Implement JWT token validation
|
||||
- Add request authentication checking
|
||||
- Include rate limiting for auth endpoints
|
||||
|
||||
### 3. Configuration Management
|
||||
|
||||
#### [] Implement configuration models
|
||||
|
||||
- []Create `src/server/models/config.py`
|
||||
- []Define ConfigResponse, ConfigUpdate models
|
||||
- []Add SchedulerConfig, LoggingConfig models
|
||||
- []Include ValidationResult model
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Add GET/POST `/api/config/backup` - backup management
|
||||
|
||||
### 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 `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
|
||||
- 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
|
||||
- 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 `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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Update download service to emit progress
|
||||
- Add scan progress notifications
|
||||
- Include queue change broadcasts
|
||||
- Add error/completion notifications
|
||||
|
||||
### 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- []Review and integrate existing CSS files in `src/server/web/static/css/`
|
||||
- []Ensure styling works with FastAPI static file serving
|
||||
- []Maintain existing responsive design and theme support
|
||||
- []Update any hardcoded paths if necessary
|
||||
- Review and integrate existing CSS files in `src/server/web/static/css/`
|
||||
- Ensure styling works with FastAPI static file serving
|
||||
- Maintain existing responsive design and theme support
|
||||
- Update any hardcoded paths if necessary
|
||||
|
||||
#### [] Update frontend-backend integration
|
||||
|
||||
- []Ensure existing JavaScript calls match new API endpoints
|
||||
- []Update authentication flow to work with new auth system
|
||||
- []Verify WebSocket events match new service implementations
|
||||
- []Test all existing UI functionality with new backend
|
||||
- Ensure existing JavaScript calls match new API endpoints
|
||||
- Update authentication flow to work with new auth system
|
||||
- Verify WebSocket events match new service implementations
|
||||
- Test all existing UI functionality with new backend
|
||||
|
||||
### 8. Core Logic Integration
|
||||
|
||||
#### [] Enhance SeriesApp for web integration
|
||||
|
||||
- []Update `src/core/SeriesApp.py`
|
||||
- []Add async callback support
|
||||
- []Implement progress reporting
|
||||
- []Include better error handling
|
||||
- []Add cancellation support
|
||||
- Update `src/core/SeriesApp.py`
|
||||
- Add async callback support
|
||||
- Implement progress reporting
|
||||
- Include better error handling
|
||||
- Add cancellation support
|
||||
|
||||
#### [] Create progress callback system
|
||||
|
||||
- []Add progress callback interface
|
||||
- []Implement scan progress reporting
|
||||
- []Add download progress tracking
|
||||
- []Include error/completion callbacks
|
||||
- Add progress callback interface
|
||||
- Implement scan progress reporting
|
||||
- Add download progress tracking
|
||||
- Include error/completion callbacks
|
||||
|
||||
#### [] Add configuration persistence
|
||||
|
||||
- []Implement configuration file management
|
||||
- []Add settings validation
|
||||
- []Include backup/restore functionality
|
||||
- []Add migration support for config updates
|
||||
- Implement configuration file management
|
||||
- Add settings validation
|
||||
- Include backup/restore functionality
|
||||
- Add migration support for config updates
|
||||
|
||||
### 9. Database Layer
|
||||
|
||||
#### [] Implement database models
|
||||
|
||||
- []Create `src/server/database/models.py`
|
||||
- []Add SQLAlchemy models for anime series
|
||||
- []Implement download queue persistence
|
||||
- []Include user session storage
|
||||
- Create `src/server/database/models.py`
|
||||
- Add SQLAlchemy models for anime series
|
||||
- Implement download queue persistence
|
||||
- Include user session storage
|
||||
|
||||
#### [] Create database service
|
||||
|
||||
- []Create `src/server/database/service.py`
|
||||
- []Add CRUD operations for anime data
|
||||
- []Implement queue persistence
|
||||
- []Include database migration support
|
||||
- Create `src/server/database/service.py`
|
||||
- Add CRUD operations for anime data
|
||||
- Implement queue persistence
|
||||
- Include database migration support
|
||||
|
||||
#### [] Add database initialization
|
||||
|
||||
- []Create `src/server/database/init.py`
|
||||
- []Implement database setup
|
||||
- []Add initial data migration
|
||||
- []Include schema validation
|
||||
- Create `src/server/database/init.py`
|
||||
- Implement database setup
|
||||
- Add initial data migration
|
||||
- Include schema validation
|
||||
|
||||
### 10. Testing
|
||||
|
||||
#### [] Create unit tests for services
|
||||
|
||||
- []Create `tests/unit/test_auth_service.py`
|
||||
- []Create `tests/unit/test_anime_service.py`
|
||||
- []Create `tests/unit/test_download_service.py`
|
||||
- []Create `tests/unit/test_config_service.py`
|
||||
- Create `tests/unit/test_auth_service.py`
|
||||
- Create `tests/unit/test_anime_service.py`
|
||||
- Create `tests/unit/test_download_service.py`
|
||||
- Create `tests/unit/test_config_service.py`
|
||||
|
||||
#### [] Create API endpoint tests
|
||||
|
||||
- []Create `tests/api/test_auth_endpoints.py`
|
||||
- []Create `tests/api/test_anime_endpoints.py`
|
||||
- []Create `tests/api/test_download_endpoints.py`
|
||||
- []Create `tests/api/test_config_endpoints.py`
|
||||
- Create `tests/api/test_auth_endpoints.py`
|
||||
- Create `tests/api/test_anime_endpoints.py`
|
||||
- Create `tests/api/test_download_endpoints.py`
|
||||
- Create `tests/api/test_config_endpoints.py`
|
||||
|
||||
#### [] Create integration tests
|
||||
|
||||
- []Create `tests/integration/test_download_flow.py`
|
||||
- []Create `tests/integration/test_auth_flow.py`
|
||||
- []Create `tests/integration/test_websocket.py`
|
||||
- Create `tests/integration/test_download_flow.py`
|
||||
- Create `tests/integration/test_auth_flow.py`
|
||||
- Create `tests/integration/test_websocket.py`
|
||||
|
||||
#### [] Create frontend integration tests
|
||||
|
||||
- []Create `tests/frontend/test_existing_ui_integration.py`
|
||||
- []Test existing JavaScript functionality with new backend
|
||||
- []Verify WebSocket connections and real-time updates
|
||||
- []Test authentication flow with existing frontend
|
||||
- Create `tests/frontend/test_existing_ui_integration.py`
|
||||
- Test existing JavaScript functionality with new backend
|
||||
- Verify WebSocket connections and real-time updates
|
||||
- Test authentication flow with existing frontend
|
||||
|
||||
### 11. Deployment and Configuration
|
||||
|
||||
#### [] Create Docker configuration
|
||||
|
||||
- []Create `Dockerfile`
|
||||
- []Create `docker-compose.yml`
|
||||
- []Add environment configuration
|
||||
- []Include volume mappings for existing web assets
|
||||
- Create `Dockerfile`
|
||||
- Create `docker-compose.yml`
|
||||
- Add environment configuration
|
||||
- Include volume mappings for existing web assets
|
||||
|
||||
#### [] Create production configuration
|
||||
|
||||
- []Create `src/server/config/production.py`
|
||||
- []Add environment variable handling
|
||||
- []Include security settings
|
||||
- []Add performance optimizations
|
||||
- Create `src/server/config/production.py`
|
||||
- Add environment variable handling
|
||||
- Include security settings
|
||||
- Add performance optimizations
|
||||
|
||||
#### [] Create startup scripts
|
||||
|
||||
- []Create `scripts/start.sh`
|
||||
- []Create `scripts/setup.py`
|
||||
- []Add dependency installation
|
||||
- []Include database initialization
|
||||
- Create `scripts/start.sh`
|
||||
- Create `scripts/setup.py`
|
||||
- Add dependency installation
|
||||
- Include database initialization
|
||||
|
||||
### 12. Documentation and Error Handling
|
||||
|
||||
#### [] Create API documentation
|
||||
|
||||
- []Add OpenAPI/Swagger documentation
|
||||
- []Include endpoint descriptions
|
||||
- []Add request/response examples
|
||||
- []Include authentication details
|
||||
- Add OpenAPI/Swagger documentation
|
||||
- Include endpoint descriptions
|
||||
- Add request/response examples
|
||||
- Include authentication details
|
||||
|
||||
#### [] Implement comprehensive error handling
|
||||
|
||||
- []Create custom exception classes
|
||||
- []Add error logging and tracking
|
||||
- []Implement user-friendly error messages
|
||||
- []Include error recovery mechanisms
|
||||
- Create custom exception classes
|
||||
- Add error logging and tracking
|
||||
- Implement user-friendly error messages
|
||||
- Include error recovery mechanisms
|
||||
|
||||
#### [] Create user documentation
|
||||
|
||||
- []Create `docs/user_guide.md`
|
||||
- []Add installation instructions
|
||||
- []Include configuration guide
|
||||
- []Add troubleshooting section
|
||||
- Create `docs/user_guide.md`
|
||||
- Add installation instructions
|
||||
- Include configuration guide
|
||||
- Add troubleshooting section
|
||||
|
||||
## File Size Guidelines
|
||||
|
||||
- []**Models**: Max 200 lines each
|
||||
- []**Services**: Max 450 lines each
|
||||
- []**API Endpoints**: Max 350 lines each
|
||||
- []**Templates**: Max 400 lines each
|
||||
- []**JavaScript**: Max 500 lines each
|
||||
- []**CSS**: Max 500 lines each
|
||||
- []**Tests**: Max 400 lines each
|
||||
- **Models**: Max 200 lines each
|
||||
- **Services**: Max 450 lines each
|
||||
- **API Endpoints**: Max 350 lines each
|
||||
- **Templates**: Max 400 lines each
|
||||
- **JavaScript**: Max 500 lines each
|
||||
- **CSS**: Max 500 lines each
|
||||
- **Tests**: Max 400 lines each
|
||||
|
||||
## Existing Frontend Assets
|
||||
|
||||
The following frontend assets already exist and should be integrated:
|
||||
|
||||
- []**Templates**: Located in `src/server/web/templates/`
|
||||
- []**JavaScript**: Located in `src/server/web/static/js/` (app.js, queue.js, etc.)
|
||||
- []**CSS**: Located in `src/server/web/static/css/`
|
||||
- []**Static Assets**: Images and other assets in `src/server/web/static/`
|
||||
- **Templates**: Located in `src/server/web/templates/`
|
||||
- **JavaScript**: Located in `src/server/web/static/js/` (app.js, queue.js, etc.)
|
||||
- **CSS**: Located in `src/server/web/static/css/`
|
||||
- **Static Assets**: Images and other assets in `src/server/web/static/`
|
||||
|
||||
When working with these files:
|
||||
|
||||
- []Review existing functionality before making changes
|
||||
- []Maintain existing UI/UX patterns and design
|
||||
- []Update API calls to match new FastAPI endpoints
|
||||
- []Preserve existing WebSocket event handling
|
||||
- []Keep existing theme and responsive design features
|
||||
- Review existing functionality before making changes
|
||||
- Maintain existing UI/UX patterns and design
|
||||
- Update API calls to match new FastAPI endpoints
|
||||
- Preserve existing WebSocket event handling
|
||||
- Keep existing theme and responsive design features
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
#### [] Code quality checks
|
||||
|
||||
- []Run linting with flake8/pylint
|
||||
- []Check type hints with mypy
|
||||
- []Validate formatting with black
|
||||
- []Run security checks with bandit
|
||||
- Run linting with flake8/pylint
|
||||
- Check type hints with mypy
|
||||
- Validate formatting with black
|
||||
- Run security checks with bandit
|
||||
|
||||
#### [] Performance testing
|
||||
|
||||
- []Load test API endpoints
|
||||
- []Test WebSocket connection limits
|
||||
- []Validate download performance
|
||||
- []Check memory usage patterns
|
||||
- Load test API endpoints
|
||||
- Test WebSocket connection limits
|
||||
- Validate download performance
|
||||
- Check memory usage patterns
|
||||
|
||||
#### [] Security validation
|
||||
|
||||
- []Test authentication bypass attempts
|
||||
- []Validate input sanitization
|
||||
- []Check for injection vulnerabilities
|
||||
- []Test session management security
|
||||
- Test authentication bypass attempts
|
||||
- Validate input sanitization
|
||||
- Check for injection vulnerabilities
|
||||
- Test session management security
|
||||
|
||||
Each task should be implemented with proper error handling, logging, and type hints according to the project's coding standards.
|
||||
|
||||
## Additional Implementation Guidelines
|
||||
|
||||
### Code Style and Standards
|
||||
|
||||
- **Type Hints**: Use comprehensive type annotations throughout all modules
|
||||
- **Docstrings**: Follow PEP 257 for function and class documentation
|
||||
- **Error Handling**: Implement custom exception classes with meaningful messages
|
||||
- **Logging**: Use structured logging with appropriate log levels
|
||||
- **Security**: Validate all inputs and sanitize outputs
|
||||
- **Performance**: Use async/await patterns for I/O operations
|
||||
|
||||
### Monitoring and Health Checks
|
||||
|
||||
#### [] Implement health check endpoints
|
||||
|
||||
- []Create `src/server/api/health.py`
|
||||
- []Add GET `/health` - basic health check
|
||||
- []Add GET `/health/detailed` - comprehensive system status
|
||||
- []Include dependency checks (database, file system)
|
||||
- []Add performance metrics
|
||||
|
||||
#### [] Create monitoring service
|
||||
|
||||
- []Create `src/server/services/monitoring_service.py`
|
||||
- []Implement system resource monitoring
|
||||
- []Add download queue metrics
|
||||
- []Include error rate tracking
|
||||
- []Add performance benchmarking
|
||||
|
||||
#### [] Add metrics collection
|
||||
|
||||
- []Create `src/server/utils/metrics.py`
|
||||
- []Implement Prometheus metrics export
|
||||
- []Add custom business metrics
|
||||
- []Include request timing and counts
|
||||
- []Add download success/failure rates
|
||||
|
||||
### Advanced Features
|
||||
|
||||
#### [] Implement backup and restore
|
||||
|
||||
- []Create `src/server/services/backup_service.py`
|
||||
- []Add configuration backup/restore
|
||||
- []Implement anime data export/import
|
||||
- []Include download history preservation
|
||||
- []Add scheduled backup functionality
|
||||
|
||||
#### [] Create notification system
|
||||
|
||||
- []Create `src/server/services/notification_service.py`
|
||||
- []Implement email notifications for completed downloads
|
||||
- []Add webhook support for external integrations
|
||||
- []Include in-app notification system
|
||||
- []Add notification preference management
|
||||
|
||||
#### [] Add analytics and reporting
|
||||
|
||||
- []Create `src/server/services/analytics_service.py`
|
||||
- []Implement download statistics
|
||||
- []Add series popularity tracking
|
||||
- []Include storage usage analysis
|
||||
- []Add performance reports
|
||||
|
||||
### Maintenance and Operations
|
||||
|
||||
#### [] Create maintenance endpoints
|
||||
|
||||
- []Create `src/server/api/maintenance.py`
|
||||
- []Add POST `/api/maintenance/cleanup` - cleanup temporary files
|
||||
- []Add POST `/api/maintenance/rebuild-index` - rebuild search index
|
||||
- []Add GET `/api/maintenance/stats` - system statistics
|
||||
- []Add POST `/api/maintenance/vacuum` - database maintenance
|
||||
|
||||
#### [] Implement log management
|
||||
|
||||
- []Create `src/server/utils/log_manager.py`
|
||||
- []Add log rotation and archival
|
||||
- []Implement log level management
|
||||
- []Include log search and filtering
|
||||
- []Add log export functionality
|
||||
|
||||
#### [] Create system utilities
|
||||
|
||||
- []Create `src/server/utils/system.py`
|
||||
- []Add disk space monitoring
|
||||
- []Implement file system cleanup
|
||||
- []Include process management utilities
|
||||
- []Add system information gathering
|
||||
|
||||
### Security Enhancements
|
||||
|
||||
#### [] Implement rate limiting
|
||||
|
||||
- []Create `src/server/middleware/rate_limit.py`
|
||||
- []Add endpoint-specific rate limits
|
||||
- []Implement IP-based limiting
|
||||
- []Include user-based rate limiting
|
||||
- []Add bypass mechanisms for authenticated users
|
||||
|
||||
#### [] Add security headers
|
||||
|
||||
- []Create `src/server/middleware/security.py`
|
||||
- []Implement CORS headers
|
||||
- []Add CSP headers
|
||||
- []Include security headers (HSTS, X-Frame-Options)
|
||||
- []Add request sanitization
|
||||
|
||||
#### [] Create audit logging
|
||||
|
||||
- []Create `src/server/services/audit_service.py`
|
||||
- []Log all authentication attempts
|
||||
- []Track configuration changes
|
||||
- []Monitor download activities
|
||||
- []Include user action tracking
|
||||
|
||||
### Data Management
|
||||
|
||||
#### [] Implement data validation
|
||||
|
||||
- []Create `src/server/utils/validators.py`
|
||||
- []Add Pydantic custom validators
|
||||
- []Implement business rule validation
|
||||
- []Include data integrity checks
|
||||
- []Add format validation utilities
|
||||
|
||||
#### [] Create data migration tools
|
||||
|
||||
- []Create `src/server/database/migrations/`
|
||||
- []Add database schema migration scripts
|
||||
- []Implement data transformation tools
|
||||
- []Include rollback mechanisms
|
||||
- []Add migration validation
|
||||
|
||||
#### [] Add caching layer
|
||||
|
||||
- []Create `src/server/services/cache_service.py`
|
||||
- []Implement Redis caching
|
||||
- []Add in-memory caching for frequent data
|
||||
- []Include cache invalidation strategies
|
||||
- []Add cache performance monitoring
|
||||
|
||||
### Integration Enhancements
|
||||
|
||||
#### [] Extend provider system
|
||||
|
||||
- []Enhance `src/core/providers/` for better web integration
|
||||
- []Add provider health monitoring
|
||||
- []Implement provider failover mechanisms
|
||||
- []Include provider performance tracking
|
||||
- []Add dynamic provider configuration
|
||||
|
||||
#### [] Create plugin system
|
||||
|
||||
- []Create `src/server/plugins/`
|
||||
- []Add plugin loading and management
|
||||
- []Implement plugin API
|
||||
- []Include plugin configuration
|
||||
- []Add plugin security validation
|
||||
|
||||
#### [] Add external API integrations
|
||||
|
||||
- []Create `src/server/integrations/`
|
||||
- []Add anime database API connections
|
||||
- []Implement metadata enrichment services
|
||||
- []Include content recommendation systems
|
||||
- []Add external notification services
|
||||
|
||||
### Advanced Testing
|
||||
|
||||
#### [] Performance testing
|
||||
|
||||
- []Create `tests/performance/`
|
||||
- []Add load testing for API endpoints
|
||||
- []Implement stress testing for download system
|
||||
- []Include memory leak detection
|
||||
- []Add concurrency testing
|
||||
|
||||
#### [] Security testing
|
||||
|
||||
- []Create `tests/security/`
|
||||
- []Add penetration testing scripts
|
||||
- []Implement vulnerability scanning
|
||||
- []Include authentication bypass testing
|
||||
- []Add input validation testing
|
||||
|
||||
#### [] End-to-end testing
|
||||
|
||||
- []Create `tests/e2e/`
|
||||
- []Add full workflow testing
|
||||
- []Implement UI automation tests
|
||||
- []Include cross-browser testing
|
||||
- []Add mobile responsiveness testing
|
||||
|
||||
### Deployment Strategies
|
||||
|
||||
#### [] Container orchestration
|
||||
|
||||
- []Create `kubernetes/` directory
|
||||
- []Add Kubernetes deployment manifests
|
||||
- []Implement service discovery
|
||||
- []Include load balancing configuration
|
||||
- []Add auto-scaling policies
|
||||
|
||||
#### [] CI/CD pipeline
|
||||
|
||||
- []Create `.github/workflows/`
|
||||
- []Add automated testing pipeline
|
||||
- []Implement deployment automation
|
||||
- []Include security scanning
|
||||
- []Add performance benchmarking
|
||||
|
||||
#### [] Environment management
|
||||
|
||||
- []Create environment-specific configurations
|
||||
- []Add secrets management
|
||||
- []Implement feature flags
|
||||
- []Include environment validation
|
||||
- []Add rollback mechanisms
|
||||
|
||||
## Implementation Best Practices
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
```python
|
||||
# Custom exception hierarchy
|
||||
class AniWorldException(Exception):
|
||||
"""Base exception for AniWorld application"""
|
||||
pass
|
||||
|
||||
class AuthenticationError(AniWorldException):
|
||||
"""Authentication related errors"""
|
||||
pass
|
||||
|
||||
class DownloadError(AniWorldException):
|
||||
"""Download related errors"""
|
||||
pass
|
||||
|
||||
# Service-level error handling
|
||||
async def download_episode(episode_id: str) -> DownloadResult:
|
||||
try:
|
||||
result = await downloader.download(episode_id)
|
||||
return result
|
||||
except ProviderError as e:
|
||||
logger.error(f"Provider error downloading {episode_id}: {e}")
|
||||
raise DownloadError(f"Failed to download episode: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error downloading {episode_id}")
|
||||
raise DownloadError("Unexpected download error")
|
||||
```
|
||||
|
||||
### Logging Standards
|
||||
|
||||
```python
|
||||
import logging
|
||||
import structlog
|
||||
|
||||
# Configure structured logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer()
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Usage examples
|
||||
logger.info("Download started", episode_id=episode_id, user_id=user_id)
|
||||
logger.error("Download failed", episode_id=episode_id, error=str(e))
|
||||
```
|
||||
|
||||
### API Response Patterns
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
errors: Optional[List[str]] = None
|
||||
|
||||
class PaginatedResponse(APIResponse):
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
# Usage in endpoints
|
||||
@router.get("/anime", response_model=PaginatedResponse)
|
||||
async def list_anime(page: int = 1, per_page: int = 20):
|
||||
try:
|
||||
anime_list, total = await anime_service.list_anime(page, per_page)
|
||||
return PaginatedResponse(
|
||||
success=True,
|
||||
data=anime_list,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=(total + per_page - 1) // per_page
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to list anime")
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="Failed to retrieve anime list",
|
||||
errors=[str(e)]
|
||||
)
|
||||
```
|
||||
|
||||
### Dependency Injection Patterns
|
||||
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from typing import Annotated
|
||||
|
||||
# Service dependencies
|
||||
def get_anime_service() -> AnimeService:
|
||||
return AnimeService()
|
||||
|
||||
def get_download_service() -> DownloadService:
|
||||
return DownloadService()
|
||||
|
||||
# Dependency annotations
|
||||
AnimeServiceDep = Annotated[AnimeService, Depends(get_anime_service)]
|
||||
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||
|
||||
# Usage in endpoints
|
||||
@router.post("/download")
|
||||
async def start_download(
|
||||
request: DownloadRequest,
|
||||
download_service: DownloadServiceDep,
|
||||
anime_service: AnimeServiceDep
|
||||
):
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
## Final Implementation Notes
|
||||
|
||||
1. **Incremental Development**: Implement features incrementally, testing each component thoroughly before moving to the next
|
||||
2. **Code Review**: Review all generated code for adherence to project standards
|
||||
3. **Documentation**: Document all public APIs and complex logic
|
||||
4. **Testing**: Maintain test coverage above 80% for all new code
|
||||
5. **Performance**: Profile and optimize critical paths, especially download and streaming operations
|
||||
6. **Security**: Regular security audits and dependency updates
|
||||
7. **Monitoring**: Implement comprehensive monitoring and alerting
|
||||
8. **Maintenance**: Plan for regular maintenance and updates
|
||||
|
||||
## Task Completion Checklist
|
||||
|
||||
For each task completed:
|
||||
|
||||
- [ ] Implementation follows coding standards
|
||||
- [ ] Unit tests written and passing
|
||||
- [ ] Integration tests passing
|
||||
- [ ] Documentation updated
|
||||
- [ ] Error handling implemented
|
||||
- [ ] Logging added
|
||||
- [ ] Security considerations addressed
|
||||
- [ ] Performance validated
|
||||
- [ ] Code reviewed
|
||||
- [ ] Task marked as complete in instructions.md
|
||||
- [ ] Infrastructure.md updated
|
||||
- [ ] Changes committed to git
|
||||
|
||||
This comprehensive guide ensures a robust, maintainable, and scalable anime download management system with modern web capabilities.
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"asyncio: mark test as asynchronous"
|
||||
]
|
||||
@ -1,13 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
jinja2==3.1.2
|
||||
python-multipart==0.0.6
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
aiofiles==23.2.1
|
||||
websockets==12.0
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
@ -1,14 +0,0 @@
|
||||
"""Package shim: expose `server` package from `src/server`.
|
||||
|
||||
This file inserts the actual `src/server` directory into this package's
|
||||
`__path__` so imports like `import server.models.auth` will resolve to
|
||||
the code under `src/server` during tests.
|
||||
"""
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(__file__)
|
||||
_SRC_SERVER = os.path.normpath(os.path.join(_HERE, "..", "src", "server"))
|
||||
|
||||
# Prepend the real src/server directory to the package __path__ so
|
||||
# normal imports resolve to the source tree.
|
||||
__path__.insert(0, _SRC_SERVER)
|
||||
@ -3,6 +3,10 @@ Core module for AniWorld application.
|
||||
Contains domain entities, interfaces, application services, and exceptions.
|
||||
"""
|
||||
|
||||
from . import entities, exceptions, interfaces, providers
|
||||
from . import entities
|
||||
from . import exceptions
|
||||
from . import interfaces
|
||||
from . import application
|
||||
from . import providers
|
||||
|
||||
__all__ = ['entities', 'exceptions', 'interfaces', 'providers']
|
||||
__all__ = ['entities', 'exceptions', 'interfaces', 'application', 'providers']
|
||||
@ -1,84 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from src.server.models.auth import AuthStatus, LoginRequest, LoginResponse, SetupRequest
|
||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||
|
||||
# NOTE: import dependencies (optional_auth, security) lazily inside handlers
|
||||
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/setup", status_code=status.HTTP_201_CREATED)
|
||||
def setup_auth(req: SetupRequest):
|
||||
"""Initial setup endpoint to configure the master password."""
|
||||
if auth_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Master password already configured",
|
||||
)
|
||||
|
||||
try:
|
||||
auth_service.setup_master_password(req.master_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(req: LoginRequest):
|
||||
"""Validate master password and return JWT token."""
|
||||
# Use a simple identifier for failed attempts; prefer IP in a real app
|
||||
identifier = "global"
|
||||
|
||||
try:
|
||||
valid = auth_service.validate_master_password(req.password, identifier=identifier)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except LockedOutError as e:
|
||||
raise HTTPException(status_code=429, detail=str(e))
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
token = auth_service.create_access_token(subject="master", remember=bool(req.remember))
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(credentials: HTTPAuthorizationCredentials = None):
|
||||
"""Logout by revoking token (no-op for stateless JWT)."""
|
||||
# Import security dependency lazily to avoid heavy imports during test
|
||||
if credentials is None:
|
||||
from fastapi import Depends
|
||||
|
||||
from src.server.utils.dependencies import security as _security
|
||||
|
||||
# Trigger dependency resolution during normal request handling
|
||||
credentials = Depends(_security)
|
||||
|
||||
# If a plain credentials object was provided, extract token
|
||||
token = getattr(credentials, "credentials", None)
|
||||
# Placeholder; auth_service.revoke_token can be expanded to persist revocations
|
||||
auth_service.revoke_token(token)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/status", response_model=AuthStatus)
|
||||
def status(auth: Optional[dict] = None):
|
||||
"""Return whether master password is configured and if caller is authenticated."""
|
||||
# Lazy import to avoid pulling in database/sqlalchemy during module import
|
||||
from fastapi import Depends
|
||||
try:
|
||||
from src.server.utils.dependencies import optional_auth as _optional_auth
|
||||
except Exception:
|
||||
_optional_auth = None
|
||||
|
||||
# If dependency injection didn't provide auth, attempt to resolve optionally
|
||||
if auth is None and _optional_auth is not None:
|
||||
auth = Depends(_optional_auth)
|
||||
return AuthStatus(configured=auth_service.is_configured(), authenticated=bool(auth))
|
||||
@ -1,5 +0,0 @@
|
||||
"""
|
||||
Controllers package for FastAPI application.
|
||||
|
||||
This package contains route controllers organized by functionality.
|
||||
"""
|
||||
@ -1,39 +0,0 @@
|
||||
"""
|
||||
Error handler controller for managing application exceptions.
|
||||
|
||||
This module provides custom error handlers for different HTTP status codes.
|
||||
"""
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.server.utils.templates import templates
|
||||
|
||||
|
||||
async def not_found_handler(request: Request, exc: HTTPException):
|
||||
"""Custom 404 handler."""
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "API endpoint not found"}
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "error": "Page not found", "status_code": 404}
|
||||
)
|
||||
|
||||
|
||||
async def server_error_handler(request: Request, exc: Exception):
|
||||
"""Custom 500 handler."""
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"}
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": "Internal server error",
|
||||
"status_code": 500
|
||||
}
|
||||
)
|
||||
@ -1,31 +0,0 @@
|
||||
"""
|
||||
Health check controller for monitoring and status endpoints.
|
||||
|
||||
This module provides health check endpoints for application monitoring.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
def get_series_app() -> Optional[SeriesApp]:
|
||||
"""Get the current SeriesApp instance."""
|
||||
# This will be replaced with proper dependency injection
|
||||
from src.server.fastapi_app import series_app
|
||||
return series_app
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring."""
|
||||
series_app = get_series_app()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "aniworld-api",
|
||||
"version": "1.0.0",
|
||||
"series_app_initialized": series_app is not None
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
"""
|
||||
Page controller for serving HTML templates.
|
||||
|
||||
This module provides endpoints for serving HTML pages using Jinja2 templates.
|
||||
"""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from src.server.utils.templates import templates
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Serve the main application page."""
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "title": "Aniworld Download Manager"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup", response_class=HTMLResponse)
|
||||
async def setup_page(request: Request):
|
||||
"""Serve the setup page."""
|
||||
return templates.TemplateResponse(
|
||||
"setup.html",
|
||||
{"request": request, "title": "Setup - Aniworld"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
"""Serve the login page."""
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "title": "Login - Aniworld"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/queue", response_class=HTMLResponse)
|
||||
async def queue_page(request: Request):
|
||||
"""Serve the download queue page."""
|
||||
return templates.TemplateResponse(
|
||||
"queue.html",
|
||||
{"request": request, "title": "Download Queue - Aniworld"}
|
||||
)
|
||||
@ -1,103 +0,0 @@
|
||||
"""
|
||||
FastAPI application for Aniworld anime download manager.
|
||||
|
||||
This module provides the main FastAPI application with proper CORS
|
||||
configuration, middleware setup, static file serving, and Jinja2 template
|
||||
integration.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from src.config.settings import settings
|
||||
|
||||
# Import core functionality
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.api.auth import router as auth_router
|
||||
from src.server.controllers.error_controller import (
|
||||
not_found_handler,
|
||||
server_error_handler,
|
||||
)
|
||||
|
||||
# Import controllers
|
||||
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.middleware.auth import AuthMiddleware
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Aniworld Download Manager",
|
||||
description="Modern web interface for Aniworld anime download management",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Configure static files
|
||||
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Attach authentication middleware (token parsing + simple rate limiter)
|
||||
app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health_router)
|
||||
app.include_router(page_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Global variables for application state
|
||||
series_app: Optional[SeriesApp] = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize application on startup."""
|
||||
global series_app
|
||||
try:
|
||||
# Initialize SeriesApp with configured directory
|
||||
if settings.anime_directory:
|
||||
series_app = SeriesApp(settings.anime_directory)
|
||||
print("FastAPI application started successfully")
|
||||
except Exception as e:
|
||||
print(f"Error during startup: {e}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Cleanup on application shutdown."""
|
||||
print("FastAPI application shutting down")
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def handle_not_found(request: Request, exc: HTTPException):
|
||||
"""Custom 404 handler."""
|
||||
return await not_found_handler(request, exc)
|
||||
|
||||
|
||||
@app.exception_handler(500)
|
||||
async def handle_server_error(request: Request, exc: Exception):
|
||||
"""Custom 500 handler."""
|
||||
return await server_error_handler(request, exc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"fastapi_app:app",
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
||||
@ -1,91 +0,0 @@
|
||||
"""Authentication middleware for Aniworld FastAPI app.
|
||||
|
||||
Responsibilities:
|
||||
- Validate Bearer JWT tokens (optional on public endpoints)
|
||||
- Attach session info to request.state.session when valid
|
||||
- Enforce simple in-memory rate limiting for auth endpoints
|
||||
|
||||
This middleware is intentionally lightweight and synchronous.
|
||||
For production use consider a distributed rate limiter (Redis) and
|
||||
a proper token revocation store.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from src.server.services.auth_service import AuthError, auth_service
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that decodes JWT Bearer tokens (if present) and
|
||||
provides a small rate limiter for authentication endpoints.
|
||||
|
||||
How it works
|
||||
- If Authorization: Bearer <token> header is present, attempt to
|
||||
decode and create a session model using the existing auth_service.
|
||||
On success, store session dict on ``request.state.session``.
|
||||
- For POST requests to ``/api/auth/login`` and ``/api/auth/setup``
|
||||
a simple per-IP rate limiter is applied to mitigate brute-force
|
||||
attempts.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, rate_limit_per_minute: int = 5) -> None:
|
||||
super().__init__(app)
|
||||
# in-memory rate limiter: ip -> {count, window_start}
|
||||
self._rate: Dict[str, Dict[str, float]] = {}
|
||||
self.rate_limit_per_minute = rate_limit_per_minute
|
||||
self.window_seconds = 60
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
path = request.url.path or ""
|
||||
|
||||
# Apply rate limiting to auth endpoints that accept credentials
|
||||
if path in ("/api/auth/login", "/api/auth/setup") and request.method.upper() == "POST":
|
||||
client_host = self._get_client_ip(request)
|
||||
rec = self._rate.setdefault(client_host, {"count": 0, "window_start": time.time()})
|
||||
now = time.time()
|
||||
if now - rec["window_start"] > self.window_seconds:
|
||||
# reset window
|
||||
rec["window_start"] = now
|
||||
rec["count"] = 0
|
||||
|
||||
rec["count"] += 1
|
||||
if rec["count"] > self.rate_limit_per_minute:
|
||||
# Too many requests in window — return a JSON 429 response
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={"detail": "Too many authentication attempts, try again later"},
|
||||
)
|
||||
|
||||
# If Authorization header present try to decode token and attach session
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
try:
|
||||
session = auth_service.create_session_model(token)
|
||||
# attach to request.state for downstream usage
|
||||
request.state.session = session.dict()
|
||||
except AuthError:
|
||||
# Invalid token: if this is a protected API path, reject.
|
||||
# For public/auth endpoints let the dependency system handle
|
||||
# optional auth and return None.
|
||||
if path.startswith("/api/") and not path.startswith("/api/auth"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@staticmethod
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
try:
|
||||
client = request.client
|
||||
if client is None:
|
||||
return "unknown"
|
||||
return client.host or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
@ -1,3 +0,0 @@
|
||||
"""Models package for server-side Pydantic models."""
|
||||
|
||||
__all__ = ["auth"]
|
||||
@ -1,57 +0,0 @@
|
||||
"""Authentication Pydantic models for the Aniworld web application.
|
||||
|
||||
This module defines simple request/response shapes used by the auth API and
|
||||
by the authentication service. Keep models small and focused so they are
|
||||
easy to validate and test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, constr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Request body for a login attempt.
|
||||
|
||||
Fields:
|
||||
- password: master password string (minimum 8 chars recommended)
|
||||
- remember: optional flag to request a long-lived session
|
||||
"""
|
||||
|
||||
password: constr(min_length=1) = Field(..., description="Master password")
|
||||
remember: Optional[bool] = Field(False, description="Keep session alive")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Response returned after a successful login."""
|
||||
|
||||
access_token: str = Field(..., description="JWT access token")
|
||||
token_type: str = Field("bearer", description="Token type")
|
||||
expires_at: Optional[datetime] = Field(None, description="Optional expiry timestamp")
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
"""Request to initialize the master password during first-time setup."""
|
||||
|
||||
master_password: constr(min_length=8) = Field(..., description="New master password")
|
||||
|
||||
|
||||
class AuthStatus(BaseModel):
|
||||
"""Public status about whether auth is configured and the current user state."""
|
||||
|
||||
configured: bool = Field(..., description="Whether a master password is set")
|
||||
authenticated: bool = Field(False, description="Whether the caller is authenticated")
|
||||
|
||||
|
||||
class SessionModel(BaseModel):
|
||||
"""Lightweight session representation stored/returned by the auth service.
|
||||
|
||||
This model can be persisted if a persistent session store is used.
|
||||
"""
|
||||
|
||||
session_id: str = Field(..., description="Unique session identifier")
|
||||
user: Optional[str] = Field(None, description="Username or identifier")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
expires_at: Optional[datetime] = Field(None)
|
||||
@ -1,199 +0,0 @@
|
||||
"""Authentication service for Aniworld.
|
||||
|
||||
Responsibilities:
|
||||
- Setup and validate a master password (hashed with bcrypt via passlib)
|
||||
- Issue and validate JWT access tokens
|
||||
- Track failed login attempts and apply temporary lockouts
|
||||
- Provide simple session model creation data
|
||||
|
||||
This service is intentionally small and synchronous; FastAPI endpoints
|
||||
can call it from async routes via threadpool if needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt # type: ignore
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.auth import LoginResponse, SessionModel
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LockedOutError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service to manage master password and JWT sessions.
|
||||
|
||||
Notes:
|
||||
- Master password hash is stored in settings.master_password_hash when
|
||||
available. For persistence beyond environment variables, a proper
|
||||
config persistence should be used (not implemented here).
|
||||
- Lockout policy is kept in-memory and will reset when the process
|
||||
restarts. This is acceptable for single-process deployments.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._hash: Optional[str] = settings.master_password_hash
|
||||
# In-memory failed attempts per identifier. Values are dicts with
|
||||
# keys: count, last, locked_until
|
||||
self._failed: Dict[str, Dict] = {}
|
||||
# Policy
|
||||
self.max_attempts = 5
|
||||
self.lockout_seconds = 300 # 5 minutes
|
||||
self.token_expiry_hours = settings.token_expiry_hours or 24
|
||||
self.secret = settings.jwt_secret_key
|
||||
|
||||
# --- password helpers ---
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def _verify_password(self, plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._hash)
|
||||
|
||||
def setup_master_password(self, password: str) -> None:
|
||||
"""Set the master password (hash and store in memory/settings).
|
||||
|
||||
For now we update only the in-memory value and
|
||||
settings.master_password_hash. A future task should persist this
|
||||
to a config file.
|
||||
"""
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long")
|
||||
# Basic strength checks
|
||||
if password.islower() or password.isupper():
|
||||
raise ValueError("Password must include mixed case")
|
||||
if password.isalnum():
|
||||
# encourage a special character
|
||||
raise ValueError("Password should include a symbol or punctuation")
|
||||
|
||||
h = self._hash_password(password)
|
||||
self._hash = h
|
||||
# Mirror into settings for simple persistence via env (if used)
|
||||
try:
|
||||
settings.master_password_hash = h
|
||||
except Exception:
|
||||
# Settings may be frozen or not persisted - that's okay for now
|
||||
pass
|
||||
|
||||
# --- failed attempts and lockout ---
|
||||
def _get_fail_record(self, identifier: str) -> Dict:
|
||||
return self._failed.setdefault(
|
||||
identifier,
|
||||
{"count": 0, "last": None, "locked_until": None},
|
||||
)
|
||||
|
||||
def _record_failure(self, identifier: str) -> None:
|
||||
rec = self._get_fail_record(identifier)
|
||||
rec["count"] += 1
|
||||
rec["last"] = datetime.utcnow()
|
||||
if rec["count"] >= self.max_attempts:
|
||||
rec["locked_until"] = (
|
||||
datetime.utcnow() + timedelta(seconds=self.lockout_seconds)
|
||||
)
|
||||
|
||||
def _clear_failures(self, identifier: str) -> None:
|
||||
if identifier in self._failed:
|
||||
self._failed.pop(identifier, None)
|
||||
|
||||
def _check_locked(self, identifier: str) -> None:
|
||||
rec = self._get_fail_record(identifier)
|
||||
lu = rec.get("locked_until")
|
||||
if lu and datetime.utcnow() < lu:
|
||||
raise LockedOutError(
|
||||
"Too many failed attempts - temporarily locked out"
|
||||
)
|
||||
if lu and datetime.utcnow() >= lu:
|
||||
# lock expired, reset
|
||||
self._failed[identifier] = {
|
||||
"count": 0,
|
||||
"last": None,
|
||||
"locked_until": None,
|
||||
}
|
||||
|
||||
# --- authentication ---
|
||||
def validate_master_password(
|
||||
self, password: str, identifier: str = "global"
|
||||
) -> bool:
|
||||
"""Validate provided password against stored master hash.
|
||||
|
||||
identifier: string to track failed attempts (IP, user, or 'global').
|
||||
"""
|
||||
# Check lockout
|
||||
self._check_locked(identifier)
|
||||
|
||||
if not self._hash:
|
||||
raise AuthError("Master password not configured")
|
||||
|
||||
ok = self._verify_password(password, self._hash)
|
||||
if not ok:
|
||||
self._record_failure(identifier)
|
||||
return False
|
||||
|
||||
# success
|
||||
self._clear_failures(identifier)
|
||||
return True
|
||||
|
||||
# --- JWT tokens ---
|
||||
def create_access_token(
|
||||
self, subject: str = "master", remember: bool = False
|
||||
) -> LoginResponse:
|
||||
expiry = datetime.utcnow() + timedelta(
|
||||
hours=(168 if remember else self.token_expiry_hours)
|
||||
)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"exp": int(expiry.timestamp()),
|
||||
"iat": int(datetime.utcnow().timestamp()),
|
||||
}
|
||||
token = jwt.encode(payload, self.secret, algorithm="HS256")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=token, token_type="bearer", expires_at=expiry
|
||||
)
|
||||
|
||||
def decode_token(self, token: str) -> Dict:
|
||||
try:
|
||||
data = jwt.decode(token, self.secret, algorithms=["HS256"])
|
||||
return data
|
||||
except JWTError as e:
|
||||
raise AuthError("Invalid token") from e
|
||||
|
||||
def create_session_model(self, token: str) -> SessionModel:
|
||||
data = self.decode_token(token)
|
||||
exp_val = data.get("exp")
|
||||
expires_at = (
|
||||
datetime.utcfromtimestamp(exp_val) if exp_val is not None else None
|
||||
)
|
||||
return SessionModel(
|
||||
session_id=hashlib.sha256(token.encode()).hexdigest(),
|
||||
user=data.get("sub"),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def revoke_token(self, token: str) -> None:
|
||||
# For JWT stateless tokens we can't revoke without a store. This
|
||||
# is a placeholder. A real implementation would add the token jti
|
||||
# to a revocation list.
|
||||
return None
|
||||
|
||||
|
||||
# Singleton service instance for import convenience
|
||||
auth_service = AuthService()
|
||||
1122
src/server/services/bulk_service.py
Normal file
1122
src/server/services/bulk_service.py
Normal file
File diff suppressed because it is too large
Load Diff
981
src/server/services/config_service.py
Normal file
981
src/server/services/config_service.py
Normal file
@ -0,0 +1,981 @@
|
||||
"""
|
||||
User Preferences and Settings Persistence Manager
|
||||
|
||||
This module provides user preferences management, settings persistence,
|
||||
and customization options for the AniWorld web interface.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
|
||||
class UserPreferencesManager:
|
||||
"""Manages user preferences and settings persistence."""
|
||||
|
||||
def __init__(self, app=None):
|
||||
self.app = app
|
||||
self.preferences_file = 'data/user_preferences.json'
|
||||
self.preferences = {} # Initialize preferences attribute
|
||||
self.default_preferences = {
|
||||
'ui': {
|
||||
'theme': 'auto', # 'light', 'dark', 'auto'
|
||||
'density': 'comfortable', # 'compact', 'comfortable', 'spacious'
|
||||
'language': 'en',
|
||||
'animations_enabled': True,
|
||||
'sidebar_collapsed': False,
|
||||
'grid_view': True,
|
||||
'items_per_page': 20
|
||||
},
|
||||
'downloads': {
|
||||
'auto_download': False,
|
||||
'download_quality': 'best',
|
||||
'concurrent_downloads': 3,
|
||||
'retry_failed': True,
|
||||
'notification_sound': True,
|
||||
'auto_organize': True
|
||||
},
|
||||
'notifications': {
|
||||
'browser_notifications': True,
|
||||
'email_notifications': False,
|
||||
'webhook_notifications': False,
|
||||
'notification_types': {
|
||||
'download_complete': True,
|
||||
'download_error': True,
|
||||
'series_updated': False,
|
||||
'system_alerts': True
|
||||
}
|
||||
},
|
||||
'keyboard_shortcuts': {
|
||||
'enabled': True,
|
||||
'shortcuts': {
|
||||
'search': 'ctrl+f',
|
||||
'download': 'ctrl+d',
|
||||
'refresh': 'f5',
|
||||
'select_all': 'ctrl+a',
|
||||
'help': 'f1',
|
||||
'settings': 'ctrl+comma'
|
||||
}
|
||||
},
|
||||
'advanced': {
|
||||
'debug_mode': False,
|
||||
'performance_mode': False,
|
||||
'cache_enabled': True,
|
||||
'auto_backup': True,
|
||||
'log_level': 'info'
|
||||
}
|
||||
}
|
||||
|
||||
# Initialize with defaults if no app provided
|
||||
if app is None:
|
||||
self.preferences = self.default_preferences.copy()
|
||||
else:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Initialize with Flask app."""
|
||||
self.app = app
|
||||
self.preferences_file = os.path.join(app.instance_path, 'data/user_preferences.json')
|
||||
|
||||
# Ensure instance path exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
|
||||
# Load or create preferences file
|
||||
self.load_preferences()
|
||||
|
||||
def load_preferences(self) -> Dict[str, Any]:
|
||||
"""Load preferences from file."""
|
||||
try:
|
||||
if os.path.exists(self.preferences_file):
|
||||
with open(self.preferences_file, 'r', encoding='utf-8') as f:
|
||||
loaded_prefs = json.load(f)
|
||||
|
||||
# Merge with defaults to ensure all keys exist
|
||||
self.preferences = self.merge_preferences(self.default_preferences, loaded_prefs)
|
||||
else:
|
||||
self.preferences = self.default_preferences.copy()
|
||||
self.save_preferences()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading preferences: {e}")
|
||||
self.preferences = self.default_preferences.copy()
|
||||
|
||||
return self.preferences
|
||||
|
||||
def save_preferences(self) -> bool:
|
||||
"""Save preferences to file."""
|
||||
try:
|
||||
with open(self.preferences_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.preferences, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error saving preferences: {e}")
|
||||
return False
|
||||
|
||||
def merge_preferences(self, defaults: Dict, user_prefs: Dict) -> Dict:
|
||||
"""Recursively merge user preferences with defaults."""
|
||||
result = defaults.copy()
|
||||
|
||||
for key, value in user_prefs.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = self.merge_preferences(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
def get_preference(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific preference using dot notation (e.g., 'ui.theme')."""
|
||||
keys = key.split('.')
|
||||
value = self.preferences
|
||||
|
||||
try:
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def set_preference(self, key: str, value: Any) -> bool:
|
||||
"""Set a specific preference using dot notation."""
|
||||
keys = key.split('.')
|
||||
pref_dict = self.preferences
|
||||
|
||||
try:
|
||||
# Navigate to parent dictionary
|
||||
for k in keys[:-1]:
|
||||
if k not in pref_dict:
|
||||
pref_dict[k] = {}
|
||||
pref_dict = pref_dict[k]
|
||||
|
||||
# Set the value
|
||||
pref_dict[keys[-1]] = value
|
||||
|
||||
# Save to file
|
||||
return self.save_preferences()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error setting preference {key}: {e}")
|
||||
return False
|
||||
|
||||
def reset_preferences(self) -> bool:
|
||||
"""Reset all preferences to defaults."""
|
||||
self.preferences = self.default_preferences.copy()
|
||||
return self.save_preferences()
|
||||
|
||||
def export_preferences(self) -> str:
|
||||
"""Export preferences as JSON string."""
|
||||
try:
|
||||
return json.dumps(self.preferences, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"Error exporting preferences: {e}")
|
||||
return "{}"
|
||||
|
||||
def import_preferences(self, json_data: str) -> bool:
|
||||
"""Import preferences from JSON string."""
|
||||
try:
|
||||
imported_prefs = json.loads(json_data)
|
||||
self.preferences = self.merge_preferences(self.default_preferences, imported_prefs)
|
||||
return self.save_preferences()
|
||||
except Exception as e:
|
||||
print(f"Error importing preferences: {e}")
|
||||
return False
|
||||
|
||||
def get_user_session_preferences(self) -> Dict[str, Any]:
|
||||
"""Get preferences for current user session."""
|
||||
# For now, return global preferences
|
||||
# In the future, could be user-specific
|
||||
return self.preferences.copy()
|
||||
|
||||
def get_preferences_js(self):
|
||||
"""Generate JavaScript code for preferences management."""
|
||||
return f"""
|
||||
// AniWorld User Preferences Manager
|
||||
class UserPreferencesManager {{
|
||||
constructor() {{
|
||||
this.preferences = {json.dumps(self.preferences)};
|
||||
this.defaultPreferences = {json.dumps(self.default_preferences)};
|
||||
this.changeListeners = new Map();
|
||||
this.init();
|
||||
}}
|
||||
|
||||
init() {{
|
||||
this.loadFromServer();
|
||||
this.applyPreferences();
|
||||
this.setupPreferencesUI();
|
||||
this.setupAutoSave();
|
||||
}}
|
||||
|
||||
async loadFromServer() {{
|
||||
try {{
|
||||
const response = await fetch('/api/preferences');
|
||||
if (response.ok) {{
|
||||
this.preferences = await response.json();
|
||||
this.applyPreferences();
|
||||
}}
|
||||
}} catch (error) {{
|
||||
console.error('Error loading preferences:', error);
|
||||
}}
|
||||
}}
|
||||
|
||||
async saveToServer() {{
|
||||
try {{
|
||||
const response = await fetch('/api/preferences', {{
|
||||
method: 'PUT',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json'
|
||||
}},
|
||||
body: JSON.stringify(this.preferences)
|
||||
}});
|
||||
|
||||
if (!response.ok) {{
|
||||
console.error('Error saving preferences to server');
|
||||
}}
|
||||
}} catch (error) {{
|
||||
console.error('Error saving preferences:', error);
|
||||
}}
|
||||
}}
|
||||
|
||||
get(key, defaultValue = null) {{
|
||||
const keys = key.split('.');
|
||||
let value = this.preferences;
|
||||
|
||||
try {{
|
||||
for (const k of keys) {{
|
||||
value = value[k];
|
||||
}}
|
||||
return value !== undefined ? value : defaultValue;
|
||||
}} catch (error) {{
|
||||
return defaultValue;
|
||||
}}
|
||||
}}
|
||||
|
||||
set(key, value, save = true) {{
|
||||
const keys = key.split('.');
|
||||
let obj = this.preferences;
|
||||
|
||||
// Navigate to parent object
|
||||
for (let i = 0; i < keys.length - 1; i++) {{
|
||||
const k = keys[i];
|
||||
if (!obj[k] || typeof obj[k] !== 'object') {{
|
||||
obj[k] = {{}};
|
||||
}}
|
||||
obj = obj[k];
|
||||
}}
|
||||
|
||||
// Set the value
|
||||
const lastKey = keys[keys.length - 1];
|
||||
const oldValue = obj[lastKey];
|
||||
obj[lastKey] = value;
|
||||
|
||||
// Apply the change immediately
|
||||
this.applyPreference(key, value);
|
||||
|
||||
// Notify listeners
|
||||
this.notifyChangeListeners(key, value, oldValue);
|
||||
|
||||
// Save to server
|
||||
if (save) {{
|
||||
this.saveToServer();
|
||||
}}
|
||||
|
||||
// Store in localStorage as backup
|
||||
localStorage.setItem('aniworld_preferences', JSON.stringify(this.preferences));
|
||||
}}
|
||||
|
||||
applyPreferences() {{
|
||||
// Apply all preferences
|
||||
this.applyTheme();
|
||||
this.applyUIPreferences();
|
||||
this.applyKeyboardShortcuts();
|
||||
this.applyNotificationSettings();
|
||||
}}
|
||||
|
||||
applyPreference(key, value) {{
|
||||
// Apply individual preference change
|
||||
if (key.startsWith('ui.theme')) {{
|
||||
this.applyTheme();
|
||||
}} else if (key.startsWith('ui.')) {{
|
||||
this.applyUIPreferences();
|
||||
}} else if (key.startsWith('keyboard_shortcuts.')) {{
|
||||
this.applyKeyboardShortcuts();
|
||||
}} else if (key.startsWith('notifications.')) {{
|
||||
this.applyNotificationSettings();
|
||||
}}
|
||||
}}
|
||||
|
||||
applyTheme() {{
|
||||
const theme = this.get('ui.theme', 'auto');
|
||||
const html = document.documentElement;
|
||||
|
||||
html.classList.remove('theme-light', 'theme-dark');
|
||||
|
||||
if (theme === 'auto') {{
|
||||
// Use system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
html.classList.add(prefersDark ? 'theme-dark' : 'theme-light');
|
||||
}} else {{
|
||||
html.classList.add(`theme-${{theme}}`);
|
||||
}}
|
||||
|
||||
// Update Bootstrap theme
|
||||
html.setAttribute('data-bs-theme', theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light');
|
||||
}}
|
||||
|
||||
applyUIPreferences() {{
|
||||
const density = this.get('ui.density', 'comfortable');
|
||||
const animations = this.get('ui.animations_enabled', true);
|
||||
const gridView = this.get('ui.grid_view', true);
|
||||
|
||||
// Apply UI density
|
||||
document.body.className = document.body.className.replace(/density-\\w+/g, '');
|
||||
document.body.classList.add(`density-${{density}}`);
|
||||
|
||||
// Apply animations
|
||||
if (!animations) {{
|
||||
document.body.classList.add('no-animations');
|
||||
}} else {{
|
||||
document.body.classList.remove('no-animations');
|
||||
}}
|
||||
|
||||
// Apply view mode
|
||||
const viewToggle = document.querySelector('.view-toggle');
|
||||
if (viewToggle) {{
|
||||
viewToggle.classList.toggle('grid-view', gridView);
|
||||
viewToggle.classList.toggle('list-view', !gridView);
|
||||
}}
|
||||
}}
|
||||
|
||||
applyKeyboardShortcuts() {{
|
||||
const enabled = this.get('keyboard_shortcuts.enabled', true);
|
||||
const shortcuts = this.get('keyboard_shortcuts.shortcuts', {{}});
|
||||
|
||||
if (window.keyboardManager) {{
|
||||
window.keyboardManager.setEnabled(enabled);
|
||||
window.keyboardManager.updateShortcuts(shortcuts);
|
||||
}}
|
||||
}}
|
||||
|
||||
applyNotificationSettings() {{
|
||||
const browserNotifications = this.get('notifications.browser_notifications', true);
|
||||
|
||||
// Request notification permission if needed
|
||||
if (browserNotifications && 'Notification' in window && Notification.permission === 'default') {{
|
||||
Notification.requestPermission();
|
||||
}}
|
||||
}}
|
||||
|
||||
setupPreferencesUI() {{
|
||||
this.createSettingsModal();
|
||||
this.bindSettingsEvents();
|
||||
}}
|
||||
|
||||
createSettingsModal() {{
|
||||
const existingModal = document.getElementById('preferences-modal');
|
||||
if (existingModal) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'preferences-modal';
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Preferences</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#ui-tab">Interface</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#downloads-tab">Downloads</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#notifications-tab">Notifications</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#shortcuts-tab">Shortcuts</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#advanced-tab">Advanced</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
${{this.createUITab()}}
|
||||
${{this.createDownloadsTab()}}
|
||||
${{this.createNotificationsTab()}}
|
||||
${{this.createShortcutsTab()}}
|
||||
${{this.createAdvancedTab()}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-outline-danger" id="reset-preferences">Reset to Defaults</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="export-preferences">Export</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="import-preferences">Import</button>
|
||||
<button type="button" class="btn btn-primary" id="save-preferences">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}}
|
||||
|
||||
createUITab() {{
|
||||
return `
|
||||
<div class="tab-pane fade show active" id="ui-tab">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Theme</label>
|
||||
<select class="form-select" id="pref-theme">
|
||||
<option value="auto">Auto (System)</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">UI Density</label>
|
||||
<select class="form-select" id="pref-density">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="spacious">Spacious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Language</label>
|
||||
<select class="form-select" id="pref-language">
|
||||
<option value="en">English</option>
|
||||
<option value="de">German</option>
|
||||
<option value="ja">Japanese</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Items per page</label>
|
||||
<select class="form-select" id="pref-items-per-page">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-animations">
|
||||
<label class="form-check-label" for="pref-animations">
|
||||
Enable animations
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-grid-view">
|
||||
<label class="form-check-label" for="pref-grid-view">
|
||||
Default to grid view
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
|
||||
createDownloadsTab() {{
|
||||
return `
|
||||
<div class="tab-pane fade" id="downloads-tab">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Download Quality</label>
|
||||
<select class="form-select" id="pref-download-quality">
|
||||
<option value="best">Best Available</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Concurrent Downloads</label>
|
||||
<input type="number" class="form-control" id="pref-concurrent-downloads" min="1" max="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-auto-download">
|
||||
<label class="form-check-label" for="pref-auto-download">
|
||||
Auto-download new episodes
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-retry-failed">
|
||||
<label class="form-check-label" for="pref-retry-failed">
|
||||
Retry failed downloads
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-auto-organize">
|
||||
<label class="form-check-label" for="pref-auto-organize">
|
||||
Auto-organize downloads
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
|
||||
createNotificationsTab() {{
|
||||
return `
|
||||
<div class="tab-pane fade" id="notifications-tab">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>General</h6>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-browser-notifications">
|
||||
<label class="form-check-label" for="pref-browser-notifications">
|
||||
Browser notifications
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-notification-sound">
|
||||
<label class="form-check-label" for="pref-notification-sound">
|
||||
Notification sound
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Notification Types</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="pref-notify-download-complete">
|
||||
<label class="form-check-label" for="pref-notify-download-complete">
|
||||
Download complete
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="pref-notify-download-error">
|
||||
<label class="form-check-label" for="pref-notify-download-error">
|
||||
Download errors
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="pref-notify-series-updated">
|
||||
<label class="form-check-label" for="pref-notify-series-updated">
|
||||
Series updates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
|
||||
createShortcutsTab() {{
|
||||
return `
|
||||
<div class="tab-pane fade" id="shortcuts-tab">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-shortcuts-enabled">
|
||||
<label class="form-check-label" for="pref-shortcuts-enabled">
|
||||
Enable keyboard shortcuts
|
||||
</label>
|
||||
</div>
|
||||
<div id="shortcuts-list">
|
||||
<!-- Shortcuts will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
|
||||
createAdvancedTab() {{
|
||||
return `
|
||||
<div class="tab-pane fade" id="advanced-tab">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-debug-mode">
|
||||
<label class="form-check-label" for="pref-debug-mode">
|
||||
Debug mode
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-performance-mode">
|
||||
<label class="form-check-label" for="pref-performance-mode">
|
||||
Performance mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-cache-enabled">
|
||||
<label class="form-check-label" for="pref-cache-enabled">
|
||||
Enable caching
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="pref-auto-backup">
|
||||
<label class="form-check-label" for="pref-auto-backup">
|
||||
Auto backup settings
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
|
||||
bindSettingsEvents() {{
|
||||
// Theme system preference listener
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {{
|
||||
if (this.get('ui.theme') === 'auto') {{
|
||||
this.applyTheme();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Settings modal events will be bound when modal is shown
|
||||
document.addEventListener('show.bs.modal', (e) => {{
|
||||
if (e.target.id === 'preferences-modal') {{
|
||||
this.populateSettingsForm();
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
populateSettingsForm() {{
|
||||
// Populate form fields with current preferences
|
||||
const fields = [
|
||||
{{ id: 'pref-theme', key: 'ui.theme' }},
|
||||
{{ id: 'pref-density', key: 'ui.density' }},
|
||||
{{ id: 'pref-language', key: 'ui.language' }},
|
||||
{{ id: 'pref-items-per-page', key: 'ui.items_per_page' }},
|
||||
{{ id: 'pref-animations', key: 'ui.animations_enabled' }},
|
||||
{{ id: 'pref-grid-view', key: 'ui.grid_view' }},
|
||||
{{ id: 'pref-download-quality', key: 'downloads.download_quality' }},
|
||||
{{ id: 'pref-concurrent-downloads', key: 'downloads.concurrent_downloads' }},
|
||||
{{ id: 'pref-auto-download', key: 'downloads.auto_download' }},
|
||||
{{ id: 'pref-retry-failed', key: 'downloads.retry_failed' }},
|
||||
{{ id: 'pref-auto-organize', key: 'downloads.auto_organize' }},
|
||||
{{ id: 'pref-browser-notifications', key: 'notifications.browser_notifications' }},
|
||||
{{ id: 'pref-notification-sound', key: 'downloads.notification_sound' }},
|
||||
{{ id: 'pref-shortcuts-enabled', key: 'keyboard_shortcuts.enabled' }},
|
||||
{{ id: 'pref-debug-mode', key: 'advanced.debug_mode' }},
|
||||
{{ id: 'pref-performance-mode', key: 'advanced.performance_mode' }},
|
||||
{{ id: 'pref-cache-enabled', key: 'advanced.cache_enabled' }},
|
||||
{{ id: 'pref-auto-backup', key: 'advanced.auto_backup' }}
|
||||
];
|
||||
|
||||
fields.forEach(field => {{
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) {{
|
||||
const value = this.get(field.key);
|
||||
if (element.type === 'checkbox') {{
|
||||
element.checked = value;
|
||||
}} else {{
|
||||
element.value = value;
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
setupAutoSave() {{
|
||||
// Auto-save preferences on change
|
||||
document.addEventListener('change', (e) => {{
|
||||
if (e.target.id && e.target.id.startsWith('pref-')) {{
|
||||
this.saveFormValue(e.target);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
saveFormValue(element) {{
|
||||
const keyMap = {{
|
||||
'pref-theme': 'ui.theme',
|
||||
'pref-density': 'ui.density',
|
||||
'pref-language': 'ui.language',
|
||||
'pref-items-per-page': 'ui.items_per_page',
|
||||
'pref-animations': 'ui.animations_enabled',
|
||||
'pref-grid-view': 'ui.grid_view',
|
||||
'pref-download-quality': 'downloads.download_quality',
|
||||
'pref-concurrent-downloads': 'downloads.concurrent_downloads',
|
||||
'pref-auto-download': 'downloads.auto_download',
|
||||
'pref-retry-failed': 'downloads.retry_failed',
|
||||
'pref-auto-organize': 'downloads.auto_organize',
|
||||
'pref-browser-notifications': 'notifications.browser_notifications',
|
||||
'pref-notification-sound': 'downloads.notification_sound',
|
||||
'pref-shortcuts-enabled': 'keyboard_shortcuts.enabled',
|
||||
'pref-debug-mode': 'advanced.debug_mode',
|
||||
'pref-performance-mode': 'advanced.performance_mode',
|
||||
'pref-cache-enabled': 'advanced.cache_enabled',
|
||||
'pref-auto-backup': 'advanced.auto_backup'
|
||||
}};
|
||||
|
||||
const key = keyMap[element.id];
|
||||
if (key) {{
|
||||
let value = element.type === 'checkbox' ? element.checked : element.value;
|
||||
if (element.type === 'number') {{
|
||||
value = parseInt(value, 10);
|
||||
}}
|
||||
this.set(key, value);
|
||||
}}
|
||||
}}
|
||||
|
||||
showPreferences() {{
|
||||
const modal = document.getElementById('preferences-modal');
|
||||
if (modal) {{
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
}}
|
||||
}}
|
||||
|
||||
onPreferenceChange(key, callback) {{
|
||||
if (!this.changeListeners.has(key)) {{
|
||||
this.changeListeners.set(key, []);
|
||||
}}
|
||||
this.changeListeners.get(key).push(callback);
|
||||
}}
|
||||
|
||||
notifyChangeListeners(key, newValue, oldValue) {{
|
||||
const listeners = this.changeListeners.get(key) || [];
|
||||
listeners.forEach(callback => {{
|
||||
try {{
|
||||
callback(newValue, oldValue, key);
|
||||
}} catch (error) {{
|
||||
console.error('Error in preference change listener:', error);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
reset() {{
|
||||
this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences));
|
||||
this.applyPreferences();
|
||||
this.saveToServer();
|
||||
localStorage.removeItem('aniworld_preferences');
|
||||
}}
|
||||
|
||||
export() {{
|
||||
const data = JSON.stringify(this.preferences, null, 2);
|
||||
const blob = new Blob([data], {{ type: 'application/json' }});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'aniworld_preferences.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
|
||||
import(file) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {{
|
||||
try {{
|
||||
const imported = JSON.parse(e.target.result);
|
||||
this.preferences = this.mergePreferences(this.defaultPreferences, imported);
|
||||
this.applyPreferences();
|
||||
this.saveToServer();
|
||||
resolve(true);
|
||||
}} catch (error) {{
|
||||
reject(error);
|
||||
}}
|
||||
}};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
}});
|
||||
}}
|
||||
|
||||
mergePreferences(defaults, userPrefs) {{
|
||||
const result = {{ ...defaults }};
|
||||
|
||||
for (const [key, value] of Object.entries(userPrefs)) {{
|
||||
if (key in result && typeof result[key] === 'object' && typeof value === 'object') {{
|
||||
result[key] = this.mergePreferences(result[key], value);
|
||||
}} else {{
|
||||
result[key] = value;
|
||||
}}
|
||||
}}
|
||||
|
||||
return result;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Initialize preferences when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
window.preferencesManager = new UserPreferencesManager();
|
||||
}});
|
||||
"""
|
||||
|
||||
def get_css(self):
|
||||
"""Generate CSS for user preferences."""
|
||||
return """
|
||||
/* User Preferences Styles */
|
||||
.density-compact {
|
||||
--spacing: 0.5rem;
|
||||
--font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.density-comfortable {
|
||||
--spacing: 1rem;
|
||||
--font-size: 1rem;
|
||||
}
|
||||
|
||||
.density-spacious {
|
||||
--spacing: 1.5rem;
|
||||
--font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.no-animations * {
|
||||
animation-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--bs-body-bg: #ffffff;
|
||||
--bs-body-color: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
--bs-body-bg: #121212;
|
||||
--bs-body-color: #e9ecef;
|
||||
--bs-primary: #0d6efd;
|
||||
}
|
||||
|
||||
#preferences-modal .nav-tabs {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
#preferences-modal .tab-pane {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.preference-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.preference-group h6 {
|
||||
color: var(--bs-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive preferences modal */
|
||||
@media (max-width: 768px) {
|
||||
#preferences-modal .modal-dialog {
|
||||
max-width: 95vw;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
#preferences-modal .nav-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#preferences-modal .nav-link {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# Create the preferences API blueprint
|
||||
preferences_bp = Blueprint('preferences', __name__, url_prefix='/api')
|
||||
|
||||
# Global preferences manager instance
|
||||
preferences_manager = UserPreferencesManager()
|
||||
|
||||
@preferences_bp.route('/preferences', methods=['GET'])
|
||||
def get_preferences():
|
||||
"""Get user preferences."""
|
||||
try:
|
||||
return jsonify(preferences_manager.get_user_session_preferences())
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences', methods=['PUT'])
|
||||
def update_preferences():
|
||||
"""Update user preferences."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
preferences_manager.preferences = preferences_manager.merge_preferences(
|
||||
preferences_manager.default_preferences,
|
||||
data
|
||||
)
|
||||
|
||||
if preferences_manager.save_preferences():
|
||||
return jsonify({'success': True, 'message': 'Preferences updated'})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to save preferences'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences/<key>', methods=['GET'])
|
||||
def get_preference(key):
|
||||
"""Get a specific preference."""
|
||||
try:
|
||||
value = preferences_manager.get_preference(key)
|
||||
return jsonify({'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences/<key>', methods=['PUT'])
|
||||
def set_preference(key):
|
||||
"""Set a specific preference."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
value = data.get('value')
|
||||
|
||||
if preferences_manager.set_preference(key, value):
|
||||
return jsonify({'success': True, 'key': key, 'value': value})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to set preference'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences/reset', methods=['POST'])
|
||||
def reset_preferences():
|
||||
"""Reset preferences to defaults."""
|
||||
try:
|
||||
if preferences_manager.reset_preferences():
|
||||
return jsonify({'success': True, 'message': 'Preferences reset to defaults'})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to reset preferences'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences/export', methods=['GET'])
|
||||
def export_preferences():
|
||||
"""Export preferences as JSON file."""
|
||||
try:
|
||||
from flask import Response
|
||||
json_data = preferences_manager.export_preferences()
|
||||
|
||||
return Response(
|
||||
json_data,
|
||||
mimetype='application/json',
|
||||
headers={'Content-Disposition': 'attachment; filename=aniworld_preferences.json'}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@preferences_bp.route('/preferences/import', methods=['POST'])
|
||||
def import_preferences():
|
||||
"""Import preferences from JSON file."""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No file selected'}), 400
|
||||
|
||||
json_data = file.read().decode('utf-8')
|
||||
|
||||
if preferences_manager.import_preferences(json_data):
|
||||
return jsonify({'success': True, 'message': 'Preferences imported successfully'})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to import preferences'}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
565
src/server/services/monitoring_service.py
Normal file
565
src/server/services/monitoring_service.py
Normal file
@ -0,0 +1,565 @@
|
||||
"""
|
||||
System Health Monitoring for AniWorld App
|
||||
|
||||
This module provides comprehensive system health checks and monitoring
|
||||
for the anime downloading application.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from flask import Blueprint, jsonify, request
|
||||
import os
|
||||
import socket
|
||||
import requests
|
||||
from auth import require_auth, optional_auth
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthMetric:
|
||||
"""Represents a health metric measurement."""
|
||||
name: str
|
||||
value: Any
|
||||
unit: str
|
||||
status: str # 'healthy', 'warning', 'critical'
|
||||
threshold_warning: Optional[float] = None
|
||||
threshold_critical: Optional[float] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
|
||||
class SystemHealthMonitor:
|
||||
"""Monitor system health metrics and performance."""
|
||||
|
||||
def __init__(self, check_interval: int = 60):
|
||||
self.check_interval = check_interval
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.metrics_history: Dict[str, List[HealthMetric]] = {}
|
||||
self.alerts: List[Dict] = []
|
||||
self.monitoring_enabled = True
|
||||
self.monitor_thread = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Configurable thresholds
|
||||
self.thresholds = {
|
||||
'cpu_percent': {'warning': 80.0, 'critical': 95.0},
|
||||
'memory_percent': {'warning': 85.0, 'critical': 95.0},
|
||||
'disk_percent': {'warning': 90.0, 'critical': 98.0},
|
||||
'disk_free_gb': {'warning': 5.0, 'critical': 1.0},
|
||||
'network_latency_ms': {'warning': 1000, 'critical': 5000},
|
||||
}
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start continuous health monitoring."""
|
||||
if self.monitor_thread and self.monitor_thread.is_alive():
|
||||
self.logger.warning("Health monitoring already running")
|
||||
return
|
||||
|
||||
self.monitoring_enabled = True
|
||||
self.monitor_thread = threading.Thread(target=self._monitoring_loop, daemon=True)
|
||||
self.monitor_thread.start()
|
||||
self.logger.info("System health monitoring started")
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop health monitoring."""
|
||||
self.monitoring_enabled = False
|
||||
if self.monitor_thread:
|
||||
self.monitor_thread.join(timeout=5)
|
||||
self.logger.info("System health monitoring stopped")
|
||||
|
||||
def _monitoring_loop(self):
|
||||
"""Main monitoring loop."""
|
||||
while self.monitoring_enabled:
|
||||
try:
|
||||
self.collect_all_metrics()
|
||||
time.sleep(self.check_interval)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in monitoring loop: {e}", exc_info=True)
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def collect_all_metrics(self):
|
||||
"""Collect all health metrics."""
|
||||
metrics = []
|
||||
|
||||
# System metrics
|
||||
metrics.extend(self.get_cpu_metrics())
|
||||
metrics.extend(self.get_memory_metrics())
|
||||
metrics.extend(self.get_disk_metrics())
|
||||
metrics.extend(self.get_network_metrics())
|
||||
|
||||
# Application metrics
|
||||
metrics.extend(self.get_process_metrics())
|
||||
|
||||
# Store metrics
|
||||
with self._lock:
|
||||
for metric in metrics:
|
||||
if metric.name not in self.metrics_history:
|
||||
self.metrics_history[metric.name] = []
|
||||
|
||||
self.metrics_history[metric.name].append(metric)
|
||||
|
||||
# Keep only last 24 hours of data
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
self.metrics_history[metric.name] = [
|
||||
m for m in self.metrics_history[metric.name]
|
||||
if m.timestamp > cutoff
|
||||
]
|
||||
|
||||
# Check for alerts
|
||||
self._check_alert_conditions(metric)
|
||||
|
||||
def get_cpu_metrics(self) -> List[HealthMetric]:
|
||||
"""Get CPU-related metrics."""
|
||||
metrics = []
|
||||
|
||||
# CPU usage percentage
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
status = self._get_status_for_metric('cpu_percent', cpu_percent)
|
||||
metrics.append(HealthMetric(
|
||||
name='cpu_percent',
|
||||
value=cpu_percent,
|
||||
unit='%',
|
||||
status=status,
|
||||
threshold_warning=self.thresholds['cpu_percent']['warning'],
|
||||
threshold_critical=self.thresholds['cpu_percent']['critical']
|
||||
))
|
||||
|
||||
# CPU count
|
||||
metrics.append(HealthMetric(
|
||||
name='cpu_count',
|
||||
value=psutil.cpu_count(),
|
||||
unit='cores',
|
||||
status='healthy'
|
||||
))
|
||||
|
||||
# Load average (Unix-like systems only)
|
||||
try:
|
||||
load_avg = psutil.getloadavg()
|
||||
metrics.append(HealthMetric(
|
||||
name='load_average_1m',
|
||||
value=load_avg[0],
|
||||
unit='',
|
||||
status='healthy'
|
||||
))
|
||||
except AttributeError:
|
||||
pass # Not available on Windows
|
||||
|
||||
return metrics
|
||||
|
||||
def get_memory_metrics(self) -> List[HealthMetric]:
|
||||
"""Get memory-related metrics."""
|
||||
metrics = []
|
||||
|
||||
# Virtual memory
|
||||
memory = psutil.virtual_memory()
|
||||
status = self._get_status_for_metric('memory_percent', memory.percent)
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name='memory_percent',
|
||||
value=memory.percent,
|
||||
unit='%',
|
||||
status=status,
|
||||
threshold_warning=self.thresholds['memory_percent']['warning'],
|
||||
threshold_critical=self.thresholds['memory_percent']['critical']
|
||||
))
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name='memory_total_gb',
|
||||
value=round(memory.total / (1024**3), 2),
|
||||
unit='GB',
|
||||
status='healthy'
|
||||
))
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name='memory_available_gb',
|
||||
value=round(memory.available / (1024**3), 2),
|
||||
unit='GB',
|
||||
status='healthy'
|
||||
))
|
||||
|
||||
# Swap memory
|
||||
swap = psutil.swap_memory()
|
||||
if swap.total > 0:
|
||||
metrics.append(HealthMetric(
|
||||
name='swap_percent',
|
||||
value=swap.percent,
|
||||
unit='%',
|
||||
status='warning' if swap.percent > 50 else 'healthy'
|
||||
))
|
||||
|
||||
return metrics
|
||||
|
||||
def get_disk_metrics(self) -> List[HealthMetric]:
|
||||
"""Get disk-related metrics."""
|
||||
metrics = []
|
||||
|
||||
# Check main disk partitions
|
||||
partitions = psutil.disk_partitions()
|
||||
for partition in partitions:
|
||||
if 'cdrom' in partition.opts or partition.fstype == '':
|
||||
continue
|
||||
|
||||
try:
|
||||
usage = psutil.disk_usage(partition.mountpoint)
|
||||
disk_percent = (usage.used / usage.total) * 100
|
||||
free_gb = usage.free / (1024**3)
|
||||
|
||||
# Disk usage percentage
|
||||
status_percent = self._get_status_for_metric('disk_percent', disk_percent)
|
||||
device_name = partition.device.replace(":", "").replace("\\", "")
|
||||
metrics.append(HealthMetric(
|
||||
name=f'disk_percent_{device_name}',
|
||||
value=round(disk_percent, 1),
|
||||
unit='%',
|
||||
status=status_percent,
|
||||
threshold_warning=self.thresholds['disk_percent']['warning'],
|
||||
threshold_critical=self.thresholds['disk_percent']['critical']
|
||||
))
|
||||
|
||||
# Free space in GB
|
||||
status_free = 'critical' if free_gb < self.thresholds['disk_free_gb']['critical'] \
|
||||
else 'warning' if free_gb < self.thresholds['disk_free_gb']['warning'] \
|
||||
else 'healthy'
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name=f'disk_free_gb_{device_name}',
|
||||
value=round(free_gb, 2),
|
||||
unit='GB',
|
||||
status=status_free,
|
||||
threshold_warning=self.thresholds['disk_free_gb']['warning'],
|
||||
threshold_critical=self.thresholds['disk_free_gb']['critical']
|
||||
))
|
||||
|
||||
except PermissionError:
|
||||
continue
|
||||
|
||||
# Disk I/O
|
||||
try:
|
||||
disk_io = psutil.disk_io_counters()
|
||||
if disk_io:
|
||||
metrics.append(HealthMetric(
|
||||
name='disk_read_mb',
|
||||
value=round(disk_io.read_bytes / (1024**2), 2),
|
||||
unit='MB',
|
||||
status='healthy'
|
||||
))
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name='disk_write_mb',
|
||||
value=round(disk_io.write_bytes / (1024**2), 2),
|
||||
unit='MB',
|
||||
status='healthy'
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metrics
|
||||
|
||||
def get_network_metrics(self) -> List[HealthMetric]:
|
||||
"""Get network-related metrics."""
|
||||
metrics = []
|
||||
|
||||
# Network I/O
|
||||
try:
|
||||
net_io = psutil.net_io_counters()
|
||||
if net_io:
|
||||
metrics.append(HealthMetric(
|
||||
name='network_sent_mb',
|
||||
value=round(net_io.bytes_sent / (1024**2), 2),
|
||||
unit='MB',
|
||||
status='healthy'
|
||||
))
|
||||
|
||||
metrics.append(HealthMetric(
|
||||
name='network_recv_mb',
|
||||
value=round(net_io.bytes_recv / (1024**2), 2),
|
||||
unit='MB',
|
||||
status='healthy'
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Network connectivity test
|
||||
try:
|
||||
start_time = time.time()
|
||||
socket.create_connection(("8.8.8.8", 53), timeout=5)
|
||||
latency = (time.time() - start_time) * 1000 # Convert to ms
|
||||
|
||||
status = self._get_status_for_metric('network_latency_ms', latency)
|
||||
metrics.append(HealthMetric(
|
||||
name='network_latency_ms',
|
||||
value=round(latency, 2),
|
||||
unit='ms',
|
||||
status=status,
|
||||
threshold_warning=self.thresholds['network_latency_ms']['warning'],
|
||||
threshold_critical=self.thresholds['network_latency_ms']['critical']
|
||||
))
|
||||
except Exception:
|
||||
metrics.append(HealthMetric(
|
||||
name='network_latency_ms',
|
||||
value=-1,
|
||||
unit='ms',
|
||||
status='critical'
|
||||
))
|
||||
|
||||
return metrics
|
||||
|
||||
def get_process_metrics(self) -> List[HealthMetric]:
|
||||
"""Get process-specific metrics."""
|
||||
metrics = []
|
||||
|
||||
try:
|
||||
# Current process metrics
|
||||
process = psutil.Process()
|
||||
|
||||
# Process CPU usage
|
||||
cpu_percent = process.cpu_percent()
|
||||
metrics.append(HealthMetric(
|
||||
name='process_cpu_percent',
|
||||
value=cpu_percent,
|
||||
unit='%',
|
||||
status='warning' if cpu_percent > 50 else 'healthy'
|
||||
))
|
||||
|
||||
# Process memory usage
|
||||
memory_info = process.memory_info()
|
||||
memory_mb = memory_info.rss / (1024**2)
|
||||
metrics.append(HealthMetric(
|
||||
name='process_memory_mb',
|
||||
value=round(memory_mb, 2),
|
||||
unit='MB',
|
||||
status='warning' if memory_mb > 1024 else 'healthy' # Warning if > 1GB
|
||||
))
|
||||
|
||||
# Process threads
|
||||
threads = process.num_threads()
|
||||
metrics.append(HealthMetric(
|
||||
name='process_threads',
|
||||
value=threads,
|
||||
unit='',
|
||||
status='warning' if threads > 50 else 'healthy'
|
||||
))
|
||||
|
||||
# Process connections
|
||||
try:
|
||||
connections = len(process.connections())
|
||||
metrics.append(HealthMetric(
|
||||
name='process_connections',
|
||||
value=connections,
|
||||
unit='',
|
||||
status='warning' if connections > 100 else 'healthy'
|
||||
))
|
||||
except psutil.AccessDenied:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get process metrics: {e}")
|
||||
|
||||
return metrics
|
||||
|
||||
def _get_status_for_metric(self, metric_name: str, value: float) -> str:
|
||||
"""Determine status based on thresholds."""
|
||||
if metric_name in self.thresholds:
|
||||
thresholds = self.thresholds[metric_name]
|
||||
if value >= thresholds['critical']:
|
||||
return 'critical'
|
||||
elif value >= thresholds['warning']:
|
||||
return 'warning'
|
||||
return 'healthy'
|
||||
|
||||
def _check_alert_conditions(self, metric: HealthMetric):
|
||||
"""Check if metric triggers an alert."""
|
||||
if metric.status in ['critical', 'warning']:
|
||||
alert = {
|
||||
'timestamp': metric.timestamp.isoformat(),
|
||||
'metric_name': metric.name,
|
||||
'value': metric.value,
|
||||
'unit': metric.unit,
|
||||
'status': metric.status,
|
||||
'message': f"{metric.name} is {metric.status}: {metric.value}{metric.unit}"
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
self.alerts.append(alert)
|
||||
|
||||
# Keep only last 100 alerts
|
||||
if len(self.alerts) > 100:
|
||||
self.alerts = self.alerts[-100:]
|
||||
|
||||
def get_current_health_status(self) -> Dict[str, Any]:
|
||||
"""Get current system health status."""
|
||||
with self._lock:
|
||||
latest_metrics = {}
|
||||
for name, history in self.metrics_history.items():
|
||||
if history:
|
||||
latest_metrics[name] = {
|
||||
'value': history[-1].value,
|
||||
'unit': history[-1].unit,
|
||||
'status': history[-1].status,
|
||||
'timestamp': history[-1].timestamp.isoformat()
|
||||
}
|
||||
|
||||
# Calculate overall health status
|
||||
statuses = [metric['status'] for metric in latest_metrics.values()]
|
||||
if 'critical' in statuses:
|
||||
overall_status = 'critical'
|
||||
elif 'warning' in statuses:
|
||||
overall_status = 'warning'
|
||||
else:
|
||||
overall_status = 'healthy'
|
||||
|
||||
return {
|
||||
'overall_status': overall_status,
|
||||
'metrics': latest_metrics,
|
||||
'recent_alerts': self.alerts[-10:], # Last 10 alerts
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_metric_history(self, metric_name: str, hours: int = 24) -> List[Dict]:
|
||||
"""Get history for a specific metric."""
|
||||
with self._lock:
|
||||
if metric_name not in self.metrics_history:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now() - timedelta(hours=hours)
|
||||
history = [
|
||||
{
|
||||
'value': m.value,
|
||||
'status': m.status,
|
||||
'timestamp': m.timestamp.isoformat()
|
||||
}
|
||||
for m in self.metrics_history[metric_name]
|
||||
if m.timestamp > cutoff
|
||||
]
|
||||
|
||||
return history
|
||||
|
||||
|
||||
# Blueprint for health endpoints
|
||||
health_bp = Blueprint('health', __name__)
|
||||
|
||||
# Global health monitor instance
|
||||
health_monitor = SystemHealthMonitor()
|
||||
|
||||
|
||||
@health_bp.route('/api/health/status')
|
||||
@optional_auth
|
||||
def get_health_status():
|
||||
"""Get current system health status."""
|
||||
try:
|
||||
status = health_monitor.get_current_health_status()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': status
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/metrics/<metric_name>')
|
||||
@optional_auth
|
||||
def get_metric_history(metric_name):
|
||||
"""Get history for a specific metric."""
|
||||
try:
|
||||
hours = int(request.args.get('hours', 24))
|
||||
history = health_monitor.get_metric_history(metric_name, hours)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'metric_name': metric_name,
|
||||
'history': history
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/alerts')
|
||||
@optional_auth
|
||||
def get_health_alerts():
|
||||
"""Get recent health alerts."""
|
||||
try:
|
||||
with health_monitor._lock:
|
||||
alerts = health_monitor.alerts[-50:] # Last 50 alerts
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'alerts': alerts,
|
||||
'count': len(alerts)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_health_monitoring():
|
||||
"""Start health monitoring."""
|
||||
try:
|
||||
health_monitor.start_monitoring()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Health monitoring started'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/stop', methods=['POST'])
|
||||
@require_auth
|
||||
def stop_health_monitoring():
|
||||
"""Stop health monitoring."""
|
||||
try:
|
||||
health_monitor.stop_monitoring()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Health monitoring stopped'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
def init_health_monitoring():
|
||||
"""Initialize and start health monitoring."""
|
||||
health_monitor.start_monitoring()
|
||||
|
||||
|
||||
def cleanup_health_monitoring():
|
||||
"""Clean up health monitoring resources."""
|
||||
health_monitor.stop_monitoring()
|
||||
|
||||
|
||||
# Export main components
|
||||
__all__ = [
|
||||
'SystemHealthMonitor',
|
||||
'HealthMetric',
|
||||
'health_bp',
|
||||
'health_monitor',
|
||||
'init_health_monitoring',
|
||||
'cleanup_health_monitoring'
|
||||
]
|
||||
303
src/server/services/queue_service.py
Normal file
303
src/server/services/queue_service.py
Normal file
@ -0,0 +1,303 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from web.controllers.auth_controller import optional_auth
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Create blueprint for download queue management
|
||||
download_queue_bp = Blueprint('download_queue', __name__)
|
||||
|
||||
# Global download queue state
|
||||
download_queue_state = {
|
||||
'active_downloads': [],
|
||||
'pending_queue': [],
|
||||
'completed_downloads': [],
|
||||
'failed_downloads': [],
|
||||
'queue_lock': threading.Lock(),
|
||||
'statistics': {
|
||||
'total_items': 0,
|
||||
'completed_items': 0,
|
||||
'failed_items': 0,
|
||||
'estimated_time_remaining': None,
|
||||
'current_speed': '0 MB/s',
|
||||
'average_speed': '0 MB/s'
|
||||
}
|
||||
}
|
||||
|
||||
@download_queue_bp.route('/queue')
|
||||
@optional_auth
|
||||
def queue_page():
|
||||
"""Download queue management page."""
|
||||
return render_template('queue.html')
|
||||
|
||||
@download_queue_bp.route('/api/queue/status')
|
||||
@optional_auth
|
||||
def get_queue_status():
|
||||
"""Get detailed download queue status."""
|
||||
with download_queue_state['queue_lock']:
|
||||
# Calculate ETA
|
||||
eta = None
|
||||
if download_queue_state['active_downloads']:
|
||||
active_download = download_queue_state['active_downloads'][0]
|
||||
if 'progress' in active_download and active_download['progress'].get('speed_mbps', 0) > 0:
|
||||
remaining_items = len(download_queue_state['pending_queue'])
|
||||
avg_speed = active_download['progress']['speed_mbps']
|
||||
# Rough estimation: assume 500MB per episode
|
||||
estimated_mb_remaining = remaining_items * 500
|
||||
eta_seconds = estimated_mb_remaining / avg_speed if avg_speed > 0 else None
|
||||
if eta_seconds:
|
||||
eta = datetime.now() + timedelta(seconds=eta_seconds)
|
||||
|
||||
return jsonify({
|
||||
'active_downloads': download_queue_state['active_downloads'],
|
||||
'pending_queue': download_queue_state['pending_queue'],
|
||||
'completed_downloads': download_queue_state['completed_downloads'][-10:], # Last 10
|
||||
'failed_downloads': download_queue_state['failed_downloads'][-10:], # Last 10
|
||||
'statistics': {
|
||||
**download_queue_state['statistics'],
|
||||
'eta': eta.isoformat() if eta else None
|
||||
}
|
||||
})
|
||||
|
||||
@download_queue_bp.route('/api/queue/clear', methods=['POST'])
|
||||
@optional_auth
|
||||
def clear_queue():
|
||||
"""Clear completed and failed downloads from queue."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
queue_type = data.get('type', 'completed') # 'completed', 'failed', or 'all'
|
||||
|
||||
with download_queue_state['queue_lock']:
|
||||
if queue_type == 'completed' or queue_type == 'all':
|
||||
download_queue_state['completed_downloads'].clear()
|
||||
|
||||
if queue_type == 'failed' or queue_type == 'all':
|
||||
download_queue_state['failed_downloads'].clear()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Cleared {queue_type} downloads'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@download_queue_bp.route('/api/queue/retry', methods=['POST'])
|
||||
@optional_auth
|
||||
def retry_failed_download():
|
||||
"""Retry a failed download."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
download_id = data.get('id')
|
||||
|
||||
if not download_id:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Download ID is required'
|
||||
}), 400
|
||||
|
||||
with download_queue_state['queue_lock']:
|
||||
# Find failed download
|
||||
failed_download = None
|
||||
for i, download in enumerate(download_queue_state['failed_downloads']):
|
||||
if download['id'] == download_id:
|
||||
failed_download = download_queue_state['failed_downloads'].pop(i)
|
||||
break
|
||||
|
||||
if not failed_download:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed download not found'
|
||||
}), 404
|
||||
|
||||
# Reset download status and add back to queue
|
||||
failed_download['status'] = 'queued'
|
||||
failed_download['error'] = None
|
||||
failed_download['retry_count'] = failed_download.get('retry_count', 0) + 1
|
||||
download_queue_state['pending_queue'].append(failed_download)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Download added back to queue'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@download_queue_bp.route('/api/queue/remove', methods=['POST'])
|
||||
@optional_auth
|
||||
def remove_from_queue():
|
||||
"""Remove an item from the pending queue."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
download_id = data.get('id')
|
||||
|
||||
if not download_id:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Download ID is required'
|
||||
}), 400
|
||||
|
||||
with download_queue_state['queue_lock']:
|
||||
# Find and remove from pending queue
|
||||
removed = False
|
||||
for i, download in enumerate(download_queue_state['pending_queue']):
|
||||
if download['id'] == download_id:
|
||||
download_queue_state['pending_queue'].pop(i)
|
||||
removed = True
|
||||
break
|
||||
|
||||
if not removed:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Download not found in queue'
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Download removed from queue'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@download_queue_bp.route('/api/queue/reorder', methods=['POST'])
|
||||
@optional_auth
|
||||
def reorder_queue():
|
||||
"""Reorder items in the pending queue."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
new_order = data.get('order') # Array of download IDs in new order
|
||||
|
||||
if not new_order or not isinstance(new_order, list):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Valid order array is required'
|
||||
}), 400
|
||||
|
||||
with download_queue_state['queue_lock']:
|
||||
# Create new queue based on the provided order
|
||||
old_queue = download_queue_state['pending_queue'].copy()
|
||||
new_queue = []
|
||||
|
||||
# Add items in the specified order
|
||||
for download_id in new_order:
|
||||
for download in old_queue:
|
||||
if download['id'] == download_id:
|
||||
new_queue.append(download)
|
||||
break
|
||||
|
||||
# Add any remaining items that weren't in the new order
|
||||
for download in old_queue:
|
||||
if download not in new_queue:
|
||||
new_queue.append(download)
|
||||
|
||||
download_queue_state['pending_queue'] = new_queue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Queue reordered successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
# Helper functions for queue management
|
||||
def add_to_download_queue(serie_name, episode_info, priority='normal'):
|
||||
"""Add a download to the queue."""
|
||||
import uuid
|
||||
|
||||
download_item = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'serie_name': serie_name,
|
||||
'episode': episode_info,
|
||||
'status': 'queued',
|
||||
'priority': priority,
|
||||
'added_at': datetime.now().isoformat(),
|
||||
'started_at': None,
|
||||
'completed_at': None,
|
||||
'error': None,
|
||||
'retry_count': 0,
|
||||
'progress': {
|
||||
'percent': 0,
|
||||
'downloaded_mb': 0,
|
||||
'total_mb': 0,
|
||||
'speed_mbps': 0,
|
||||
'eta_seconds': None
|
||||
}
|
||||
}
|
||||
|
||||
with download_queue_state['queue_lock']:
|
||||
# Insert based on priority
|
||||
if priority == 'high':
|
||||
download_queue_state['pending_queue'].insert(0, download_item)
|
||||
else:
|
||||
download_queue_state['pending_queue'].append(download_item)
|
||||
|
||||
download_queue_state['statistics']['total_items'] += 1
|
||||
|
||||
return download_item['id']
|
||||
|
||||
def update_download_progress(download_id, progress_data):
|
||||
"""Update progress for an active download."""
|
||||
with download_queue_state['queue_lock']:
|
||||
for download in download_queue_state['active_downloads']:
|
||||
if download['id'] == download_id:
|
||||
download['progress'].update(progress_data)
|
||||
|
||||
# Update global statistics
|
||||
if 'speed_mbps' in progress_data:
|
||||
download_queue_state['statistics']['current_speed'] = f"{progress_data['speed_mbps']:.1f} MB/s"
|
||||
|
||||
break
|
||||
|
||||
def move_download_to_completed(download_id, success=True, error=None):
|
||||
"""Move download from active to completed/failed."""
|
||||
with download_queue_state['queue_lock']:
|
||||
download = None
|
||||
for i, item in enumerate(download_queue_state['active_downloads']):
|
||||
if item['id'] == download_id:
|
||||
download = download_queue_state['active_downloads'].pop(i)
|
||||
break
|
||||
|
||||
if download:
|
||||
download['completed_at'] = datetime.now().isoformat()
|
||||
|
||||
if success:
|
||||
download['status'] = 'completed'
|
||||
download['progress']['percent'] = 100
|
||||
download_queue_state['completed_downloads'].append(download)
|
||||
download_queue_state['statistics']['completed_items'] += 1
|
||||
else:
|
||||
download['status'] = 'failed'
|
||||
download['error'] = error
|
||||
download_queue_state['failed_downloads'].append(download)
|
||||
download_queue_state['statistics']['failed_items'] += 1
|
||||
|
||||
def start_next_download():
|
||||
"""Move next queued download to active state."""
|
||||
with download_queue_state['queue_lock']:
|
||||
if download_queue_state['pending_queue'] and len(download_queue_state['active_downloads']) < 3: # Max 3 concurrent
|
||||
download = download_queue_state['pending_queue'].pop(0)
|
||||
download['status'] = 'downloading'
|
||||
download['started_at'] = datetime.now().isoformat()
|
||||
download_queue_state['active_downloads'].append(download)
|
||||
return download
|
||||
return None
|
||||
|
||||
def get_queue_statistics():
|
||||
"""Get current queue statistics."""
|
||||
with download_queue_state['queue_lock']:
|
||||
return download_queue_state['statistics'].copy()
|
||||
252
src/server/services/scheduler_service.py
Normal file
252
src/server/services/scheduler_service.py
Normal file
@ -0,0 +1,252 @@
|
||||
import threading
|
||||
import time
|
||||
import schedule
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Callable, Dict, Any
|
||||
import logging
|
||||
from shared.utils.process_utils import (with_process_lock, RESCAN_LOCK,
|
||||
ProcessLockError, is_process_running)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ScheduledOperations:
|
||||
"""Handle scheduled operations like automatic rescans and downloads."""
|
||||
|
||||
def __init__(self, config_manager, socketio=None):
|
||||
self.config = config_manager
|
||||
self.socketio = socketio
|
||||
self.scheduler_thread = None
|
||||
self.running = False
|
||||
self.rescan_callback: Optional[Callable] = None
|
||||
self.download_callback: Optional[Callable] = None
|
||||
self.last_scheduled_rescan: Optional[datetime] = None
|
||||
|
||||
# Load scheduled rescan settings
|
||||
self.scheduled_rescan_enabled = getattr(self.config, 'scheduled_rescan_enabled', False)
|
||||
self.scheduled_rescan_time = getattr(self.config, 'scheduled_rescan_time', '03:00')
|
||||
self.auto_download_after_rescan = getattr(self.config, 'auto_download_after_rescan', False)
|
||||
|
||||
def set_rescan_callback(self, callback: Callable):
|
||||
"""Set callback function for performing rescan operations."""
|
||||
self.rescan_callback = callback
|
||||
|
||||
def set_download_callback(self, callback: Callable):
|
||||
"""Set callback function for performing download operations."""
|
||||
self.download_callback = callback
|
||||
|
||||
def start_scheduler(self):
|
||||
"""Start the background scheduler thread."""
|
||||
if self.running:
|
||||
logger.warning("Scheduler is already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
logger.info("Scheduled operations started")
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""Stop the background scheduler."""
|
||||
self.running = False
|
||||
schedule.clear()
|
||||
if self.scheduler_thread and self.scheduler_thread.is_alive():
|
||||
self.scheduler_thread.join(timeout=5)
|
||||
logger.info("Scheduled operations stopped")
|
||||
|
||||
def _scheduler_loop(self):
|
||||
"""Main scheduler loop that runs in background thread."""
|
||||
self._setup_scheduled_jobs()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # Check every minute
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler error: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
def _setup_scheduled_jobs(self):
|
||||
"""Setup all scheduled jobs based on configuration."""
|
||||
schedule.clear()
|
||||
|
||||
if self.scheduled_rescan_enabled and self.scheduled_rescan_time:
|
||||
try:
|
||||
schedule.every().day.at(self.scheduled_rescan_time).do(self._perform_scheduled_rescan)
|
||||
logger.info(f"Scheduled daily rescan at {self.scheduled_rescan_time}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up scheduled rescan: {e}")
|
||||
|
||||
def _perform_scheduled_rescan(self):
|
||||
"""Perform the scheduled rescan operation."""
|
||||
try:
|
||||
logger.info("Starting scheduled rescan...")
|
||||
|
||||
# Emit scheduled rescan started event
|
||||
if self.socketio:
|
||||
self.socketio.emit('scheduled_rescan_started')
|
||||
|
||||
# Check if rescan is already running
|
||||
if is_process_running(RESCAN_LOCK):
|
||||
logger.warning("Rescan is already running, skipping scheduled rescan")
|
||||
if self.socketio:
|
||||
self.socketio.emit('scheduled_rescan_skipped', {
|
||||
'reason': 'Rescan already in progress'
|
||||
})
|
||||
return
|
||||
|
||||
# Perform the rescan using process lock
|
||||
@with_process_lock(RESCAN_LOCK, timeout_minutes=180)
|
||||
def perform_rescan():
|
||||
self.last_scheduled_rescan = datetime.now()
|
||||
|
||||
if self.rescan_callback:
|
||||
result = self.rescan_callback()
|
||||
logger.info("Scheduled rescan completed successfully")
|
||||
|
||||
if self.socketio:
|
||||
self.socketio.emit('scheduled_rescan_completed', {
|
||||
'timestamp': self.last_scheduled_rescan.isoformat(),
|
||||
'result': result
|
||||
})
|
||||
|
||||
# Auto-start download if configured
|
||||
if self.auto_download_after_rescan and self.download_callback:
|
||||
logger.info("Starting auto-download after scheduled rescan")
|
||||
threading.Thread(
|
||||
target=self._perform_auto_download,
|
||||
daemon=True
|
||||
).start()
|
||||
else:
|
||||
logger.warning("No rescan callback configured")
|
||||
|
||||
perform_rescan(_locked_by='scheduled_operation')
|
||||
|
||||
except ProcessLockError:
|
||||
logger.warning("Could not acquire rescan lock for scheduled operation")
|
||||
if self.socketio:
|
||||
self.socketio.emit('scheduled_rescan_error', {
|
||||
'error': 'Could not acquire rescan lock'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled rescan failed: {e}")
|
||||
if self.socketio:
|
||||
self.socketio.emit('scheduled_rescan_error', {
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
def _perform_auto_download(self):
|
||||
"""Perform automatic download after scheduled rescan."""
|
||||
try:
|
||||
# Wait a bit after rescan to let UI update
|
||||
time.sleep(10)
|
||||
|
||||
if self.download_callback:
|
||||
# Find series with missing episodes and start download
|
||||
logger.info("Starting auto-download of missing episodes")
|
||||
result = self.download_callback()
|
||||
|
||||
if self.socketio:
|
||||
self.socketio.emit('auto_download_started', {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'result': result
|
||||
})
|
||||
else:
|
||||
logger.warning("No download callback configured for auto-download")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-download after scheduled rescan failed: {e}")
|
||||
if self.socketio:
|
||||
self.socketio.emit('auto_download_error', {
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
def update_scheduled_rescan_config(self, enabled: bool, time_str: str, auto_download: bool = False):
|
||||
"""Update scheduled rescan configuration."""
|
||||
try:
|
||||
# Validate time format
|
||||
if enabled and time_str:
|
||||
datetime.strptime(time_str, '%H:%M')
|
||||
|
||||
# Update configuration
|
||||
self.scheduled_rescan_enabled = enabled
|
||||
self.scheduled_rescan_time = time_str
|
||||
self.auto_download_after_rescan = auto_download
|
||||
|
||||
# Save to config
|
||||
self.config.scheduled_rescan_enabled = enabled
|
||||
self.config.scheduled_rescan_time = time_str
|
||||
self.config.auto_download_after_rescan = auto_download
|
||||
self.config.save_config()
|
||||
|
||||
# Restart scheduler with new settings
|
||||
if self.running:
|
||||
self._setup_scheduled_jobs()
|
||||
|
||||
logger.info(f"Updated scheduled rescan config: enabled={enabled}, time={time_str}, auto_download={auto_download}")
|
||||
return True
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid time format: {time_str}")
|
||||
raise ValueError(f"Invalid time format. Use HH:MM format.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating scheduled rescan config: {e}")
|
||||
raise
|
||||
|
||||
def get_scheduled_rescan_config(self) -> Dict[str, Any]:
|
||||
"""Get current scheduled rescan configuration."""
|
||||
next_run = None
|
||||
if self.scheduled_rescan_enabled and self.scheduled_rescan_time:
|
||||
try:
|
||||
# Calculate next run time
|
||||
now = datetime.now()
|
||||
today_run = datetime.strptime(f"{now.strftime('%Y-%m-%d')} {self.scheduled_rescan_time}", '%Y-%m-%d %H:%M')
|
||||
|
||||
if now > today_run:
|
||||
# Next run is tomorrow
|
||||
next_run = today_run + timedelta(days=1)
|
||||
else:
|
||||
# Next run is today
|
||||
next_run = today_run
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating next run time: {e}")
|
||||
|
||||
return {
|
||||
'enabled': self.scheduled_rescan_enabled,
|
||||
'time': self.scheduled_rescan_time,
|
||||
'auto_download_after_rescan': self.auto_download_after_rescan,
|
||||
'next_run': next_run.isoformat() if next_run else None,
|
||||
'last_run': self.last_scheduled_rescan.isoformat() if self.last_scheduled_rescan else None,
|
||||
'is_running': self.running
|
||||
}
|
||||
|
||||
def trigger_manual_scheduled_rescan(self):
|
||||
"""Manually trigger a scheduled rescan (for testing purposes)."""
|
||||
logger.info("Manually triggering scheduled rescan")
|
||||
threading.Thread(target=self._perform_scheduled_rescan, daemon=True).start()
|
||||
|
||||
def get_next_scheduled_jobs(self) -> list:
|
||||
"""Get list of all scheduled jobs with their next run times."""
|
||||
jobs = []
|
||||
for job in schedule.jobs:
|
||||
jobs.append({
|
||||
'job_func': job.job_func.__name__ if hasattr(job.job_func, '__name__') else str(job.job_func),
|
||||
'next_run': job.next_run.isoformat() if job.next_run else None,
|
||||
'interval': str(job.interval),
|
||||
'unit': job.unit
|
||||
})
|
||||
return jobs
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
scheduled_operations = None
|
||||
|
||||
def init_scheduler(config_manager, socketio=None):
|
||||
"""Initialize the global scheduler."""
|
||||
global scheduled_operations
|
||||
scheduled_operations = ScheduledOperations(config_manager, socketio)
|
||||
return scheduled_operations
|
||||
|
||||
def get_scheduler():
|
||||
"""Get the global scheduler instance."""
|
||||
return scheduled_operations
|
||||
1361
src/server/services/search_service.py
Normal file
1361
src/server/services/search_service.py
Normal file
File diff suppressed because it is too large
Load Diff
268
src/server/services/setup_service.py
Normal file
268
src/server/services/setup_service.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""
|
||||
Setup service for detecting and managing application setup state.
|
||||
|
||||
This service determines if the application is properly configured and set up,
|
||||
following the application flow pattern: setup → auth → main application.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SetupService:
|
||||
"""Service for managing application setup detection and configuration."""
|
||||
|
||||
def __init__(self, config_path: str = "data/config.json", db_path: str = "data/aniworld.db"):
|
||||
"""Initialize the setup service with configuration and database paths."""
|
||||
self.config_path = Path(config_path)
|
||||
self.db_path = Path(db_path)
|
||||
self._config_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
def is_setup_complete(self) -> bool:
|
||||
"""
|
||||
Check if the application setup is complete.
|
||||
|
||||
Setup is considered complete if:
|
||||
1. Configuration file exists and is valid
|
||||
2. Database exists and is accessible
|
||||
3. Master password is configured
|
||||
4. Setup completion flag is set (if present)
|
||||
|
||||
Returns:
|
||||
bool: True if setup is complete, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if configuration file exists and is valid
|
||||
if not self._is_config_valid():
|
||||
logger.info("Setup incomplete: Configuration file is missing or invalid")
|
||||
return False
|
||||
|
||||
# Check if database exists and is accessible
|
||||
if not self._is_database_accessible():
|
||||
logger.info("Setup incomplete: Database is not accessible")
|
||||
return False
|
||||
|
||||
# Check if master password is configured
|
||||
if not self._is_master_password_configured():
|
||||
logger.info("Setup incomplete: Master password is not configured")
|
||||
return False
|
||||
|
||||
# Check for explicit setup completion flag
|
||||
config = self.get_config()
|
||||
if config and config.get("setup", {}).get("completed") is False:
|
||||
logger.info("Setup incomplete: Setup completion flag is False")
|
||||
return False
|
||||
|
||||
logger.debug("Setup validation complete: All checks passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking setup completion: {e}")
|
||||
return False
|
||||
|
||||
def _is_config_valid(self) -> bool:
|
||||
"""Check if the configuration file exists and contains valid JSON."""
|
||||
try:
|
||||
if not self.config_path.exists():
|
||||
return False
|
||||
|
||||
config = self.get_config()
|
||||
return config is not None and isinstance(config, dict)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Configuration validation error: {e}")
|
||||
return False
|
||||
|
||||
def _is_database_accessible(self) -> bool:
|
||||
"""Check if the database exists and is accessible."""
|
||||
try:
|
||||
if not self.db_path.exists():
|
||||
return False
|
||||
|
||||
# Try to connect and perform a simple query
|
||||
with sqlite3.connect(str(self.db_path)) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database accessibility check failed: {e}")
|
||||
return False
|
||||
|
||||
def _is_master_password_configured(self) -> bool:
|
||||
"""Check if master password is properly configured."""
|
||||
try:
|
||||
config = self.get_config()
|
||||
if not config:
|
||||
return False
|
||||
|
||||
security_config = config.get("security", {})
|
||||
|
||||
# Check if password hash exists
|
||||
password_hash = security_config.get("master_password_hash")
|
||||
salt = security_config.get("salt")
|
||||
|
||||
return bool(password_hash and salt and len(password_hash) > 0 and len(salt) > 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Master password configuration check failed: {e}")
|
||||
return False
|
||||
|
||||
def get_config(self, force_reload: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the configuration data from the config file.
|
||||
|
||||
Args:
|
||||
force_reload: If True, reload config from file even if cached
|
||||
|
||||
Returns:
|
||||
dict: Configuration data or None if not accessible
|
||||
"""
|
||||
try:
|
||||
if self._config_cache is None or force_reload:
|
||||
if not self.config_path.exists():
|
||||
return None
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self._config_cache = json.load(f)
|
||||
|
||||
return self._config_cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {e}")
|
||||
return None
|
||||
|
||||
def mark_setup_complete(self, config_updates: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Mark the setup as completed and optionally update configuration.
|
||||
|
||||
Args:
|
||||
config_updates: Additional configuration updates to apply
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
config = self.get_config() or {}
|
||||
|
||||
# Update configuration with any provided updates
|
||||
if config_updates:
|
||||
config.update(config_updates)
|
||||
|
||||
# Set setup completion flag
|
||||
if "setup" not in config:
|
||||
config["setup"] = {}
|
||||
config["setup"]["completed"] = True
|
||||
config["setup"]["completed_at"] = str(datetime.utcnow())
|
||||
|
||||
# Save updated configuration
|
||||
return self._save_config(config)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking setup as complete: {e}")
|
||||
return False
|
||||
|
||||
def reset_setup(self) -> bool:
|
||||
"""
|
||||
Reset the setup completion status (for development/testing).
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
config = self.get_config()
|
||||
if not config:
|
||||
return False
|
||||
|
||||
# Remove or set setup completion flag to false
|
||||
if "setup" in config:
|
||||
config["setup"]["completed"] = False
|
||||
|
||||
return self._save_config(config)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting setup: {e}")
|
||||
return False
|
||||
|
||||
def _save_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save configuration
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# Clear cache to force reload on next access
|
||||
self._config_cache = None
|
||||
|
||||
logger.info(f"Configuration saved to {self.config_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving configuration: {e}")
|
||||
return False
|
||||
|
||||
def get_setup_requirements(self) -> Dict[str, bool]:
|
||||
"""
|
||||
Get detailed breakdown of setup requirements and their status.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with requirement names and their completion status
|
||||
"""
|
||||
config = self.get_config()
|
||||
return {
|
||||
"config_file_exists": self.config_path.exists(),
|
||||
"config_file_valid": self._is_config_valid(),
|
||||
"database_exists": self.db_path.exists(),
|
||||
"database_accessible": self._is_database_accessible(),
|
||||
"master_password_configured": self._is_master_password_configured(),
|
||||
"setup_marked_complete": bool(config and config.get("setup", {}).get("completed", True))
|
||||
}
|
||||
|
||||
def get_missing_requirements(self) -> List[str]:
|
||||
"""
|
||||
Get list of missing setup requirements.
|
||||
|
||||
Returns:
|
||||
list: List of missing requirement descriptions
|
||||
"""
|
||||
requirements = self.get_setup_requirements()
|
||||
missing = []
|
||||
|
||||
if not requirements["config_file_exists"]:
|
||||
missing.append("Configuration file is missing")
|
||||
elif not requirements["config_file_valid"]:
|
||||
missing.append("Configuration file is invalid or corrupted")
|
||||
|
||||
if not requirements["database_exists"]:
|
||||
missing.append("Database file is missing")
|
||||
elif not requirements["database_accessible"]:
|
||||
missing.append("Database is not accessible or corrupted")
|
||||
|
||||
if not requirements["master_password_configured"]:
|
||||
missing.append("Master password is not configured")
|
||||
|
||||
if not requirements["setup_marked_complete"]:
|
||||
missing.append("Setup process was not completed")
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
# Convenience functions for easy import
|
||||
def is_setup_complete() -> bool:
|
||||
"""Convenience function to check if setup is complete."""
|
||||
service = SetupService()
|
||||
return service.is_setup_complete()
|
||||
|
||||
|
||||
def get_setup_service() -> SetupService:
|
||||
"""Get a configured setup service instance."""
|
||||
return SetupService()
|
||||
@ -1,6 +0,0 @@
|
||||
"""
|
||||
Utility modules for the FastAPI application.
|
||||
|
||||
This package contains dependency injection, security utilities, and other
|
||||
helper functions for the web application.
|
||||
"""
|
||||
@ -1,191 +0,0 @@
|
||||
"""
|
||||
Dependency injection utilities for FastAPI.
|
||||
|
||||
This module provides dependency injection functions for the FastAPI
|
||||
application, including SeriesApp instances, database sessions, and
|
||||
authentication dependencies.
|
||||
"""
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.services.auth_service import AuthError, auth_service
|
||||
|
||||
# Security scheme for JWT authentication
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# Global SeriesApp instance
|
||||
_series_app: Optional[SeriesApp] = None
|
||||
|
||||
|
||||
def get_series_app() -> SeriesApp:
|
||||
"""
|
||||
Dependency to get SeriesApp instance.
|
||||
|
||||
Returns:
|
||||
SeriesApp: The main application instance for anime management
|
||||
|
||||
Raises:
|
||||
HTTPException: If SeriesApp is not initialized or anime directory
|
||||
is not configured
|
||||
"""
|
||||
global _series_app
|
||||
|
||||
if not settings.anime_directory:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Anime directory not configured. Please complete setup."
|
||||
)
|
||||
|
||||
if _series_app is None:
|
||||
try:
|
||||
_series_app = SeriesApp(settings.anime_directory)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initialize SeriesApp: {str(e)}"
|
||||
)
|
||||
|
||||
return _series_app
|
||||
|
||||
|
||||
def reset_series_app() -> None:
|
||||
"""Reset the global SeriesApp instance (for testing or config changes)."""
|
||||
global _series_app
|
||||
_series_app = None
|
||||
|
||||
|
||||
async def get_database_session() -> AsyncGenerator[Optional[object], None]:
|
||||
"""
|
||||
Dependency to get database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session for async operations
|
||||
"""
|
||||
# TODO: Implement database session management
|
||||
# This is a placeholder for future database implementation
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Database functionality not yet implemented"
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> dict:
|
||||
"""
|
||||
Dependency to get current authenticated user.
|
||||
|
||||
Args:
|
||||
credentials: JWT token from Authorization header
|
||||
|
||||
Returns:
|
||||
dict: User information
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user is not authenticated
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing authorization credentials",
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
# Validate and decode token using the auth service
|
||||
session = auth_service.create_session_model(token)
|
||||
return session.dict()
|
||||
except AuthError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
def require_auth(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Dependency that requires authentication.
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user from get_current_user
|
||||
|
||||
Returns:
|
||||
dict: User information
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
def optional_auth(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
|
||||
HTTPBearer(auto_error=False)
|
||||
)
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Dependency for optional authentication.
|
||||
|
||||
Args:
|
||||
credentials: Optional JWT token from Authorization header
|
||||
|
||||
Returns:
|
||||
Optional[dict]: User information if authenticated, None otherwise
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return get_current_user(credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
class CommonQueryParams:
|
||||
"""Common query parameters for API endpoints."""
|
||||
|
||||
def __init__(self, skip: int = 0, limit: int = 100):
|
||||
self.skip = skip
|
||||
self.limit = limit
|
||||
|
||||
|
||||
def common_parameters(
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> CommonQueryParams:
|
||||
"""
|
||||
Dependency for common query parameters.
|
||||
|
||||
Args:
|
||||
skip: Number of items to skip (for pagination)
|
||||
limit: Maximum number of items to return
|
||||
|
||||
Returns:
|
||||
CommonQueryParams: Common query parameters
|
||||
"""
|
||||
return CommonQueryParams(skip=skip, limit=limit)
|
||||
|
||||
|
||||
# Dependency for rate limiting (placeholder)
|
||||
async def rate_limit_dependency():
|
||||
"""
|
||||
Dependency for rate limiting API requests.
|
||||
|
||||
TODO: Implement rate limiting logic
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Dependency for request logging (placeholder)
|
||||
async def log_request_dependency():
|
||||
"""
|
||||
Dependency for logging API requests.
|
||||
|
||||
TODO: Implement request logging logic
|
||||
"""
|
||||
pass
|
||||
@ -1,446 +0,0 @@
|
||||
"""
|
||||
Logging configuration for the AniWorld web application.
|
||||
|
||||
This module provides a comprehensive logging system with:
|
||||
- Structured logging with multiple handlers
|
||||
- Log rotation and cleanup
|
||||
- Request/response logging middleware
|
||||
- Performance logging
|
||||
- Different log levels for different components
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Context variables for request tracking
|
||||
request_id_var: ContextVar[Optional[str]] = ContextVar(
|
||||
'request_id', default=None)
|
||||
user_id_var: ContextVar[Optional[str]] = ContextVar(
|
||||
'user_id', default=None)
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Custom JSON formatter for structured logging."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON."""
|
||||
log_data = {
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno,
|
||||
}
|
||||
|
||||
# Add request context if available
|
||||
request_id = request_id_var.get()
|
||||
if request_id:
|
||||
log_data['request_id'] = request_id
|
||||
|
||||
user_id = user_id_var.get()
|
||||
if user_id:
|
||||
log_data['user_id'] = user_id
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra fields from the log record
|
||||
excluded_fields = {
|
||||
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
|
||||
'filename', 'module', 'lineno', 'funcName', 'created',
|
||||
'msecs', 'relativeCreated', 'thread', 'threadName',
|
||||
'processName', 'process', 'getMessage', 'exc_info',
|
||||
'exc_text', 'stack_info'
|
||||
}
|
||||
extra_fields = {
|
||||
k: v for k, v in record.__dict__.items()
|
||||
if k not in excluded_fields
|
||||
}
|
||||
if extra_fields:
|
||||
log_data['extra'] = extra_fields
|
||||
|
||||
return json.dumps(log_data, default=str)
|
||||
|
||||
|
||||
class LoggingConfig:
|
||||
"""Central logging configuration for the application."""
|
||||
|
||||
def __init__(self,
|
||||
log_dir: Union[str, Path] = "logs",
|
||||
log_level: str = "INFO",
|
||||
max_file_size: int = 10 * 1024 * 1024, # 10MB
|
||||
backup_count: int = 5,
|
||||
enable_console: bool = True,
|
||||
enable_json_format: bool = True):
|
||||
"""Initialize logging configuration.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files
|
||||
log_level: Default log level
|
||||
max_file_size: Maximum size for log files before rotation
|
||||
backup_count: Number of backup files to keep
|
||||
enable_console: Whether to enable console logging
|
||||
enable_json_format: Whether to use JSON formatting
|
||||
"""
|
||||
self.log_dir = Path(log_dir)
|
||||
self.log_level = getattr(logging, log_level.upper())
|
||||
self.max_file_size = max_file_size
|
||||
self.backup_count = backup_count
|
||||
self.enable_console = enable_console
|
||||
self.enable_json_format = enable_json_format
|
||||
|
||||
# Ensure log directory exists
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Configure loggers
|
||||
self._setup_loggers()
|
||||
|
||||
def _setup_loggers(self) -> None:
|
||||
"""Set up all application loggers."""
|
||||
# Clear existing handlers
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Set root logger level
|
||||
root_logger.setLevel(self.log_level)
|
||||
|
||||
# Create formatters
|
||||
json_formatter = JSONFormatter()
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Console handler
|
||||
if self.enable_console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(self.log_level)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Main application log file with rotation
|
||||
app_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_dir / "app.log",
|
||||
maxBytes=self.max_file_size,
|
||||
backupCount=self.backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
app_handler.setLevel(self.log_level)
|
||||
if self.enable_json_format:
|
||||
app_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
app_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(app_handler)
|
||||
|
||||
# Error log file
|
||||
error_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_dir / "error.log",
|
||||
maxBytes=self.max_file_size,
|
||||
backupCount=self.backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
if self.enable_json_format:
|
||||
error_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
error_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(error_handler)
|
||||
|
||||
# Download-specific log file
|
||||
download_logger = logging.getLogger("download")
|
||||
download_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_dir / "download.log",
|
||||
maxBytes=self.max_file_size,
|
||||
backupCount=self.backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
download_handler.setLevel(logging.INFO)
|
||||
if self.enable_json_format:
|
||||
download_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
download_handler.setFormatter(console_formatter)
|
||||
download_logger.addHandler(download_handler)
|
||||
download_logger.setLevel(logging.INFO)
|
||||
download_logger.propagate = False
|
||||
|
||||
# Security log file
|
||||
security_logger = logging.getLogger("security")
|
||||
security_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_dir / "security.log",
|
||||
maxBytes=self.max_file_size,
|
||||
backupCount=self.backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
security_handler.setLevel(logging.INFO)
|
||||
if self.enable_json_format:
|
||||
security_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
security_handler.setFormatter(console_formatter)
|
||||
security_logger.addHandler(security_handler)
|
||||
security_logger.setLevel(logging.INFO)
|
||||
security_logger.propagate = False
|
||||
|
||||
# Performance log file
|
||||
performance_logger = logging.getLogger("performance")
|
||||
performance_handler = logging.handlers.RotatingFileHandler(
|
||||
self.log_dir / "performance.log",
|
||||
maxBytes=self.max_file_size,
|
||||
backupCount=self.backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
performance_handler.setLevel(logging.INFO)
|
||||
if self.enable_json_format:
|
||||
performance_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
performance_handler.setFormatter(console_formatter)
|
||||
performance_logger.addHandler(performance_handler)
|
||||
performance_logger.setLevel(logging.INFO)
|
||||
performance_logger.propagate = False
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
|
||||
logging.getLogger("charset_normalizer").setLevel(logging.WARNING)
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware for logging HTTP requests and responses."""
|
||||
|
||||
def __init__(self, app, logger_name: str = "web"):
|
||||
super().__init__(app)
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
self.performance_logger = logging.getLogger("performance")
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request and log details."""
|
||||
# Generate unique request ID
|
||||
request_id = str(uuid.uuid4())
|
||||
request_id_var.set(request_id)
|
||||
|
||||
# Extract client information
|
||||
client_ip = self._get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
# Log request start
|
||||
start_time = time.time()
|
||||
self.logger.info(
|
||||
"Request started",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"client_ip": client_ip,
|
||||
"user_agent": user_agent,
|
||||
"request_size": request.headers.get("content-length", 0)
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate processing time
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
# Log successful response
|
||||
self.logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"processing_time": processing_time,
|
||||
"response_size": response.headers.get("content-length", 0)
|
||||
}
|
||||
)
|
||||
|
||||
# Log performance metrics
|
||||
self.performance_logger.info(
|
||||
"Request performance",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"status_code": response.status_code,
|
||||
"processing_time": processing_time,
|
||||
"client_ip": client_ip
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Calculate processing time even for errors
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
# Log error
|
||||
self.logger.error(
|
||||
"Request failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"processing_time": processing_time
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Re-raise the exception
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Clear request context
|
||||
request_id_var.set(None)
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract client IP address from request."""
|
||||
# Check for forwarded headers first
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# Fall back to direct client IP
|
||||
if hasattr(request, "client") and request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def setup_logging(log_dir: str = "logs",
|
||||
log_level: str = "INFO",
|
||||
enable_json: bool = True) -> LoggingConfig:
|
||||
"""Set up logging for the application.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files
|
||||
log_level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
enable_json: Whether to use JSON formatting
|
||||
|
||||
Returns:
|
||||
LoggingConfig instance
|
||||
"""
|
||||
return LoggingConfig(
|
||||
log_dir=log_dir,
|
||||
log_level=log_level,
|
||||
enable_json_format=enable_json
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance with the specified name.
|
||||
|
||||
Args:
|
||||
name: Logger name, typically __name__
|
||||
|
||||
Returns:
|
||||
Logger instance
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def log_download_progress(episode_id: str,
|
||||
progress: float,
|
||||
status: str,
|
||||
speed: Optional[float] = None,
|
||||
eta: Optional[str] = None) -> None:
|
||||
"""Log download progress information.
|
||||
|
||||
Args:
|
||||
episode_id: Episode identifier
|
||||
progress: Download progress (0.0 to 1.0)
|
||||
status: Download status
|
||||
speed: Download speed in bytes/second
|
||||
eta: Estimated time remaining
|
||||
"""
|
||||
logger = logging.getLogger("download")
|
||||
logger.info(
|
||||
"Download progress",
|
||||
extra={
|
||||
"episode_id": episode_id,
|
||||
"progress": progress,
|
||||
"status": status,
|
||||
"speed": speed,
|
||||
"eta": eta
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def log_security_event(event_type: str,
|
||||
details: Dict[str, Any],
|
||||
severity: str = "INFO") -> None:
|
||||
"""Log security-related events.
|
||||
|
||||
Args:
|
||||
event_type: Type of security event
|
||||
details: Event details
|
||||
severity: Event severity
|
||||
"""
|
||||
logger = logging.getLogger("security")
|
||||
log_func = getattr(logger, severity.lower(), logger.info)
|
||||
log_func(
|
||||
f"Security event: {event_type}",
|
||||
extra=details
|
||||
)
|
||||
|
||||
|
||||
def cleanup_old_logs(log_dir: Union[str, Path],
|
||||
days_to_keep: int = 30) -> None:
|
||||
"""Clean up old log files.
|
||||
|
||||
Args:
|
||||
log_dir: Directory containing log files
|
||||
days_to_keep: Number of days to keep log files
|
||||
"""
|
||||
log_path = Path(log_dir)
|
||||
if not log_path.exists():
|
||||
return
|
||||
|
||||
cutoff_time = time.time() - (days_to_keep * 24 * 60 * 60)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
for log_file in log_path.glob("*.log*"):
|
||||
try:
|
||||
if log_file.stat().st_mtime < cutoff_time:
|
||||
log_file.unlink()
|
||||
logger.info(f"Deleted old log file: {log_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete log file {log_file}: {e}")
|
||||
|
||||
|
||||
# Initialize default logging configuration
|
||||
_default_config: Optional[LoggingConfig] = None
|
||||
|
||||
|
||||
def init_logging(log_dir: str = "logs",
|
||||
log_level: str = "INFO",
|
||||
enable_json: bool = True) -> None:
|
||||
"""Initialize the logging system.
|
||||
|
||||
This should be called once at application startup.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files
|
||||
log_level: Log level
|
||||
enable_json: Whether to use JSON formatting
|
||||
"""
|
||||
global _default_config
|
||||
_default_config = setup_logging(log_dir, log_level, enable_json)
|
||||
|
||||
|
||||
def get_request_logging_middleware() -> RequestLoggingMiddleware:
|
||||
"""Get the request logging middleware instance."""
|
||||
return RequestLoggingMiddleware
|
||||
@ -1,12 +0,0 @@
|
||||
"""
|
||||
Shared templates configuration for FastAPI application.
|
||||
|
||||
This module provides centralized Jinja2 template configuration.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
# Configure templates - shared across controllers
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Aniworld</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/styles.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title mb-0">Error {{ status_code }}</h4>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-warning" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h5>{{ error }}</h5>
|
||||
<p class="text-muted">
|
||||
{% if status_code == 404 %}
|
||||
The page you're looking for doesn't exist.
|
||||
{% elif status_code == 500 %}
|
||||
Something went wrong on our end. Please try again later.
|
||||
{% else %}
|
||||
An unexpected error occurred.
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://kit.fontawesome.com/your-kit-id.js" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1 +0,0 @@
|
||||
"""Test package for Aniworld application."""
|
||||
@ -1,44 +0,0 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_auth_flow_setup_login_status_logout():
|
||||
# Ensure not configured at start for test isolation
|
||||
auth_service._hash = None
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Setup
|
||||
r = await client.post("/api/auth/setup", json={"master_password": "Aa!strong1"})
|
||||
assert r.status_code == 201
|
||||
|
||||
# Bad login
|
||||
r = await client.post("/api/auth/login", json={"password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
# Good login
|
||||
r = await client.post("/api/auth/login", json={"password": "Aa!strong1"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "access_token" in data
|
||||
|
||||
token = data["access_token"]
|
||||
|
||||
# Status unauthenticated when no auth header
|
||||
r = await client.get("/api/auth/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["configured"] is True
|
||||
|
||||
# Status authenticated with header
|
||||
r = await client.get("/api/auth/status", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["authenticated"] is True
|
||||
|
||||
# Logout
|
||||
r = await client.post("/api/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
|
||||
@ -1 +0,0 @@
|
||||
"""Unit tests package for Aniworld application."""
|
||||
@ -1,46 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from server.models.auth import (
|
||||
AuthStatus,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SessionModel,
|
||||
SetupRequest,
|
||||
)
|
||||
|
||||
|
||||
def test_login_request_validation():
|
||||
# password is required
|
||||
with pytest.raises(ValueError):
|
||||
LoginRequest(password="")
|
||||
|
||||
req = LoginRequest(password="hunter2", remember=True)
|
||||
assert req.password == "hunter2"
|
||||
assert req.remember is True
|
||||
|
||||
|
||||
def test_setup_request_requires_min_length():
|
||||
with pytest.raises(ValueError):
|
||||
SetupRequest(master_password="short")
|
||||
|
||||
good = SetupRequest(master_password="longenoughpassword")
|
||||
assert good.master_password == "longenoughpassword"
|
||||
|
||||
|
||||
def test_login_response_and_session_model():
|
||||
expires = datetime.utcnow() + timedelta(hours=1)
|
||||
lr = LoginResponse(access_token="tok", expires_at=expires)
|
||||
assert lr.token_type == "bearer"
|
||||
assert lr.access_token == "tok"
|
||||
|
||||
s = SessionModel(session_id="abc123", user="admin", expires_at=expires)
|
||||
assert s.session_id == "abc123"
|
||||
assert s.user == "admin"
|
||||
|
||||
|
||||
def test_auth_status_defaults():
|
||||
status = AuthStatus(configured=False, authenticated=False)
|
||||
assert status.configured is False
|
||||
assert status.authenticated is False
|
||||
@ -1,59 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from src.server.services.auth_service import AuthError, AuthService, LockedOutError
|
||||
|
||||
|
||||
def test_setup_and_validate_success():
|
||||
svc = AuthService()
|
||||
password = "Str0ng!Pass"
|
||||
svc.setup_master_password(password)
|
||||
assert svc.is_configured()
|
||||
|
||||
assert svc.validate_master_password(password) is True
|
||||
|
||||
resp = svc.create_access_token(subject="tester", remember=False)
|
||||
assert resp.token_type == "bearer"
|
||||
assert resp.access_token
|
||||
|
||||
sess = svc.create_session_model(resp.access_token)
|
||||
assert sess.expires_at is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"short",
|
||||
"lowercaseonly",
|
||||
"UPPERCASEONLY",
|
||||
"NoSpecial1",
|
||||
],
|
||||
)
|
||||
def test_setup_weak_passwords(bad):
|
||||
svc = AuthService()
|
||||
with pytest.raises(ValueError):
|
||||
svc.setup_master_password(bad)
|
||||
|
||||
|
||||
def test_failed_attempts_and_lockout():
|
||||
svc = AuthService()
|
||||
password = "An0ther$Good1"
|
||||
svc.setup_master_password(password)
|
||||
|
||||
identifier = "test-ip"
|
||||
# fail max_attempts times
|
||||
for _ in range(svc.max_attempts):
|
||||
assert (
|
||||
svc.validate_master_password("wrongpassword", identifier=identifier)
|
||||
is False
|
||||
)
|
||||
|
||||
# Next attempt must raise LockedOutError
|
||||
with pytest.raises(LockedOutError):
|
||||
svc.validate_master_password(password, identifier=identifier)
|
||||
|
||||
|
||||
def test_token_decode_invalid():
|
||||
svc = AuthService()
|
||||
# invalid token should raise AuthError
|
||||
with pytest.raises(AuthError):
|
||||
svc.decode_token("not-a-jwt")
|
||||
@ -1,296 +0,0 @@
|
||||
"""
|
||||
Unit tests for dependency injection system.
|
||||
|
||||
This module tests the FastAPI dependency injection utilities including
|
||||
SeriesApp dependency, database session dependency, and authentication
|
||||
dependencies.
|
||||
"""
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from src.server.utils.dependencies import (
|
||||
CommonQueryParams,
|
||||
common_parameters,
|
||||
get_current_user,
|
||||
get_database_session,
|
||||
get_series_app,
|
||||
log_request_dependency,
|
||||
optional_auth,
|
||||
rate_limit_dependency,
|
||||
require_auth,
|
||||
reset_series_app,
|
||||
)
|
||||
|
||||
|
||||
class TestSeriesAppDependency:
|
||||
"""Test cases for SeriesApp dependency injection."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup for each test method."""
|
||||
# Reset the global SeriesApp instance before each test
|
||||
reset_series_app()
|
||||
|
||||
@patch('src.server.utils.dependencies.settings')
|
||||
@patch('src.server.utils.dependencies.SeriesApp')
|
||||
def test_get_series_app_success(self, mock_series_app_class,
|
||||
mock_settings):
|
||||
"""Test successful SeriesApp dependency injection."""
|
||||
# Arrange
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
mock_series_app_instance = Mock()
|
||||
mock_series_app_class.return_value = mock_series_app_instance
|
||||
|
||||
# Act
|
||||
result = get_series_app()
|
||||
|
||||
# Assert
|
||||
assert result == mock_series_app_instance
|
||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
||||
|
||||
@patch('src.server.utils.dependencies.settings')
|
||||
def test_get_series_app_no_directory_configured(self, mock_settings):
|
||||
"""Test SeriesApp dependency when directory is not configured."""
|
||||
# Arrange
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_series_app()
|
||||
|
||||
assert (exc_info.value.status_code ==
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
assert "Anime directory not configured" in str(exc_info.value.detail)
|
||||
|
||||
@patch('src.server.utils.dependencies.settings')
|
||||
@patch('src.server.utils.dependencies.SeriesApp')
|
||||
def test_get_series_app_initialization_error(self, mock_series_app_class,
|
||||
mock_settings):
|
||||
"""Test SeriesApp dependency when initialization fails."""
|
||||
# Arrange
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
mock_series_app_class.side_effect = Exception("Initialization failed")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_series_app()
|
||||
|
||||
assert (exc_info.value.status_code ==
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
assert "Failed to initialize SeriesApp" in str(exc_info.value.detail)
|
||||
|
||||
@patch('src.server.utils.dependencies.settings')
|
||||
@patch('src.server.utils.dependencies.SeriesApp')
|
||||
def test_get_series_app_singleton_behavior(self, mock_series_app_class,
|
||||
mock_settings):
|
||||
"""Test SeriesApp dependency returns same instance on calls."""
|
||||
# Arrange
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
mock_series_app_instance = Mock()
|
||||
mock_series_app_class.return_value = mock_series_app_instance
|
||||
|
||||
# Act
|
||||
result1 = get_series_app()
|
||||
result2 = get_series_app()
|
||||
|
||||
# Assert
|
||||
assert result1 == result2
|
||||
assert result1 == mock_series_app_instance
|
||||
# SeriesApp should only be instantiated once
|
||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
||||
|
||||
def test_reset_series_app(self):
|
||||
"""Test resetting the global SeriesApp instance."""
|
||||
# Act
|
||||
reset_series_app()
|
||||
|
||||
# Assert - this should complete without error
|
||||
|
||||
|
||||
class TestDatabaseDependency:
|
||||
"""Test cases for database session dependency injection."""
|
||||
|
||||
def test_get_database_session_not_implemented(self):
|
||||
"""Test that database session dependency is not yet implemented."""
|
||||
import inspect
|
||||
|
||||
# Test that function exists and is an async generator function
|
||||
assert inspect.isfunction(get_database_session)
|
||||
assert inspect.iscoroutinefunction(get_database_session)
|
||||
|
||||
# Since it immediately raises an exception,
|
||||
# we can't test the actual async behavior easily
|
||||
|
||||
|
||||
class TestAuthenticationDependencies:
|
||||
"""Test cases for authentication dependency injection."""
|
||||
|
||||
def test_get_current_user_not_implemented(self):
|
||||
"""Test that current user dependency is not yet implemented."""
|
||||
# Arrange
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials="test-token"
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_user(credentials)
|
||||
|
||||
assert (exc_info.value.status_code ==
|
||||
status.HTTP_501_NOT_IMPLEMENTED)
|
||||
assert ("Authentication functionality not yet implemented" in
|
||||
str(exc_info.value.detail))
|
||||
|
||||
def test_require_auth_with_user(self):
|
||||
"""Test require_auth dependency with authenticated user."""
|
||||
# Arrange
|
||||
mock_user = {"user_id": 123, "username": "testuser"}
|
||||
|
||||
# Act
|
||||
result = require_auth(mock_user)
|
||||
|
||||
# Assert
|
||||
assert result == mock_user
|
||||
|
||||
def test_optional_auth_without_credentials(self):
|
||||
"""Test optional authentication without credentials."""
|
||||
# Act
|
||||
result = optional_auth(None)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@patch('src.server.utils.dependencies.get_current_user')
|
||||
def test_optional_auth_with_valid_credentials(self, mock_get_current_user):
|
||||
"""Test optional authentication with valid credentials."""
|
||||
# Arrange
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials="valid-token"
|
||||
)
|
||||
mock_user = {"user_id": 123, "username": "testuser"}
|
||||
mock_get_current_user.return_value = mock_user
|
||||
|
||||
# Act
|
||||
result = optional_auth(credentials)
|
||||
|
||||
# Assert
|
||||
assert result == mock_user
|
||||
mock_get_current_user.assert_called_once_with(credentials)
|
||||
|
||||
@patch('src.server.utils.dependencies.get_current_user')
|
||||
def test_optional_auth_with_invalid_credentials(self,
|
||||
mock_get_current_user):
|
||||
"""Test optional authentication with invalid credentials."""
|
||||
# Arrange
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials="invalid-token"
|
||||
)
|
||||
mock_get_current_user.side_effect = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = optional_auth(credentials)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_get_current_user.assert_called_once_with(credentials)
|
||||
|
||||
|
||||
class TestCommonQueryParams:
|
||||
"""Test cases for common query parameters."""
|
||||
|
||||
def test_common_query_params_initialization(self):
|
||||
"""Test CommonQueryParams initialization."""
|
||||
# Act
|
||||
params = CommonQueryParams(skip=10, limit=50)
|
||||
|
||||
# Assert
|
||||
assert params.skip == 10
|
||||
assert params.limit == 50
|
||||
|
||||
def test_common_query_params_defaults(self):
|
||||
"""Test CommonQueryParams with default values."""
|
||||
# Act
|
||||
params = CommonQueryParams()
|
||||
|
||||
# Assert
|
||||
assert params.skip == 0
|
||||
assert params.limit == 100
|
||||
|
||||
def test_common_parameters_dependency(self):
|
||||
"""Test common parameters dependency function."""
|
||||
# Act
|
||||
params = common_parameters(skip=20, limit=30)
|
||||
|
||||
# Assert
|
||||
assert isinstance(params, CommonQueryParams)
|
||||
assert params.skip == 20
|
||||
assert params.limit == 30
|
||||
|
||||
def test_common_parameters_dependency_defaults(self):
|
||||
"""Test common parameters dependency with defaults."""
|
||||
# Act
|
||||
params = common_parameters()
|
||||
|
||||
# Assert
|
||||
assert isinstance(params, CommonQueryParams)
|
||||
assert params.skip == 0
|
||||
assert params.limit == 100
|
||||
|
||||
|
||||
class TestUtilityDependencies:
|
||||
"""Test cases for utility dependencies."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_dependency(self):
|
||||
"""Test rate limit dependency (placeholder)."""
|
||||
# Act - should complete without error
|
||||
await rate_limit_dependency()
|
||||
|
||||
# Assert - no exception should be raised
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_request_dependency(self):
|
||||
"""Test log request dependency (placeholder)."""
|
||||
# Act - should complete without error
|
||||
await log_request_dependency()
|
||||
|
||||
# Assert - no exception should be raised
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Integration test scenarios for dependency injection."""
|
||||
|
||||
def test_series_app_lifecycle(self):
|
||||
"""Test the complete SeriesApp dependency lifecycle."""
|
||||
# Use separate mock instances for each call
|
||||
with patch('src.server.utils.dependencies.settings') as mock_settings:
|
||||
with patch('src.server.utils.dependencies.SeriesApp') as mock_series_app_class:
|
||||
# Arrange
|
||||
mock_settings.anime_directory = "/path/to/anime"
|
||||
|
||||
# Create separate mock instances for each instantiation
|
||||
mock_instance1 = MagicMock()
|
||||
mock_instance2 = MagicMock()
|
||||
mock_series_app_class.side_effect = [mock_instance1, mock_instance2]
|
||||
|
||||
# Act - Get SeriesApp instance
|
||||
app1 = get_series_app()
|
||||
app2 = get_series_app() # Should return same instance
|
||||
|
||||
# Reset and get again
|
||||
reset_series_app()
|
||||
app3 = get_series_app()
|
||||
|
||||
# Assert
|
||||
assert app1 == app2 # Same instance due to singleton behavior
|
||||
assert app1 != app3 # Different instance after reset
|
||||
# Called twice due to reset
|
||||
assert mock_series_app_class.call_count == 2
|
||||
@ -1,400 +0,0 @@
|
||||
"""
|
||||
Tests for the logging system.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.utils.logging import (
|
||||
JSONFormatter,
|
||||
LoggingConfig,
|
||||
RequestLoggingMiddleware,
|
||||
cleanup_old_logs,
|
||||
get_logger,
|
||||
init_logging,
|
||||
log_download_progress,
|
||||
log_security_event,
|
||||
request_id_var,
|
||||
setup_logging,
|
||||
user_id_var,
|
||||
)
|
||||
|
||||
|
||||
class TestJSONFormatter:
|
||||
"""Test the JSON log formatter."""
|
||||
|
||||
def test_format_basic_log(self):
|
||||
"""Test basic log formatting."""
|
||||
formatter = JSONFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="/test.py",
|
||||
lineno=42,
|
||||
msg="Test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
result = formatter.format(record)
|
||||
log_data = json.loads(result)
|
||||
|
||||
assert log_data["level"] == "INFO"
|
||||
assert log_data["logger"] == "test"
|
||||
assert log_data["message"] == "Test message"
|
||||
assert log_data["module"] == "test"
|
||||
assert log_data["line"] == 42
|
||||
assert "timestamp" in log_data
|
||||
|
||||
def test_format_with_context(self):
|
||||
"""Test log formatting with request context."""
|
||||
request_id_var.set("test-request-123")
|
||||
user_id_var.set("user-456")
|
||||
|
||||
formatter = JSONFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="/test.py",
|
||||
lineno=42,
|
||||
msg="Test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
result = formatter.format(record)
|
||||
log_data = json.loads(result)
|
||||
|
||||
assert log_data["request_id"] == "test-request-123"
|
||||
assert log_data["user_id"] == "user-456"
|
||||
|
||||
# Clean up context
|
||||
request_id_var.set(None)
|
||||
user_id_var.set(None)
|
||||
|
||||
def test_format_with_exception(self):
|
||||
"""Test log formatting with exception."""
|
||||
formatter = JSONFormatter()
|
||||
|
||||
try:
|
||||
raise ValueError("Test error")
|
||||
except ValueError:
|
||||
import sys
|
||||
exc_info = sys.exc_info()
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.ERROR,
|
||||
pathname="/test.py",
|
||||
lineno=42,
|
||||
msg="Error occurred",
|
||||
args=(),
|
||||
exc_info=exc_info
|
||||
)
|
||||
|
||||
result = formatter.format(record)
|
||||
log_data = json.loads(result)
|
||||
|
||||
assert log_data["level"] == "ERROR"
|
||||
assert "exception" in log_data
|
||||
assert "ValueError" in log_data["exception"]
|
||||
|
||||
def test_format_with_extra_fields(self):
|
||||
"""Test log formatting with extra fields."""
|
||||
formatter = JSONFormatter()
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="/test.py",
|
||||
lineno=42,
|
||||
msg="Test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
# Add extra fields
|
||||
record.episode_id = "episode-123"
|
||||
record.download_speed = 1024.5
|
||||
|
||||
result = formatter.format(record)
|
||||
log_data = json.loads(result)
|
||||
|
||||
assert "extra" in log_data
|
||||
assert log_data["extra"]["episode_id"] == "episode-123"
|
||||
assert log_data["extra"]["download_speed"] == 1024.5
|
||||
|
||||
|
||||
class TestLoggingConfig:
|
||||
"""Test the logging configuration."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
"""Test initialization with default values."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
config = LoggingConfig(log_dir=temp_dir)
|
||||
|
||||
assert config.log_dir == Path(temp_dir)
|
||||
assert config.log_level == logging.INFO
|
||||
assert config.enable_console is True
|
||||
assert config.enable_json_format is True
|
||||
|
||||
# Check that log files would be created
|
||||
# No logs yet, files created on first log
|
||||
assert config.log_dir.exists()
|
||||
|
||||
def test_log_directory_creation(self):
|
||||
"""Test that log directory is created."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_dir = Path(temp_dir) / "logs" / "subdir"
|
||||
config = LoggingConfig(log_dir=log_dir)
|
||||
|
||||
assert log_dir.exists()
|
||||
assert config.log_dir == log_dir
|
||||
|
||||
def test_logger_setup(self):
|
||||
"""Test that loggers are properly configured."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
LoggingConfig(log_dir=temp_dir)
|
||||
|
||||
# Test main logger
|
||||
logger = logging.getLogger()
|
||||
assert logger.level == logging.INFO
|
||||
|
||||
# Test specific loggers
|
||||
download_logger = logging.getLogger("download")
|
||||
assert download_logger.level == logging.INFO
|
||||
assert download_logger.propagate is False
|
||||
|
||||
security_logger = logging.getLogger("security")
|
||||
assert security_logger.level == logging.INFO
|
||||
assert security_logger.propagate is False
|
||||
|
||||
performance_logger = logging.getLogger("performance")
|
||||
assert performance_logger.level == logging.INFO
|
||||
assert performance_logger.propagate is False
|
||||
|
||||
def test_file_logging(self):
|
||||
"""Test that log files are created and written to."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
LoggingConfig(log_dir=temp_dir, enable_console=False)
|
||||
|
||||
# Write some logs
|
||||
logger = logging.getLogger("test")
|
||||
logger.info("Test info message")
|
||||
logger.error("Test error message")
|
||||
|
||||
# Force handler flush
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
|
||||
# Check that log files exist and contain content
|
||||
app_log = Path(temp_dir) / "app.log"
|
||||
error_log = Path(temp_dir) / "error.log"
|
||||
|
||||
# Files should exist after logging
|
||||
assert app_log.exists()
|
||||
assert error_log.exists()
|
||||
|
||||
|
||||
class TestRequestLoggingMiddleware:
|
||||
"""Test the request logging middleware."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request(self):
|
||||
"""Create a mock request."""
|
||||
request = MagicMock()
|
||||
request.method = "GET"
|
||||
request.url = "http://test.com/api/test"
|
||||
request.headers = {
|
||||
"user-agent": "test-agent",
|
||||
"content-length": "100"
|
||||
}
|
||||
request.client.host = "127.0.0.1"
|
||||
return request
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response(self):
|
||||
"""Create a mock response."""
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.headers = {"content-length": "200"}
|
||||
return response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_request_logging(
|
||||
self, mock_request, mock_response):
|
||||
"""Test logging of successful requests."""
|
||||
app = MagicMock()
|
||||
middleware = RequestLoggingMiddleware(app)
|
||||
|
||||
async def mock_call_next(request):
|
||||
return mock_response
|
||||
|
||||
with patch.object(middleware.logger, 'info') as mock_log_info:
|
||||
with patch.object(
|
||||
middleware.performance_logger, 'info') as mock_perf_log:
|
||||
response = await middleware.dispatch(
|
||||
mock_request, mock_call_next)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_log_info.call_count == 2 # Start and completion
|
||||
assert mock_perf_log.call_count == 1
|
||||
|
||||
# Check log messages
|
||||
start_call = mock_log_info.call_args_list[0]
|
||||
assert "Request started" in start_call[0][0]
|
||||
|
||||
completion_call = mock_log_info.call_args_list[1]
|
||||
assert "Request completed" in completion_call[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_request_logging(self, mock_request):
|
||||
"""Test logging of failed requests."""
|
||||
app = MagicMock()
|
||||
middleware = RequestLoggingMiddleware(app)
|
||||
|
||||
async def mock_call_next(request):
|
||||
raise ValueError("Test error")
|
||||
|
||||
with patch.object(middleware.logger, 'info') as mock_log_info:
|
||||
with patch.object(middleware.logger, 'error') as mock_log_error:
|
||||
with pytest.raises(ValueError):
|
||||
await middleware.dispatch(mock_request, mock_call_next)
|
||||
|
||||
assert mock_log_info.call_count == 1 # Only start
|
||||
assert mock_log_error.call_count == 1 # Error
|
||||
|
||||
error_call = mock_log_error.call_args_list[0]
|
||||
assert "Request failed" in error_call[0][0]
|
||||
|
||||
def test_get_client_ip_forwarded_for(self):
|
||||
"""Test client IP extraction with X-Forwarded-For header."""
|
||||
app = MagicMock()
|
||||
middleware = RequestLoggingMiddleware(app)
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1"}
|
||||
|
||||
ip = middleware._get_client_ip(request)
|
||||
assert ip == "192.168.1.1"
|
||||
|
||||
def test_get_client_ip_real_ip(self):
|
||||
"""Test client IP extraction with X-Real-IP header."""
|
||||
app = MagicMock()
|
||||
middleware = RequestLoggingMiddleware(app)
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {"x-real-ip": "192.168.1.2"}
|
||||
|
||||
ip = middleware._get_client_ip(request)
|
||||
assert ip == "192.168.1.2"
|
||||
|
||||
def test_get_client_ip_direct(self):
|
||||
"""Test client IP extraction from direct connection."""
|
||||
app = MagicMock()
|
||||
middleware = RequestLoggingMiddleware(app)
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
request.client.host = "192.168.1.3"
|
||||
|
||||
ip = middleware._get_client_ip(request)
|
||||
assert ip == "192.168.1.3"
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test utility functions."""
|
||||
|
||||
def test_setup_logging(self):
|
||||
"""Test setup_logging function."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
config = setup_logging(log_dir=temp_dir, log_level="DEBUG")
|
||||
|
||||
assert isinstance(config, LoggingConfig)
|
||||
assert config.log_dir == Path(temp_dir)
|
||||
assert config.log_level == logging.DEBUG
|
||||
|
||||
def test_get_logger(self):
|
||||
"""Test get_logger function."""
|
||||
logger = get_logger("test.module")
|
||||
|
||||
assert isinstance(logger, logging.Logger)
|
||||
assert logger.name == "test.module"
|
||||
|
||||
def test_log_download_progress(self):
|
||||
"""Test download progress logging."""
|
||||
with patch('logging.getLogger') as mock_get_logger:
|
||||
mock_logger = MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
|
||||
log_download_progress(
|
||||
episode_id="ep-123",
|
||||
progress=0.75,
|
||||
status="downloading",
|
||||
speed=1024.5,
|
||||
eta="5 minutes"
|
||||
)
|
||||
|
||||
mock_get_logger.assert_called_with("download")
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
call_args = mock_logger.info.call_args
|
||||
assert "Download progress" in call_args[0][0]
|
||||
assert call_args[1]["extra"]["episode_id"] == "ep-123"
|
||||
assert call_args[1]["extra"]["progress"] == 0.75
|
||||
|
||||
def test_log_security_event(self):
|
||||
"""Test security event logging."""
|
||||
with patch('logging.getLogger') as mock_get_logger:
|
||||
mock_logger = MagicMock()
|
||||
mock_get_logger.return_value = mock_logger
|
||||
|
||||
log_security_event(
|
||||
event_type="login_attempt",
|
||||
details={"user_ip": "192.168.1.1", "success": True},
|
||||
severity="INFO"
|
||||
)
|
||||
|
||||
mock_get_logger.assert_called_with("security")
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
call_args = mock_logger.info.call_args
|
||||
assert "Security event: login_attempt" in call_args[0][0]
|
||||
|
||||
def test_cleanup_old_logs(self):
|
||||
"""Test log cleanup function."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_dir = Path(temp_dir)
|
||||
|
||||
# Create some test log files
|
||||
old_log = log_dir / "old.log"
|
||||
new_log = log_dir / "new.log"
|
||||
|
||||
old_log.touch()
|
||||
new_log.touch()
|
||||
|
||||
# Test that function runs without error
|
||||
# (Real test would require complex mocking of file system)
|
||||
try:
|
||||
cleanup_old_logs(log_dir, days_to_keep=30)
|
||||
# If no exception is raised, the function works
|
||||
success = True
|
||||
except Exception:
|
||||
success = False
|
||||
|
||||
assert success
|
||||
|
||||
# Both files should still exist since they're new
|
||||
assert old_log.exists()
|
||||
assert new_log.exists()
|
||||
|
||||
def test_init_logging(self):
|
||||
"""Test init_logging function."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
init_logging(log_dir=temp_dir, log_level="DEBUG")
|
||||
|
||||
# Should set up global logging
|
||||
logger = logging.getLogger()
|
||||
assert logger.level == logging.DEBUG
|
||||
@ -1,40 +0,0 @@
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
|
||||
# Shim for environments where httpx.Client.__init__ doesn't accept an
|
||||
# 'app' kwarg (some httpx versions have a different signature). The
|
||||
# TestClient in Starlette passes `app=` through; to keep tests portable
|
||||
# we pop it before calling the real initializer.
|
||||
_orig_httpx_init = httpx.Client.__init__
|
||||
|
||||
|
||||
def _httpx_init_shim(self, *args, **kwargs):
|
||||
kwargs.pop("app", None)
|
||||
return _orig_httpx_init(self, *args, **kwargs)
|
||||
|
||||
|
||||
httpx.Client.__init__ = _httpx_init_shim
|
||||
|
||||
|
||||
def test_rate_limit_login_endpoint():
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Hit the login endpoint more times than allowed in the rate window
|
||||
for i in range(6):
|
||||
resp = client.post("/api/auth/login", json={"password": "bad"})
|
||||
# Before hitting the limit we may receive 400/401; the 6th should be 429
|
||||
if i < 5:
|
||||
assert resp.status_code in (400, 401, 429)
|
||||
else:
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
def test_protected_endpoint_invalid_token():
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Call a public endpoint with an invalid token; middleware should ignore it
|
||||
headers = {"Authorization": "Bearer invalid.token.here"}
|
||||
resp = client.get("/health", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
Loading…
x
Reference in New Issue
Block a user