Compare commits

...

70 Commits

Author SHA1 Message Date
489c37357e backup 2026-01-09 18:39:13 +01:00
4f2d652a69 Change logging level from DEBUG to INFO
- Update fastapi_app.py to use INFO level instead of DEBUG
- Update development.py config to default to INFO instead of DEBUG
- Update uvicorn log_level from debug to info
- Prevents debug messages from appearing in logs
2026-01-07 19:41:39 +01:00
bd655cb0f0 Fix event initialization issues
- Remove None assignment for download_progress event in AniworldLoader
- Remove None assignments for download_status and scan_status events in SeriesApp
- Events library requires events to not be initialized to None
- Verified logging configuration is properly set to INFO level
2026-01-07 19:39:42 +01:00
60070395e9 Update instructions.md - mark tasks as complete 2026-01-07 19:18:13 +01:00
f39a08d985 Fix event handler TypeError and increase log level to INFO 2026-01-07 19:18:01 +01:00
055bbf4de6 Fix event subscription bug in SerieScanner and mark checklist complete 2026-01-07 19:01:42 +01:00
ab7d78261e Replace asyncio.to_thread with ThreadPoolExecutor.run_in_executor
- Add ThreadPoolExecutor with 3 max workers to SeriesApp
- Replace all asyncio.to_thread calls with loop.run_in_executor
- Add shutdown() method to properly cleanup executor
- Integrate SeriesApp.shutdown() into FastAPI shutdown sequence
- Ensures proper resource cleanup on Ctrl+C (SIGINT/SIGTERM)
2026-01-03 21:04:52 +01:00
b1726968e5 Refactor: Replace CallbackManager with Events pattern
- Replace callback system with events library in SerieScanner
- Update SeriesApp to subscribe to loader and scanner events
- Refactor ScanService to use Events instead of CallbackManager
- Remove CallbackManager imports and callback classes
- Add safe event calling with error handling in SerieScanner
- Update AniworldLoader to use Events for download progress
- Remove progress_callback parameter from download methods
- Update all affected tests for Events pattern
- Fix test_series_app.py for new event subscription model
- Comment out obsolete callback tests in test_scan_service.py

All core tests passing. Events provide cleaner event-driven architecture.
2025-12-30 21:04:45 +01:00
ff9dea0488 removed cancel request 2025-12-30 20:36:02 +01:00
803f35ef39 Update config.json and workspace context as of 27. Dezember 2025 2025-12-27 21:10:53 +01:00
4780f68a23 Fix: Use yt_dlp.utils.DownloadCancelled for proper download cancellation
- Import and use DownloadCancelled exception which YT-DLP properly handles
- Add InterruptedError handling throughout the call chain
- Fire 'cancelled' status event when download is cancelled
- Handle InterruptedError in DownloadService to set CANCELLED status
2025-12-27 19:38:12 +01:00
08f816a954 Fix: Add graceful download cancellation on Ctrl+C
- Add cancellation flag to AniworldLoader with request_cancel/reset_cancel/is_cancelled methods
- Update base_provider.Loader interface with cancellation abstract methods
- Integrate cancellation check in YT-DLP progress hooks
- Add request_download_cancel method to SeriesApp and AnimeService
- Update DownloadService.stop() to request cancellation before shutdown
- Clean up temp files on cancellation
2025-12-27 19:31:57 +01:00
778d16b21a Fix: Use structlog consistently in sync_series_from_data_files 2025-12-27 19:23:54 +01:00
a67a16d6bf Fix: Add missing asyncio import in fastapi_app.py 2025-12-27 19:22:08 +01:00
2e5731b5d6 refactor: split CSS and JS into modular files (SRP) 2025-12-26 13:55:02 +01:00
94cf36bff3 style: Apply formatter fixes to documentation 2025-12-26 12:54:35 +01:00
dfdac68ecc docs: Update API, CHANGELOG, and ARCHITECTURE for Enhanced Anime Add Flow 2025-12-26 12:53:33 +01:00
3d3b97bdc2 docs: Mark Enhanced Anime Add Flow task as completed 2025-12-26 12:51:26 +01:00
1b7ca7b4da feat: Enhanced anime add flow with sanitized folders and targeted scan
- Add sanitize_folder_name utility for filesystem-safe folder names
- Add sanitized_folder property to Serie entity
- Update SerieList.add() to use sanitized display names for folders
- Add scan_single_series() method for targeted episode scanning
- Enhance add_series endpoint: DB save -> folder create -> targeted scan
- Update response to include missing_episodes and total_missing
- Add comprehensive unit tests for new functionality
- Update API tests with proper mock support
2025-12-26 12:49:23 +01:00
f28dc756c5 backup 2025-12-25 18:59:47 +01:00
d70d70e193 feat: implement graceful shutdown with SIGINT/SIGTERM support
- Add WebSocket shutdown() with client notification and graceful close
- Enhance download service stop() with pending state persistence
- Expand FastAPI lifespan shutdown with proper cleanup sequence
- Add SQLite WAL checkpoint before database close
- Update stop_server.sh to use SIGTERM with timeout fallback
- Configure uvicorn timeout_graceful_shutdown=30s
- Update ARCHITECTURE.md with shutdown documentation
2025-12-25 18:59:07 +01:00
1ba67357dc Add database transaction support with atomic operations
- Create transaction.py with @transactional decorator, atomic() context manager
- Add TransactionPropagation modes: REQUIRED, REQUIRES_NEW, NESTED
- Add savepoint support for nested transactions with partial rollback
- Update connection.py with TransactionManager, get_transactional_session
- Update service.py with bulk operations (bulk_mark_downloaded, bulk_delete)
- Wrap QueueRepository.save_item() and clear_all() in atomic transactions
- Add comprehensive tests (66 transaction tests, 90% coverage)
- All 1090 tests passing
2025-12-25 18:05:33 +01:00
b2728a7cf4 style: simplify status-indicator by removing background and border 2025-12-25 13:24:31 +01:00
f7ee9a40da Update documentation for scan status fix 2025-12-25 13:20:58 +01:00
9f4ea84b47 Improve scan status indicator reliability on page reload
- Add debug logging to checkActiveScanStatus() for better tracing
- Update status indicator before showing overlay for faster feedback
- Add warning logs when DOM elements are not found
- Ensure idle state is explicitly set when no scan is running
- Add debug logging to AnimeService.get_scan_status()
2025-12-25 13:19:10 +01:00
9e393adb00 fix: rescan-status indicator now updates and is clickable after page reload
- Added defensive check for rescan-status element before adding event listener
- Added e.stopPropagation() to prevent click event bubbling issues
- Added console logging for debugging click events
- Call checkActiveScanStatus() directly in init() method, not just on socket connect
  This ensures scan status is checked immediately on page load even if WebSocket
  connection is delayed
2025-12-24 21:35:57 +01:00
458ca1d776 Improve scan overlay UX
- Show overlay immediately when rescan is clicked (before API response)
- Add click-outside-to-close on overlay background
- Add click on rescan-status indicator to reopen overlay
- Add cursor pointer to rescan-status for clickability feedback
- All 1024 tests passing
2025-12-24 21:27:32 +01:00
b6d44ca7d8 Prevent concurrent rescans with async lock
- Add _scan_lock asyncio.Lock to AnimeService
- Check if lock is held before starting rescan
- Use async with to ensure lock is released on completion or exception
- All 1024 tests passing
2025-12-24 21:10:19 +01:00
19cb8c11a0 Show scan overlay after page reload
- Add is_scanning state tracking in AnimeService
- Add get_scan_status method to AnimeService
- Add /api/anime/scan/status endpoint to check scan state
- Add checkActiveScanStatus in JS to restore overlay on reconnect
- All 1024 tests passing
2025-12-24 21:06:22 +01:00
72ac201153 Show total items to scan in progress overlay
- Add total_items parameter to broadcast_scan_started and broadcast_scan_progress
- Pass total from SeriesApp to WebSocket broadcasts in AnimeService
- Update JS overlay to show progress bar and current/total count
- Add CSS for progress bar styling
- Add unit tests for new total_items parameter
- All 1024 tests passing
2025-12-24 20:54:27 +01:00
a24f07a36e Add MP4 scan progress visibility in UI
- Add broadcast_scan_started, broadcast_scan_progress, broadcast_scan_completed to WebSocketService
- Inject WebSocketService into AnimeService for real-time scan progress broadcasts
- Add CSS styles for scan progress overlay with spinner, stats, and completion state
- Update app.js to handle scan events and display progress overlay
- Add unit tests for new WebSocket broadcast methods
- All 1022 tests passing
2025-12-23 18:24:32 +01:00
9b071fe370 backup 2025-12-23 18:13:10 +01:00
32dc893434 cleanup 2025-12-16 19:22:16 +01:00
700f491ef9 fix: progress broadcasts now use correct WebSocket room names
- Fixed room name mismatch: ProgressService was broadcasting to
  'download_progress' but JS clients join 'downloads' room
- Added _get_room_for_progress_type() mapping function
- Updated all progress methods to use correct room names
- Added 13 new tests for room name mapping and broadcast verification
- Updated existing tests to expect correct room names
- Fixed JS clients to join valid rooms (downloads, queue, scan)
2025-12-16 19:21:30 +01:00
4c9bf6b982 Fix: Remove episodes from missing list on download/rescan
- Update _update_series_in_db to sync missing episodes bidirectionally
- Add delete_by_series_and_episode method to EpisodeService
- Remove downloaded episodes from DB after successful download
- Clear anime service cache when episodes are removed
- Fix tests to use 'message' instead of 'detail' in API responses
- Mock DB operations in rescan tests
2025-12-15 16:17:34 +01:00
bf332f27e0 pylint fixes 2025-12-15 15:22:01 +01:00
596476f9ac refactor: remove database access from core layer
- Remove db_session parameter from SeriesApp, SerieList, SerieScanner
- Move all database operations to AnimeService (service layer)
- Add add_series_to_db, contains_in_db methods to AnimeService
- Update sync_series_from_data_files to use inline DB operations
- Remove obsolete test classes for removed DB methods
- Fix pylint issues: add broad-except comments, fix line lengths
- Core layer (src/core/) now has zero database imports

722 unit tests pass
2025-12-15 15:19:03 +01:00
27108aacda Fix architecture issues from todolist
- Add documentation warnings for in-memory rate limiting and failed login attempts
- Consolidate duplicate health endpoints into api/health.py
- Fix CLI to use correct async rescan method names
- Update download.py and anime.py to use custom exception classes
- Add WebSocket room validation and rate limiting
2025-12-15 14:23:41 +01:00
54790a7ebb docu 2025-12-15 14:07:04 +01:00
1652f2f6af feat: rescan now saves to database instead of data files
- Update SeriesApp.rescan() to use database storage by default (use_database=True)
- Use SerieScanner.scan_async() for database mode, which saves directly to DB
- Fall back to legacy file-based scan() when use_database=False (for CLI compatibility)
- Reinitialize SerieList from database after scan when in database mode
- Update unit tests to use use_database=False for mocked tests
- Add parameter to control storage mode for backward compatibility
2025-12-13 20:37:03 +01:00
3cb644add4 fix: resolve pylint and type-checking issues
- Fix return type annotation in SetupRedirectMiddleware.dispatch() to use Response instead of RedirectResponse
- Replace broad 'except Exception' with specific exception types (FileNotFoundError, ValueError, OSError, etc.)
- Rename AppConfig.validate() to validate_config() to avoid shadowing BaseModel.validate()
- Fix ValidationResult.errors field to use List[str] with default_factory
- Add pylint disable comments for intentional broad exception catches during shutdown
- Rename lifespan parameter to _application to indicate unused variable
- Update all callers to use new validate_config() method name
2025-12-13 20:29:07 +01:00
63742bb369 fix: handle empty series name in data file sync
- Use folder name as fallback when series name is empty
- Skip series with both empty name and folder
- Add try/catch for individual series to prevent one failure
  from stopping the entire sync
2025-12-13 10:12:53 +01:00
8373da8547 style: fix import ordering in auth.py and config.py 2025-12-13 10:02:15 +01:00
38e0ba0484 feat: sync series from data files after setup/directory update
- Call sync_series_from_data_files after initial setup completes
- Call sync_series_from_data_files when anime directory is updated
- Return synced_series count in directory update response
2025-12-13 10:00:40 +01:00
5f6ac8e507 refactor: move sync_series_from_data_files to anime_service
- Moved _sync_series_to_database from fastapi_app.py to anime_service.py
- Renamed to sync_series_from_data_files for better clarity
- Updated all imports and test references
- Removed completed TODO tasks from instructions.md
2025-12-13 09:58:32 +01:00
684337fd0c Add data file to database sync functionality
- Add get_all_series_from_data_files() to SeriesApp
- Sync series from data files to DB on startup
- Add unit tests for new SeriesApp method
- Add integration tests for sync functionality
- Update documentation
2025-12-13 09:32:57 +01:00
86eaa8a680 cleanup 2025-12-13 09:09:48 +01:00
ee317b29f1 Remove migration code and alembic dependency 2025-12-13 09:02:26 +01:00
842f9c88eb migration removed 2025-12-10 21:12:34 +01:00
99f79e4c29 fix queue error 2025-12-10 20:55:09 +01:00
798461a1ea better db model 2025-12-04 19:22:42 +01:00
942f14f746 Fix incorrect import path for settings module 2025-12-02 17:54:06 +01:00
7c56c8bef2 Fix download service init when anime dir not configured 2025-12-02 17:36:41 +01:00
3b516c0e24 Complete download queue SQLite migration: documentation and cleanup
- Updated infrastructure.md with queue database schema and storage details
- Updated instructions.md to mark migration task as completed
- No deprecated JSON code remains in codebase
2025-12-02 16:08:37 +01:00
b0f3b643c7 Migrate download queue from JSON to SQLite database
- Created QueueRepository adapter in src/server/services/queue_repository.py
- Refactored DownloadService to use repository pattern instead of JSON
- Updated application startup to initialize download service from database
- Updated all test fixtures to use MockQueueRepository
- All 1104 tests passing
2025-12-02 16:01:25 +01:00
48daeba012 added instruction for queue db data 2025-12-02 14:15:19 +01:00
4347057c06 soem fixes 2025-12-02 14:04:37 +01:00
e0a7c6baa9 some fixes 2025-12-02 13:24:22 +01:00
ae77a11782 chore: Complete Task 10 - Final Validation
Task 10: Final Validation - All checks passed
- All 817 unit tests pass
- All 140 integration tests pass
- All 55 API tests pass
- Total: 1012 tests passing

All 10 migration tasks completed:
1.  Create Data File Migration Service
2.  Create Startup Migration Script
3.  Integrate Migration into FastAPI Lifespan
4.  Update SerieList to Use Database
5.  Update SerieScanner to Use Database
6.  Update Anime API Endpoints
7.  Update Dependencies and SeriesApp
8.  Write Integration Tests
9.  Clean Up Legacy Code
10.  Final Validation
2025-12-01 19:58:12 +01:00
396b243d59 chore: Add deprecation warnings and update documentation (Task 9)
Task 9: Clean up legacy code
- Added deprecation warnings to Serie.save_to_file() and load_from_file()
- Updated infrastructure.md with Data Storage section documenting:
  - SQLite database as primary storage
  - Legacy file storage as deprecated
  - Data migration process
- Added deprecation warning tests for Serie class
- Updated existing tests to handle new warnings
- All 1012 tests pass (872 unit + 55 API + 85 integration)
2025-12-01 19:55:15 +01:00
73283dea64 test(integration): Add comprehensive migration integration tests (Task 8)
Task 8: Write integration tests for data file migration
- Added test_migration_on_fresh_start_no_data_files test
- Added test_add_series_saves_to_database test
- Added test_scan_async_saves_to_database test
- Added test_load_series_from_db test
- Added test_search_and_add_workflow test
- All 11 migration integration tests pass
- All 870 tests pass (815 unit + 55 API)
2025-12-01 19:47:19 +01:00
cb014cf547 feat(core): Add database support to SeriesApp (Task 7)
- Added db_session parameter to SeriesApp.__init__()
- Added db_session property and set_db_session() method
- Added init_from_db_async() for async database initialization
- Pass db_session to SerieList and SerieScanner during construction
- Added get_series_app_with_db() dependency for FastAPI endpoints
- All 815 unit tests and 55 API tests pass
2025-12-01 19:42:04 +01:00
246782292f feat(api): Update anime API endpoints to use database storage
Task 6: Update Anime API endpoints to use database
- Modified add_series endpoint to save series to database when available
- Added get_optional_database_session dependency for graceful fallback
- Falls back to file-based storage when database unavailable
- All 55 API tests and 809 unit tests pass
2025-12-01 19:34:41 +01:00
46ca4c9aac Task 5: Update SerieScanner to use database storage
- Add db_session parameter to SerieScanner.__init__
- Add async scan_async() method for database-backed scanning
- Add _save_serie_to_db() helper for creating/updating series
- Add _update_serie_in_db() helper for updating existing series
- Add deprecation warning to file-based scan() method
- Maintain backward compatibility for CLI usage
- Add comprehensive unit tests (15 tests, all passing)
- Update instructions.md to mark Task 5 complete
2025-12-01 19:25:28 +01:00
795f83ada5 Task 4: Update SerieList to use database storage
- Add db_session and skip_load parameters to SerieList.__init__
- Add async load_series_from_db() method for database loading
- Add async add_to_db() method for database storage
- Add async contains_in_db() method for database checks
- Add _convert_from_db() and _convert_to_db_dict() helper methods
- Add deprecation warnings to file-based add() method
- Maintain backward compatibility for file-based operations
- Add comprehensive unit tests (29 tests, all passing)
- Update instructions.md to mark Task 4 complete
2025-12-01 19:18:50 +01:00
646385b975 task1 2025-12-01 19:10:02 +01:00
148e6c1b58 Integrate data migration into FastAPI lifespan (Task 3) 2025-12-01 18:16:54 +01:00
de58161014 Add startup migration runner (Task 2) 2025-12-01 18:13:16 +01:00
7e2d3dd5ab Add DataMigrationService for file-to-database migration (Task 1) 2025-12-01 18:09:38 +01:00
0222262f8f new tasks 2025-12-01 18:04:49 +01:00
161 changed files with 20606 additions and 7422 deletions

BIN
.coverage

Binary file not shown.

140
README.md Normal file
View File

@ -0,0 +1,140 @@
# Aniworld Download Manager
A web-based anime download manager with REST API, WebSocket real-time updates, and a modern web interface.
## Features
- Web interface for managing anime library
- REST API for programmatic access
- WebSocket real-time progress updates
- Download queue with priority management
- Automatic library scanning for missing episodes
- JWT-based authentication
- SQLite database for persistence
## Quick Start
### Prerequisites
- Python 3.10+
- Conda (recommended) or virtualenv
### Installation
1. Clone the repository:
```bash
git clone https://github.com/your-repo/aniworld.git
cd aniworld
```
2. Create and activate conda environment:
```bash
conda create -n AniWorld python=3.10
conda activate AniWorld
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Start the server:
```bash
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
```
5. Open http://127.0.0.1:8000 in your browser
### First-Time Setup
1. Navigate to http://127.0.0.1:8000/setup
2. Set a master password (minimum 8 characters, mixed case, number, special character)
3. Configure your anime directory path
4. Login with your master password
## Documentation
| Document | Description |
| ---------------------------------------------- | -------------------------------- |
| [docs/API.md](docs/API.md) | REST API and WebSocket reference |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture and design |
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration options |
| [docs/DATABASE.md](docs/DATABASE.md) | Database schema |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Developer setup guide |
| [docs/TESTING.md](docs/TESTING.md) | Testing guidelines |
## Project Structure
```
src/
+-- cli/ # CLI interface (legacy)
+-- config/ # Application settings
+-- core/ # Domain logic
| +-- SeriesApp.py # Main application facade
| +-- SerieScanner.py # Directory scanning
| +-- entities/ # Domain entities
| +-- providers/ # External provider adapters
+-- server/ # FastAPI web server
+-- api/ # REST API endpoints
+-- services/ # Business logic
+-- models/ # Pydantic models
+-- database/ # SQLAlchemy ORM
+-- middleware/ # Auth, rate limiting
```
## API Endpoints
| Endpoint | Description |
| ------------------------------ | -------------------------------- |
| `POST /api/auth/login` | Authenticate and get JWT token |
| `GET /api/anime` | List anime with missing episodes |
| `GET /api/anime/search?query=` | Search for anime |
| `POST /api/queue/add` | Add episodes to download queue |
| `POST /api/queue/start` | Start queue processing |
| `GET /api/queue/status` | Get queue status |
| `WS /ws/connect` | WebSocket for real-time updates |
See [docs/API.md](docs/API.md) for complete API reference.
## Configuration
Environment variables (via `.env` file):
| Variable | Default | Description |
| ----------------- | ------------------------------ | ---------------------- |
| `JWT_SECRET_KEY` | (random) | Secret for JWT signing |
| `DATABASE_URL` | `sqlite:///./data/aniworld.db` | Database connection |
| `ANIME_DIRECTORY` | (empty) | Path to anime library |
| `LOG_LEVEL` | `INFO` | Logging level |
See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for all options.
## Running Tests
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v
# Run unit tests only
conda run -n AniWorld python -m pytest tests/unit/ -v
# Run integration tests
conda run -n AniWorld python -m pytest tests/integration/ -v
```
## Technology Stack
- **Web Framework**: FastAPI 0.104.1
- **Database**: SQLite + SQLAlchemy 2.0
- **Auth**: JWT (python-jose) + passlib
- **Validation**: Pydantic 2.5
- **Logging**: structlog
- **Testing**: pytest + pytest-asyncio
## License
MIT License

View File

@ -1,215 +0,0 @@
# Server Management Commands
Quick reference for starting, stopping, and managing the Aniworld server.
## Start Server
### Using the start script (Recommended)
```bash
./start_server.sh
```
### Using conda directly
```bash
conda run -n AniWorld python run_server.py
```
### Using uvicorn directly
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
## Stop Server
### Using the stop script (Recommended)
```bash
./stop_server.sh
```
### Manual commands
**Kill uvicorn processes:**
```bash
pkill -f "uvicorn.*fastapi_app:app"
```
**Kill process on port 8000:**
```bash
lsof -ti:8000 | xargs kill -9
```
**Kill run_server.py processes:**
```bash
pkill -f "run_server.py"
```
## Check Server Status
**Check if port 8000 is in use:**
```bash
lsof -i:8000
```
**Check for running uvicorn processes:**
```bash
ps aux | grep uvicorn
```
**Check server is responding:**
```bash
curl http://127.0.0.1:8000/api/health
```
## Restart Server
```bash
./stop_server.sh && ./start_server.sh
```
## Common Issues
### "Address already in use" Error
**Problem:** Port 8000 is already occupied
**Solution:**
```bash
./stop_server.sh
# or
lsof -ti:8000 | xargs kill -9
```
### Server not responding
**Check logs:**
```bash
tail -f logs/app.log
```
**Check if process is running:**
```bash
ps aux | grep uvicorn
```
### Cannot connect to server
**Verify server is running:**
```bash
curl http://127.0.0.1:8000/api/health
```
**Check firewall:**
```bash
sudo ufw status
```
## Development Mode
**Run with auto-reload:**
```bash
./start_server.sh # Already includes --reload
```
**Run with custom port:**
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8080 --reload
```
**Run with debug logging:**
```bash
export LOG_LEVEL=DEBUG
./start_server.sh
```
## Production Mode
**Run without auto-reload:**
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 0.0.0.0 --port 8000 --workers 4
```
**Run with systemd (Linux):**
```bash
sudo systemctl start aniworld
sudo systemctl stop aniworld
sudo systemctl restart aniworld
sudo systemctl status aniworld
```
## URLs
- **Web Interface:** http://127.0.0.1:8000
- **API Documentation:** http://127.0.0.1:8000/api/docs
- **Login Page:** http://127.0.0.1:8000/login
- **Queue Management:** http://127.0.0.1:8000/queue
- **Health Check:** http://127.0.0.1:8000/api/health
## Default Credentials
- **Password:** `Hallo123!`
## Log Files
- **Application logs:** `logs/app.log`
- **Download logs:** `logs/downloads/`
- **Error logs:** Check console output or systemd journal
## Quick Troubleshooting
| Symptom | Solution |
| ------------------------ | ------------------------------------ |
| Port already in use | `./stop_server.sh` |
| Server won't start | Check `logs/app.log` |
| 404 errors | Verify URL and check routing |
| WebSocket not connecting | Check server is running and firewall |
| Slow responses | Check system resources (`htop`) |
| Database errors | Check `data/` directory permissions |
## Environment Variables
```bash
# Set log level
export LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
# Set server port
export PORT=8000
# Set host
export HOST=127.0.0.1
# Set workers (production)
export WORKERS=4
```
## Related Scripts
- `start_server.sh` - Start the server
- `stop_server.sh` - Stop the server
- `run_server.py` - Python server runner
- `scripts/setup.py` - Initial setup
## More Information
- [User Guide](docs/user_guide.md)
- [API Reference](docs/api_reference.md)
- [Deployment Guide](docs/deployment.md)

Binary file not shown.

BIN
data/aniworld.db-shm Normal file

Binary file not shown.

BIN
data/aniworld.db-wal Normal file

Binary file not shown.

View File

@ -17,7 +17,7 @@
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$854zxnhvzXmPsVbqvXduTQ$G0HVRAt3kyO5eFwvo.ILkpX9JdmyXYJ9MNPTS/UxAGk",
"master_password_hash": "$pbkdf2-sha256$29000$o/R.b.0dYwzhfG/t/R9DSA$kQAcjHoByVaftRAT1OaZg5rILdhMSDNS6uIz67jwdOo",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"

View File

@ -1,24 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$VCqllLL2vldKyTmHkJIyZg$jNllpzlpENdgCslmS.tG.PGxRZ9pUnrqFEQFveDEcYk",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@ -1,24 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$3/t/7733PkdoTckZQyildA$Nz9SdX2ZgqBwyzhQ9FGNcnzG1X.TW9oce3sDxJbVSdY",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@ -1,327 +0,0 @@
{
"pending": [
{
"id": "ae6424dc-558b-4946-9f07-20db1a09bf33",
"serie_id": "test-series-2",
"serie_folder": "Another Series (2024)",
"serie_name": "Another Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.593236Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "011c2038-9fe3-41cb-844f-ce50c40e415f",
"serie_id": "series-high",
"serie_folder": "Series High (2024)",
"serie_name": "Series High",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.632289Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "0eee56e0-414d-4cd7-8da7-b5a139abd8b5",
"serie_id": "series-normal",
"serie_folder": "Series Normal (2024)",
"serie_name": "Series Normal",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.635082Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "eea9f4f3-98e5-4041-9fc6-92e3d4c6fee6",
"serie_id": "series-low",
"serie_folder": "Series Low (2024)",
"serie_name": "Series Low",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "LOW",
"added_at": "2025-11-28T17:54:38.637038Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "b6f84ea9-86c8-4cc9-90e5-c7c6ce10c593",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.801266Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "412aa28d-9763-41ef-913d-3d63919f9346",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.867939Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "3a036824-2d14-41dd-81b8-094dd322a137",
"serie_id": "invalid-series",
"serie_folder": "Invalid Series (2024)",
"serie_name": "Invalid Series",
"episode": {
"season": 99,
"episode": 99,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.935125Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "1f4108ed-5488-4f46-ad5b-fe27e3b04790",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.968296Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "5e880954-1a9f-450a-8008-5b9d6ac07d66",
"serie_id": "series-2",
"serie_folder": "Series 2 (2024)",
"serie_name": "Series 2",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.055885Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2415ac21-509b-4d71-b5b9-b824116d6785",
"serie_id": "series-0",
"serie_folder": "Series 0 (2024)",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.056795Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "716f9823-d59a-4b04-863b-c75fd54bc464",
"serie_id": "series-1",
"serie_folder": "Series 1 (2024)",
"serie_name": "Series 1",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.057486Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "36ad4323-daa9-49c4-97e8-a0aec0cca7a1",
"serie_id": "series-4",
"serie_folder": "Series 4 (2024)",
"serie_name": "Series 4",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058179Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "695ee7a9-42bb-4953-9a8a-10bd7f533369",
"serie_id": "series-3",
"serie_folder": "Series 3 (2024)",
"serie_name": "Series 3",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058816Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aa948908-c410-42ec-85d6-a0298d7d95a5",
"serie_id": "persistent-series",
"serie_folder": "Persistent Series (2024)",
"serie_name": "Persistent Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.152427Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2537f20e-f394-4c68-81d5-48be3c0c402a",
"serie_id": "ws-series",
"serie_folder": "WebSocket Series (2024)",
"serie_name": "WebSocket Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.219061Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aaaf3b05-cce8-47d5-b350-59c5d72533ad",
"serie_id": "workflow-series",
"serie_folder": "Workflow Test Series (2024)",
"serie_name": "Workflow Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:39.254462Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
}
],
"active": [],
"failed": [],
"timestamp": "2025-11-28T17:54:39.259761+00:00"
}

23
diagrams/README.md Normal file
View File

@ -0,0 +1,23 @@
# Architecture Diagrams
This directory contains architecture diagram source files for the Aniworld documentation.
## Diagrams
### System Architecture (Mermaid)
See [system-architecture.mmd](system-architecture.mmd) for the system overview diagram.
### Rendering
Diagrams can be rendered using:
- Mermaid Live Editor: https://mermaid.live/
- VS Code Mermaid extension
- GitHub/GitLab native Mermaid support
## Formats
- `.mmd` - Mermaid diagram source files
- `.svg` - Exported vector graphics (add when needed)
- `.png` - Exported raster graphics (add when needed)

View File

@ -0,0 +1,44 @@
%%{init: {'theme': 'base'}}%%
sequenceDiagram
participant Client
participant FastAPI
participant AuthMiddleware
participant DownloadService
participant ProgressService
participant WebSocketService
participant SeriesApp
participant Database
Note over Client,Database: Download Flow
%% Add to queue
Client->>FastAPI: POST /api/queue/add
FastAPI->>AuthMiddleware: Validate JWT
AuthMiddleware-->>FastAPI: OK
FastAPI->>DownloadService: add_to_queue()
DownloadService->>Database: save_item()
Database-->>DownloadService: item_id
DownloadService-->>FastAPI: [item_ids]
FastAPI-->>Client: 201 Created
%% Start queue
Client->>FastAPI: POST /api/queue/start
FastAPI->>AuthMiddleware: Validate JWT
AuthMiddleware-->>FastAPI: OK
FastAPI->>DownloadService: start_queue_processing()
loop For each pending item
DownloadService->>SeriesApp: download_episode()
loop Progress updates
SeriesApp->>ProgressService: emit("progress_updated")
ProgressService->>WebSocketService: broadcast_to_room()
WebSocketService-->>Client: WebSocket message
end
SeriesApp-->>DownloadService: completed
DownloadService->>Database: update_status()
end
DownloadService-->>FastAPI: OK
FastAPI-->>Client: 200 OK

View File

@ -0,0 +1,82 @@
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#4a90d9'}}}%%
flowchart TB
subgraph Clients["Client Layer"]
Browser["Web Browser<br/>(HTML/CSS/JS)"]
CLI["CLI Client<br/>(Main.py)"]
end
subgraph Server["Server Layer (FastAPI)"]
direction TB
Middleware["Middleware<br/>Auth, Rate Limit, Error Handler"]
subgraph API["API Routers"]
AuthAPI["/api/auth"]
AnimeAPI["/api/anime"]
QueueAPI["/api/queue"]
ConfigAPI["/api/config"]
SchedulerAPI["/api/scheduler"]
HealthAPI["/health"]
WebSocketAPI["/ws"]
end
subgraph Services["Services"]
AuthService["AuthService"]
AnimeService["AnimeService"]
DownloadService["DownloadService"]
ConfigService["ConfigService"]
ProgressService["ProgressService"]
WebSocketService["WebSocketService"]
end
end
subgraph Core["Core Layer"]
SeriesApp["SeriesApp"]
SerieScanner["SerieScanner"]
SerieList["SerieList"]
end
subgraph Data["Data Layer"]
SQLite[(SQLite<br/>aniworld.db)]
ConfigJSON[(config.json)]
FileSystem[(File System<br/>Anime Directory)]
end
subgraph External["External"]
Provider["Anime Provider<br/>(aniworld.to)"]
end
%% Client connections
Browser -->|HTTP/WebSocket| Middleware
CLI -->|Direct| SeriesApp
%% Middleware to API
Middleware --> API
%% API to Services
AuthAPI --> AuthService
AnimeAPI --> AnimeService
QueueAPI --> DownloadService
ConfigAPI --> ConfigService
SchedulerAPI --> AnimeService
WebSocketAPI --> WebSocketService
%% Services to Core
AnimeService --> SeriesApp
DownloadService --> SeriesApp
%% Services to Data
AuthService --> ConfigJSON
ConfigService --> ConfigJSON
DownloadService --> SQLite
AnimeService --> SQLite
%% Core to Data
SeriesApp --> SerieScanner
SeriesApp --> SerieList
SerieScanner --> FileSystem
SerieScanner --> Provider
%% Event flow
ProgressService -.->|Events| WebSocketService
DownloadService -.->|Progress| ProgressService
WebSocketService -.->|Broadcast| Browser

1194
docs/API.md Normal file

File diff suppressed because it is too large Load Diff

625
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,625 @@
# Architecture Documentation
## Document Purpose
This document describes the system architecture of the Aniworld anime download manager.
---
## 1. System Overview
Aniworld is a web-based anime download manager built with Python, FastAPI, and SQLite. It provides a REST API and WebSocket interface for managing anime libraries, downloading episodes, and tracking progress.
### High-Level Architecture
```
+------------------+ +------------------+ +------------------+
| Web Browser | | CLI Client | | External |
| (Frontend) | | (Main.py) | | Providers |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
| HTTP/WebSocket | Direct | HTTP
| | |
+--------v---------+ +--------v---------+ +--------v---------+
| | | | | |
| FastAPI <-----> Core Layer <-----> Provider |
| Server Layer | | (SeriesApp) | | Adapters |
| | | | | |
+--------+---------+ +--------+---------+ +------------------+
| |
| |
+--------v---------+ +--------v---------+
| | | |
| SQLite DB | | File System |
| (aniworld.db) | | (data/*.json) |
| | | |
+------------------+ +------------------+
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L1-L252)
---
## 2. Architectural Layers
### 2.1 CLI Layer (`src/cli/`)
Legacy command-line interface for direct interaction with the core layer.
| Component | File | Purpose |
| --------- | ----------------------------- | --------------- |
| Main | [Main.py](../src/cli/Main.py) | CLI entry point |
### 2.2 Server Layer (`src/server/`)
FastAPI-based REST API and WebSocket server.
```
src/server/
+-- fastapi_app.py # Application entry point, lifespan management
+-- api/ # API route handlers
| +-- anime.py # /api/anime/* endpoints
| +-- auth.py # /api/auth/* endpoints
| +-- config.py # /api/config/* endpoints
| +-- download.py # /api/queue/* endpoints
| +-- scheduler.py # /api/scheduler/* endpoints
| +-- websocket.py # /ws/* WebSocket handlers
| +-- health.py # /health/* endpoints
+-- controllers/ # Page controllers for HTML rendering
| +-- page_controller.py # UI page routes
| +-- health_controller.py# Health check route
| +-- error_controller.py # Error pages (404, 500)
+-- services/ # Business logic
| +-- anime_service.py # Anime operations
| +-- auth_service.py # Authentication
| +-- config_service.py # Configuration management
| +-- download_service.py # Download queue management
| +-- progress_service.py # Progress tracking
| +-- websocket_service.py# WebSocket broadcasting
| +-- queue_repository.py # Database persistence
+-- models/ # Pydantic models
| +-- auth.py # Auth request/response models
| +-- config.py # Configuration models
| +-- download.py # Download queue models
| +-- websocket.py # WebSocket message models
+-- middleware/ # Request processing
| +-- auth.py # JWT validation, rate limiting
| +-- error_handler.py # Exception handlers
| +-- setup_redirect.py # Setup flow redirect
+-- database/ # SQLAlchemy ORM
| +-- connection.py # Database connection
| +-- models.py # ORM models
| +-- service.py # Database service
+-- utils/ # Utility modules
| +-- filesystem.py # Folder sanitization, path safety
| +-- validators.py # Input validation utilities
| +-- dependencies.py # FastAPI dependency injection
+-- web/ # Static files and templates
+-- static/ # CSS, JS, images
+-- templates/ # Jinja2 templates
```
Source: [src/server/](../src/server/)
### 2.2.1 Frontend Architecture (`src/server/web/static/`)
The frontend uses a modular architecture with no build step required. CSS and JavaScript files are organized by responsibility.
#### CSS Structure
```
src/server/web/static/css/
+-- styles.css # Entry point with @import statements
+-- base/
| +-- variables.css # CSS custom properties (colors, fonts, spacing)
| +-- reset.css # CSS reset and normalize styles
| +-- typography.css # Font styles, headings, text utilities
+-- components/
| +-- buttons.css # All button styles
| +-- cards.css # Card and panel components
| +-- forms.css # Form inputs, labels, validation styles
| +-- modals.css # Modal and overlay styles
| +-- navigation.css # Header, nav, sidebar styles
| +-- progress.css # Progress bars, loading indicators
| +-- notifications.css # Toast, alerts, messages
| +-- tables.css # Table and list styles
| +-- status.css # Status badges and indicators
+-- pages/
| +-- login.css # Login page specific styles
| +-- index.css # Index/library page specific styles
| +-- queue.css # Queue page specific styles
+-- utilities/
+-- animations.css # Keyframes and animation classes
+-- responsive.css # Media queries and breakpoints
+-- helpers.css # Utility classes (hidden, flex, spacing)
```
#### JavaScript Structure
JavaScript uses the IIFE pattern with a shared `AniWorld` namespace for browser compatibility without build tools.
```
src/server/web/static/js/
+-- shared/ # Shared utilities used by all pages
| +-- constants.js # API endpoints, localStorage keys, defaults
| +-- auth.js # Token management (getToken, setToken, checkAuth)
| +-- api-client.js # Fetch wrapper with auto-auth headers
| +-- theme.js # Dark/light theme toggle
| +-- ui-utils.js # Toast notifications, format helpers
| +-- websocket-client.js # Socket.IO wrapper
+-- index/ # Index page modules
| +-- series-manager.js # Series list rendering and filtering
| +-- selection-manager.js# Multi-select and bulk download
| +-- search.js # Series search functionality
| +-- scan-manager.js # Library rescan operations
| +-- scheduler-config.js # Scheduler configuration
| +-- logging-config.js # Logging configuration
| +-- advanced-config.js # Advanced settings
| +-- main-config.js # Main configuration and backup
| +-- config-manager.js # Config modal orchestrator
| +-- socket-handler.js # WebSocket event handlers
| +-- app-init.js # Application initialization
+-- queue/ # Queue page modules
+-- queue-api.js # Queue API interactions
+-- queue-renderer.js # Queue list rendering
+-- progress-handler.js # Download progress updates
+-- queue-socket-handler.js # WebSocket events for queue
+-- queue-init.js # Queue page initialization
```
#### Module Pattern
All JavaScript modules follow the IIFE pattern with namespace:
```javascript
var AniWorld = window.AniWorld || {};
AniWorld.ModuleName = (function () {
"use strict";
// Private variables and functions
// Public API
return {
init: init,
publicMethod: publicMethod,
};
})();
```
Source: [src/server/web/static/](../src/server/web/static/)
### 2.3 Core Layer (`src/core/`)
Domain logic for anime series management.
```
src/core/
+-- SeriesApp.py # Main application facade
+-- SerieScanner.py # Directory scanning, targeted single-series scan
+-- entities/ # Domain entities
| +-- series.py # Serie class with sanitized_folder property
| +-- SerieList.py # SerieList collection with sanitized folder support
+-- providers/ # External provider adapters
| +-- base_provider.py # Loader interface
| +-- provider_factory.py # Provider registry
+-- interfaces/ # Abstract interfaces
| +-- callbacks.py # Progress callback system
+-- exceptions/ # Domain exceptions
+-- Exceptions.py # Custom exceptions
```
**Key Components:**
| Component | Purpose |
| -------------- | -------------------------------------------------------------------------- |
| `SeriesApp` | Main application facade for anime operations |
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
| `Serie` | Domain entity with `sanitized_folder` property for filesystem-safe names |
| `SerieList` | Collection management with automatic folder creation using sanitized names |
Source: [src/core/](../src/core/)
### 2.4 Infrastructure Layer (`src/infrastructure/`)
Cross-cutting concerns.
```
src/infrastructure/
+-- logging/ # Structured logging setup
+-- security/ # Security utilities
```
### 2.5 Configuration Layer (`src/config/`)
Application settings management.
| Component | File | Purpose |
| --------- | ---------------------------------------- | ------------------------------- |
| Settings | [settings.py](../src/config/settings.py) | Environment-based configuration |
Source: [src/config/settings.py](../src/config/settings.py#L1-L96)
---
## 11. Graceful Shutdown
The application implements a comprehensive graceful shutdown mechanism that ensures data integrity and proper cleanup when the server is stopped via Ctrl+C (SIGINT) or SIGTERM.
### 11.1 Shutdown Sequence
```
1. SIGINT/SIGTERM received
+-- Uvicorn catches signal
+-- Stops accepting new requests
2. FastAPI lifespan shutdown triggered
+-- 30 second total timeout
3. WebSocket shutdown (5s timeout)
+-- Broadcast {"type": "server_shutdown"} to all clients
+-- Close each connection with code 1001 (Going Away)
+-- Clear connection tracking data
4. Download service stop (10s timeout)
+-- Set shutdown flag
+-- Persist active download as "pending" in database
+-- Cancel active download task
+-- Shutdown ThreadPoolExecutor with wait
5. Progress service cleanup
+-- Clear event subscribers
+-- Clear active progress tracking
6. Database cleanup (10s timeout)
+-- SQLite: Run PRAGMA wal_checkpoint(TRUNCATE)
+-- Dispose async engine
+-- Dispose sync engine
7. Process exits cleanly
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L142-L210)
### 11.2 Key Components
| Component | File | Shutdown Method |
| ------------------- | ------------------------------------------------------------------- | ------------------------------ |
| WebSocket Service | [websocket_service.py](../src/server/services/websocket_service.py) | `shutdown(timeout=5.0)` |
| Download Service | [download_service.py](../src/server/services/download_service.py) | `stop(timeout=10.0)` |
| Database Connection | [connection.py](../src/server/database/connection.py) | `close_db()` |
| Uvicorn Config | [run_server.py](../run_server.py) | `timeout_graceful_shutdown=30` |
| Stop Script | [stop_server.sh](../stop_server.sh) | SIGTERM with fallback |
### 11.3 Data Integrity Guarantees
1. **Active downloads preserved**: In-progress downloads are saved as "pending" and can resume on restart.
2. **Database WAL flushed**: SQLite WAL checkpoint ensures all writes are in the main database file.
3. **WebSocket clients notified**: Clients receive shutdown message before connection closes.
4. **Thread pool cleanup**: Background threads complete or are gracefully cancelled.
### 11.4 Manual Stop
```bash
# Graceful stop via script (sends SIGTERM, waits up to 30s)
./stop_server.sh
# Or press Ctrl+C in terminal running the server
```
Source: [stop_server.sh](../stop_server.sh#L1-L80)
---
## 3. Component Interactions
### 3.1 Request Flow (REST API)
```
1. Client sends HTTP request
2. AuthMiddleware validates JWT token (if required)
3. Rate limiter checks request frequency
4. FastAPI router dispatches to endpoint handler
5. Endpoint calls service layer
6. Service layer uses core layer or database
7. Response returned as JSON
```
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L1-L209)
### 3.2 Download Flow
```
1. POST /api/queue/add
+-- DownloadService.add_to_queue()
+-- QueueRepository.save_item() -> SQLite
2. POST /api/queue/start
+-- DownloadService.start_queue_processing()
+-- Process pending items sequentially
+-- ProgressService emits events
+-- WebSocketService broadcasts to clients
3. During download:
+-- ProgressService.emit("progress_updated")
+-- WebSocketService.broadcast_to_room()
+-- Client receives WebSocket message
```
Source: [src/server/services/download_service.py](../src/server/services/download_service.py#L1-L150)
### 3.3 WebSocket Event Flow
```
1. Client connects to /ws/connect
2. Server sends "connected" message
3. Client joins room: {"action": "join", "data": {"room": "downloads"}}
4. ProgressService emits events
5. WebSocketService broadcasts to room subscribers
6. Client receives real-time updates
```
Source: [src/server/api/websocket.py](../src/server/api/websocket.py#L1-L260)
---
## 4. Design Patterns
### 4.1 Repository Pattern
Database access is abstracted through repository classes.
```python
# QueueRepository provides CRUD for download items
class QueueRepository:
async def save_item(self, item: DownloadItem) -> None: ...
async def get_all_items(self) -> List[DownloadItem]: ...
async def delete_item(self, item_id: str) -> bool: ...
```
Source: [src/server/services/queue_repository.py](../src/server/services/queue_repository.py)
### 4.2 Dependency Injection
FastAPI's `Depends()` provides constructor injection.
```python
@router.get("/status")
async def get_status(
download_service: DownloadService = Depends(get_download_service),
):
...
```
Source: [src/server/utils/dependencies.py](../src/server/utils/dependencies.py)
### 4.3 Event-Driven Architecture
Progress updates use an event subscription model.
```python
# ProgressService publishes events
progress_service.emit("progress_updated", event)
# WebSocketService subscribes
progress_service.subscribe("progress_updated", ws_handler)
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L98-L108)
### 4.4 Singleton Pattern
Services use module-level singletons for shared state.
```python
# In download_service.py
_download_service_instance: Optional[DownloadService] = None
def get_download_service() -> DownloadService:
global _download_service_instance
if _download_service_instance is None:
_download_service_instance = DownloadService(...)
return _download_service_instance
```
Source: [src/server/services/download_service.py](../src/server/services/download_service.py)
---
## 5. Data Flow
### 5.1 Series Identifier Convention
The system uses two identifier fields:
| Field | Type | Purpose | Example |
| -------- | -------- | -------------------------------------- | -------------------------- |
| `key` | Primary | Provider-assigned, URL-safe identifier | `"attack-on-titan"` |
| `folder` | Metadata | Filesystem folder name (display only) | `"Attack on Titan (2013)"` |
All API operations use `key`. The `folder` is for filesystem operations only.
Source: [src/server/database/models.py](../src/server/database/models.py#L26-L50)
### 5.2 Database Schema
```
+----------------+ +----------------+ +--------------------+
| anime_series | | episodes | | download_queue_item|
+----------------+ +----------------+ +--------------------+
| id (PK) |<--+ | id (PK) | +-->| id (PK) |
| key (unique) | | | series_id (FK) |---+ | series_id (FK) |
| name | +---| season | | status |
| site | | episode_number | | priority |
| folder | | title | | progress_percent |
| created_at | | is_downloaded | | added_at |
| updated_at | | file_path | | started_at |
+----------------+ +----------------+ +--------------------+
```
Source: [src/server/database/models.py](../src/server/database/models.py#L1-L200)
### 5.3 Configuration Storage
Configuration is stored in `data/config.json`:
```json
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": { "enabled": true, "interval_minutes": 60 },
"logging": { "level": "INFO" },
"backup": { "enabled": false, "path": "data/backups" },
"other": {
"master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime"
}
}
```
Source: [data/config.json](../data/config.json)
---
## 6. Technology Stack
| Layer | Technology | Version | Purpose |
| ------------- | ------------------- | ------- | ---------------------- |
| Web Framework | FastAPI | 0.104.1 | REST API, WebSocket |
| ASGI Server | Uvicorn | 0.24.0 | HTTP server |
| Database | SQLite + SQLAlchemy | 2.0.35 | Persistence |
| Auth | python-jose | 3.3.0 | JWT tokens |
| Password | passlib | 1.7.4 | bcrypt hashing |
| Validation | Pydantic | 2.5.0 | Data models |
| Templates | Jinja2 | 3.1.2 | HTML rendering |
| Logging | structlog | 24.1.0 | Structured logging |
| Testing | pytest | 7.4.3 | Unit/integration tests |
Source: [requirements.txt](../requirements.txt)
---
## 7. Scalability Considerations
### Current Limitations
1. **Single-process deployment**: In-memory rate limiting and session state are not shared across processes.
2. **SQLite database**: Not suitable for high concurrency. Consider PostgreSQL for production.
3. **Sequential downloads**: Only one download processes at a time by design.
### Recommended Improvements for Scale
| Concern | Current | Recommended |
| -------------- | --------------- | ----------------- |
| Rate limiting | In-memory dict | Redis |
| Session store | In-memory | Redis or database |
| Database | SQLite | PostgreSQL |
| Task queue | In-memory deque | Celery + Redis |
| Load balancing | None | Nginx/HAProxy |
---
## 8. Integration Points
### 8.1 External Providers
The system integrates with anime streaming providers via the Loader interface.
```python
class Loader(ABC):
@abstractmethod
def search(self, query: str) -> List[Serie]: ...
@abstractmethod
def get_episodes(self, serie: Serie) -> Dict[int, List[int]]: ...
```
Source: [src/core/providers/base_provider.py](../src/core/providers/base_provider.py)
### 8.2 Filesystem Integration
The scanner reads anime directories to detect downloaded episodes.
```python
SerieScanner(
basePath="/path/to/anime", # Anime library directory
loader=provider, # Provider for metadata
db_session=session # Optional database
)
```
Source: [src/core/SerieScanner.py](../src/core/SerieScanner.py#L59-L96)
---
## 9. Security Architecture
### 9.1 Authentication Flow
```
1. User sets master password via POST /api/auth/setup
2. Password hashed with pbkdf2_sha256 (via passlib)
3. Hash stored in config.json
4. Login validates password, returns JWT token
5. JWT contains: session_id, user, created_at, expires_at
6. Subsequent requests include: Authorization: Bearer <token>
```
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L1-L150)
### 9.2 Password Requirements
- Minimum 8 characters
- Mixed case (upper and lower)
- At least one number
- At least one special character
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L97-L125)
### 9.3 Rate Limiting
| Endpoint | Limit | Window |
| ----------------- | ----------- | ---------- |
| `/api/auth/login` | 5 requests | 60 seconds |
| `/api/auth/setup` | 5 requests | 60 seconds |
| All origins | 60 requests | 60 seconds |
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L54-L68)
---
## 10. Deployment Modes
### 10.1 Development
```bash
# Run with hot reload
python -m uvicorn src.server.fastapi_app:app --reload
```
### 10.2 Production
```bash
# Via conda environment
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app \
--host 127.0.0.1 --port 8000
```
### 10.3 Configuration
Environment variables (via `.env` or shell):
| Variable | Default | Description |
| ----------------- | ------------------------------ | ---------------------- |
| `JWT_SECRET_KEY` | Random | Secret for JWT signing |
| `DATABASE_URL` | `sqlite:///./data/aniworld.db` | Database connection |
| `ANIME_DIRECTORY` | (empty) | Path to anime library |
| `LOG_LEVEL` | `INFO` | Logging level |
| `CORS_ORIGINS` | `localhost:3000,8000` | Allowed CORS origins |
Source: [src/config/settings.py](../src/config/settings.py#L1-L96)

105
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,105 @@
# Changelog
## Document Purpose
This document tracks all notable changes to the Aniworld project.
### What This Document Contains
- **Version History**: All released versions with dates
- **Added Features**: New functionality in each release
- **Changed Features**: Modifications to existing features
- **Deprecated Features**: Features marked for removal
- **Removed Features**: Features removed from the codebase
- **Fixed Bugs**: Bug fixes with issue references
- **Security Fixes**: Security-related changes
- **Breaking Changes**: Changes requiring user action
### What This Document Does NOT Contain
- Internal refactoring details (unless user-facing)
- Commit-level changes
- Work-in-progress features
- Roadmap or planned features
### Target Audience
- All users and stakeholders
- Operators planning upgrades
- Developers tracking changes
- Support personnel
---
## Format
This changelog follows [Keep a Changelog](https://keepachangelog.com/) principles and adheres to [Semantic Versioning](https://semver.org/).
## Sections for Each Release
```markdown
## [Version] - YYYY-MM-DD
### Added
- New features
### Changed
- Changes to existing functionality
### Deprecated
- Features that will be removed in future versions
### Removed
- Features removed in this release
### Fixed
- Bug fixes
### Security
- Security-related fixes
```
---
## Unreleased
_Changes that are in development but not yet released._
### Added
- **Enhanced Anime Add Flow**: Automatic database persistence, targeted episode scanning, and folder creation with sanitized names
- Filesystem utility module (`src/server/utils/filesystem.py`) with `sanitize_folder_name()`, `is_safe_path()`, and `create_safe_folder()` functions
- `Serie.sanitized_folder` property for generating filesystem-safe folder names from display names
- `SerieScanner.scan_single_series()` method for targeted scanning of individual anime without full library rescan
- Add series API response now includes `missing_episodes` list and `total_missing` count
- Database transaction support with `@transactional` decorator and `atomic()` context manager
- Transaction propagation modes (REQUIRED, REQUIRES_NEW, NESTED) for fine-grained control
- Savepoint support for nested transactions with partial rollback capability
- `TransactionManager` helper class for manual transaction control
- Bulk operations: `bulk_mark_downloaded`, `bulk_delete`, `clear_all` for batch processing
- `rotate_session` atomic operation for secure session rotation
- Transaction utilities: `is_session_in_transaction`, `get_session_transaction_depth`
- `get_transactional_session` for sessions without auto-commit
### Changed
- `QueueRepository.save_item()` now uses atomic transactions for data consistency
- `QueueRepository.clear_all()` now uses atomic transactions for all-or-nothing behavior
- Service layer documentation updated to reflect transaction-aware design
### Fixed
- Scan status indicator now correctly shows running state after page reload during active scan
- Improved reliability of process status updates in the UI header
---
## Version History
_To be documented as versions are released._

298
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,298 @@
# Configuration Reference
## Document Purpose
This document provides a comprehensive reference for all configuration options in the Aniworld application.
---
## 1. Configuration Overview
### Configuration Sources
Aniworld uses a layered configuration system:
1. **Environment Variables** (highest priority)
2. **`.env` file** in project root
3. **`data/config.json`** file
4. **Default values** (lowest priority)
### Loading Mechanism
Configuration is loaded at application startup via Pydantic Settings.
```python
# src/config/settings.py
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
```
Source: [src/config/settings.py](../src/config/settings.py#L1-L96)
---
## 2. Environment Variables
### Authentication Settings
| Variable | Type | Default | Description |
| ----------------------- | ------ | ---------------- | ------------------------------------------------------------------- |
| `JWT_SECRET_KEY` | string | (random) | Secret key for JWT token signing. Auto-generated if not set. |
| `PASSWORD_SALT` | string | `"default-salt"` | Salt for password hashing. |
| `MASTER_PASSWORD_HASH` | string | (none) | Pre-hashed master password. Loaded from config.json if not set. |
| `MASTER_PASSWORD` | string | (none) | **DEVELOPMENT ONLY** - Plaintext password. Never use in production. |
| `SESSION_TIMEOUT_HOURS` | int | `24` | JWT token expiry time in hours. |
Source: [src/config/settings.py](../src/config/settings.py#L13-L42)
### Server Settings
| Variable | Type | Default | Description |
| ----------------- | ------ | -------------------------------- | --------------------------------------------------------------------- |
| `ANIME_DIRECTORY` | string | `""` | Path to anime library directory. |
| `LOG_LEVEL` | string | `"INFO"` | Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL. |
| `DATABASE_URL` | string | `"sqlite:///./data/aniworld.db"` | Database connection string. |
| `CORS_ORIGINS` | string | `"http://localhost:3000"` | Comma-separated allowed CORS origins. Use `*` for localhost defaults. |
| `API_RATE_LIMIT` | int | `100` | Maximum API requests per minute. |
Source: [src/config/settings.py](../src/config/settings.py#L43-L68)
### Provider Settings
| Variable | Type | Default | Description |
| ------------------ | ------ | --------------- | --------------------------------------------- |
| `DEFAULT_PROVIDER` | string | `"aniworld.to"` | Default anime provider. |
| `PROVIDER_TIMEOUT` | int | `30` | HTTP timeout for provider requests (seconds). |
| `RETRY_ATTEMPTS` | int | `3` | Number of retry attempts for failed requests. |
Source: [src/config/settings.py](../src/config/settings.py#L69-L79)
---
## 3. Configuration File (config.json)
Location: `data/config.json`
### File Structure
```json
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime"
},
"version": "1.0.0"
}
```
Source: [data/config.json](../data/config.json)
---
## 4. Configuration Sections
### 4.1 General Settings
| Field | Type | Default | Description |
| ---------- | ------ | ------------ | ------------------------------ |
| `name` | string | `"Aniworld"` | Application name. |
| `data_dir` | string | `"data"` | Base directory for data files. |
Source: [src/server/models/config.py](../src/server/models/config.py#L62-L66)
### 4.2 Scheduler Settings
Controls automatic library rescanning.
| Field | Type | Default | Description |
| ---------------------------- | ---- | ------- | -------------------------------------------- |
| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. |
| `scheduler.interval_minutes` | int | `60` | Minutes between automatic scans. Minimum: 1. |
Source: [src/server/models/config.py](../src/server/models/config.py#L5-L12)
### 4.3 Logging Settings
| Field | Type | Default | Description |
| ---------------------- | ------ | -------- | ------------------------------------------------- |
| `logging.level` | string | `"INFO"` | Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL. |
| `logging.file` | string | `null` | Optional log file path. |
| `logging.max_bytes` | int | `null` | Maximum log file size for rotation. |
| `logging.backup_count` | int | `3` | Number of rotated log files to keep. |
Source: [src/server/models/config.py](../src/server/models/config.py#L27-L46)
### 4.4 Backup Settings
| Field | Type | Default | Description |
| ------------------ | ------ | ---------------- | -------------------------------- |
| `backup.enabled` | bool | `false` | Enable automatic config backups. |
| `backup.path` | string | `"data/backups"` | Directory for backup files. |
| `backup.keep_days` | int | `30` | Days to retain backups. |
Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24)
### 4.5 Other Settings (Dynamic)
The `other` field stores arbitrary settings.
| Key | Type | Description |
| ---------------------- | ------ | --------------------------------------- |
| `master_password_hash` | string | Hashed master password (pbkdf2-sha256). |
| `anime_directory` | string | Path to anime library. |
| `advanced` | object | Advanced configuration options. |
---
## 5. Configuration Precedence
Settings are resolved in this order (first match wins):
1. Environment variable (e.g., `ANIME_DIRECTORY`)
2. `.env` file in project root
3. `data/config.json` (for dynamic settings)
4. Code defaults in `Settings` class
---
## 6. Validation Rules
### Password Requirements
Master password must meet all criteria:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L97-L125)
### Logging Level Validation
Must be one of: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
Source: [src/server/models/config.py](../src/server/models/config.py#L43-L47)
### Backup Path Validation
If `backup.enabled` is `true`, `backup.path` must be set.
Source: [src/server/models/config.py](../src/server/models/config.py#L87-L91)
---
## 7. Example Configurations
### Minimal Development Setup
**.env file:**
```
LOG_LEVEL=DEBUG
ANIME_DIRECTORY=/home/user/anime
```
### Production Setup
**.env file:**
```
JWT_SECRET_KEY=your-secure-random-key-here
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/aniworld
LOG_LEVEL=WARNING
CORS_ORIGINS=https://your-domain.com
API_RATE_LIMIT=60
```
### Docker Setup
```yaml
# docker-compose.yml
environment:
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- DATABASE_URL=sqlite:///./data/aniworld.db
- ANIME_DIRECTORY=/media/anime
- LOG_LEVEL=INFO
volumes:
- ./data:/app/data
- /media/anime:/media/anime:ro
```
---
## 8. Configuration Backup Management
### Automatic Backups
Backups are created automatically before config changes when `backup.enabled` is `true`.
Location: `data/config_backups/`
Naming: `config_backup_YYYYMMDD_HHMMSS.json`
### Manual Backup via API
```bash
# Create backup
curl -X POST http://localhost:8000/api/config/backups \
-H "Authorization: Bearer $TOKEN"
# List backups
curl http://localhost:8000/api/config/backups \
-H "Authorization: Bearer $TOKEN"
# Restore backup
curl -X POST http://localhost:8000/api/config/backups/config_backup_20251213.json/restore \
-H "Authorization: Bearer $TOKEN"
```
Source: [src/server/api/config.py](../src/server/api/config.py#L67-L142)
---
## 9. Troubleshooting
### Configuration Not Loading
1. Check file permissions on `data/config.json`
2. Verify JSON syntax with a validator
3. Check logs for Pydantic validation errors
### Environment Variable Not Working
1. Ensure variable name matches exactly (case-sensitive)
2. Check `.env` file location (project root)
3. Restart application after changes
### Master Password Issues
1. Password hash is stored in `config.json` under `other.master_password_hash`
2. Delete this field to reset (requires re-setup)
3. Check hash format starts with `$pbkdf2-sha256$`
---
## 10. Related Documentation
- [API.md](API.md) - Configuration API endpoints
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
- [ARCHITECTURE.md](ARCHITECTURE.md) - Configuration service architecture

421
docs/DATABASE.md Normal file
View File

@ -0,0 +1,421 @@
# Database Documentation
## Document Purpose
This document describes the database schema, models, and data layer of the Aniworld application.
---
## 1. Database Overview
### Technology
- **Database Engine**: SQLite 3 (default), PostgreSQL supported
- **ORM**: SQLAlchemy 2.0 with async support (aiosqlite)
- **Location**: `data/aniworld.db` (configurable via `DATABASE_URL`)
Source: [src/config/settings.py](../src/config/settings.py#L53-L55)
### Connection Configuration
```python
# Default connection string
DATABASE_URL = "sqlite+aiosqlite:///./data/aniworld.db"
# PostgreSQL alternative
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/aniworld"
```
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 2. Entity Relationship Diagram
```
+-------------------+ +-------------------+ +------------------------+
| anime_series | | episodes | | download_queue_item |
+-------------------+ +-------------------+ +------------------------+
| id (PK) |<--+ | id (PK) | +-->| id (PK, VARCHAR) |
| key (UNIQUE) | | | series_id (FK)----+---+ | series_id (FK)---------+
| name | +---| | | status |
| site | | season | | priority |
| folder | | episode_number | | season |
| created_at | | title | | episode |
| updated_at | | file_path | | progress_percent |
+-------------------+ | is_downloaded | | error_message |
| created_at | | retry_count |
| updated_at | | added_at |
+-------------------+ | started_at |
| completed_at |
| created_at |
| updated_at |
+------------------------+
```
---
## 3. Table Schemas
### 3.1 anime_series
Stores anime series metadata.
| Column | Type | Constraints | Description |
| ------------ | ------------- | -------------------------- | ------------------------------------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `key` | VARCHAR(255) | UNIQUE, NOT NULL, INDEX | **Primary identifier** - provider-assigned URL-safe key |
| `name` | VARCHAR(500) | NOT NULL, INDEX | Display name of the series |
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Identifier Convention:**
- `key` is the **primary identifier** for all operations (e.g., `"attack-on-titan"`)
- `folder` is **metadata only** for filesystem operations (e.g., `"Attack on Titan (2013)"`)
- `id` is used only for database relationships
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
### 3.2 episodes
Stores individual episode information.
| Column | Type | Constraints | Description |
| ---------------- | ------------- | ---------------------------- | ----------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `series_id` | INTEGER | FOREIGN KEY, NOT NULL, INDEX | Reference to anime_series.id |
| `season` | INTEGER | NOT NULL | Season number (1-based) |
| `episode_number` | INTEGER | NOT NULL | Episode number within season |
| `title` | VARCHAR(500) | NULLABLE | Episode title if known |
| `file_path` | VARCHAR(1000) | NULLABLE | Local file path if downloaded |
| `is_downloaded` | BOOLEAN | NOT NULL, DEFAULT FALSE | Download status flag |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Foreign Key:**
- `series_id` -> `anime_series.id` (ON DELETE CASCADE)
Source: [src/server/database/models.py](../src/server/database/models.py#L122-L181)
### 3.3 download_queue_item
Stores download queue items with status tracking.
| Column | Type | Constraints | Description |
| ------------------ | ------------- | --------------------------- | ------------------------------ |
| `id` | VARCHAR(36) | PRIMARY KEY | UUID identifier |
| `series_id` | INTEGER | FOREIGN KEY, NOT NULL | Reference to anime_series.id |
| `season` | INTEGER | NOT NULL | Season number |
| `episode` | INTEGER | NOT NULL | Episode number |
| `status` | VARCHAR(20) | NOT NULL, DEFAULT 'pending' | Download status |
| `priority` | VARCHAR(10) | NOT NULL, DEFAULT 'NORMAL' | Queue priority |
| `progress_percent` | FLOAT | NULLABLE | Download progress (0-100) |
| `error_message` | TEXT | NULLABLE | Error description if failed |
| `retry_count` | INTEGER | NOT NULL, DEFAULT 0 | Number of retry attempts |
| `source_url` | VARCHAR(2000) | NULLABLE | Download source URL |
| `added_at` | DATETIME | NOT NULL, DEFAULT NOW | When added to queue |
| `started_at` | DATETIME | NULLABLE | When download started |
| `completed_at` | DATETIME | NULLABLE | When download completed/failed |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Status Values:** `pending`, `downloading`, `paused`, `completed`, `failed`, `cancelled`
**Priority Values:** `LOW`, `NORMAL`, `HIGH`
**Foreign Key:**
- `series_id` -> `anime_series.id` (ON DELETE CASCADE)
Source: [src/server/database/models.py](../src/server/database/models.py#L200-L300)
---
## 4. Indexes
| Table | Index Name | Columns | Purpose |
| --------------------- | ----------------------- | ----------- | --------------------------------- |
| `anime_series` | `ix_anime_series_key` | `key` | Fast lookup by primary identifier |
| `anime_series` | `ix_anime_series_name` | `name` | Search by name |
| `episodes` | `ix_episodes_series_id` | `series_id` | Join with series |
| `download_queue_item` | `ix_download_series_id` | `series_id` | Filter by series |
| `download_queue_item` | `ix_download_status` | `status` | Filter by status |
---
## 5. Model Layer
### 5.1 SQLAlchemy ORM Models
```python
# src/server/database/models.py
class AnimeSeries(Base, TimestampMixin):
__tablename__ = "anime_series"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(500), index=True)
site: Mapped[str] = mapped_column(String(500))
folder: Mapped[str] = mapped_column(String(1000))
episodes: Mapped[List["Episode"]] = relationship(
"Episode", back_populates="series", cascade="all, delete-orphan"
)
```
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
### 5.2 Pydantic API Models
```python
# src/server/models/download.py
class DownloadItem(BaseModel):
id: str
serie_id: str # Maps to anime_series.key
serie_folder: str # Metadata only
serie_name: str
episode: EpisodeIdentifier
status: DownloadStatus
priority: DownloadPriority
```
Source: [src/server/models/download.py](../src/server/models/download.py#L63-L118)
### 5.3 Model Mapping
| API Field | Database Column | Notes |
| -------------- | --------------------- | ------------------ |
| `serie_id` | `anime_series.key` | Primary identifier |
| `serie_folder` | `anime_series.folder` | Metadata only |
| `serie_name` | `anime_series.name` | Display name |
---
## 6. Transaction Support
### 6.1 Overview
The database layer provides comprehensive transaction support to ensure data consistency across compound operations. All write operations can be wrapped in explicit transactions.
Source: [src/server/database/transaction.py](../src/server/database/transaction.py)
### 6.2 Transaction Utilities
| Component | Type | Description |
| ------------------------- | ----------------- | ---------------------------------------- |
| `@transactional` | Decorator | Wraps function in transaction boundary |
| `atomic()` | Async context mgr | Provides atomic operation block |
| `atomic_sync()` | Sync context mgr | Sync version of atomic() |
| `TransactionContext` | Class | Explicit sync transaction control |
| `AsyncTransactionContext` | Class | Explicit async transaction control |
| `TransactionManager` | Class | Helper for manual transaction management |
### 6.3 Transaction Propagation Modes
| Mode | Behavior |
| -------------- | ------------------------------------------------ |
| `REQUIRED` | Use existing transaction or create new (default) |
| `REQUIRES_NEW` | Always create new transaction |
| `NESTED` | Create savepoint within existing transaction |
### 6.4 Usage Examples
**Using @transactional decorator:**
```python
from src.server.database.transaction import transactional
@transactional()
async def compound_operation(db: AsyncSession, data: dict):
# All operations commit together or rollback on error
series = await AnimeSeriesService.create(db, ...)
episode = await EpisodeService.create(db, series_id=series.id, ...)
return series, episode
```
**Using atomic() context manager:**
```python
from src.server.database.transaction import atomic
async def some_function(db: AsyncSession):
async with atomic(db) as tx:
await operation1(db)
await operation2(db)
# Auto-commits on success, rolls back on exception
```
**Using savepoints for partial rollback:**
```python
async with atomic(db) as tx:
await outer_operation(db)
async with tx.savepoint() as sp:
await risky_operation(db)
if error_condition:
await sp.rollback() # Only rollback nested ops
await final_operation(db) # Still executes
```
Source: [src/server/database/transaction.py](../src/server/database/transaction.py)
### 6.5 Connection Module Additions
| Function | Description |
| ------------------------------- | -------------------------------------------- |
| `get_transactional_session` | Session without auto-commit for transactions |
| `TransactionManager` | Helper class for manual transaction control |
| `is_session_in_transaction` | Check if session is in active transaction |
| `get_session_transaction_depth` | Get nesting depth of transactions |
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 7. Repository Pattern
The `QueueRepository` class provides data access abstraction.
```python
class QueueRepository:
async def save_item(self, item: DownloadItem) -> None:
"""Save or update a download item (atomic operation)."""
async def get_all_items(self) -> List[DownloadItem]:
"""Get all items from database."""
async def delete_item(self, item_id: str) -> bool:
"""Delete item by ID."""
async def clear_all(self) -> int:
"""Clear all items (atomic operation)."""
```
Note: Compound operations (`save_item`, `clear_all`) are wrapped in `atomic()` transactions.
Source: [src/server/services/queue_repository.py](../src/server/services/queue_repository.py)
---
## 8. Database Service
The `AnimeSeriesService` provides async CRUD operations.
```python
class AnimeSeriesService:
@staticmethod
async def create(
db: AsyncSession,
key: str,
name: str,
site: str,
folder: str
) -> AnimeSeries:
"""Create a new anime series."""
@staticmethod
async def get_by_key(
db: AsyncSession,
key: str
) -> Optional[AnimeSeries]:
"""Get series by primary key identifier."""
```
### Bulk Operations
Services provide bulk operations for transaction-safe batch processing:
| Service | Method | Description |
| ---------------------- | ---------------------- | ------------------------------ |
| `EpisodeService` | `bulk_mark_downloaded` | Mark multiple episodes at once |
| `DownloadQueueService` | `bulk_delete` | Delete multiple queue items |
| `DownloadQueueService` | `clear_all` | Clear entire queue |
| `UserSessionService` | `rotate_session` | Revoke old + create new atomic |
| `UserSessionService` | `cleanup_expired` | Bulk delete expired sessions |
Source: [src/server/database/service.py](../src/server/database/service.py)
---
## 9. Data Integrity Rules
### Validation Constraints
| Field | Rule | Error Message |
| ------------------------- | ------------------------ | ------------------------------------- |
| `anime_series.key` | Non-empty, max 255 chars | "Series key cannot be empty" |
| `anime_series.name` | Non-empty, max 500 chars | "Series name cannot be empty" |
| `episodes.season` | 0-1000 | "Season number must be non-negative" |
| `episodes.episode_number` | 0-10000 | "Episode number must be non-negative" |
Source: [src/server/database/models.py](../src/server/database/models.py#L89-L119)
### Cascade Rules
- Deleting `anime_series` deletes all related `episodes` and `download_queue_item`
---
## 10. Migration Strategy
Currently, SQLAlchemy's `create_all()` is used for schema creation.
```python
# src/server/database/connection.py
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
```
For production migrations, Alembic is recommended but not yet implemented.
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 11. Common Query Patterns
### Get all series with missing episodes
```python
series = await db.execute(
select(AnimeSeries).options(selectinload(AnimeSeries.episodes))
)
for serie in series.scalars():
downloaded = [e for e in serie.episodes if e.is_downloaded]
```
### Get pending downloads ordered by priority
```python
items = await db.execute(
select(DownloadQueueItem)
.where(DownloadQueueItem.status == "pending")
.order_by(
case(
(DownloadQueueItem.priority == "HIGH", 1),
(DownloadQueueItem.priority == "NORMAL", 2),
(DownloadQueueItem.priority == "LOW", 3),
),
DownloadQueueItem.added_at
)
)
```
---
## 12. Database Location
| Environment | Default Location |
| ----------- | ------------------------------------------------- |
| Development | `./data/aniworld.db` |
| Production | Via `DATABASE_URL` environment variable |
| Testing | In-memory SQLite (`sqlite+aiosqlite:///:memory:`) |

64
docs/DEVELOPMENT.md Normal file
View File

@ -0,0 +1,64 @@
# Development Guide
## Document Purpose
This document provides guidance for developers working on the Aniworld project.
### What This Document Contains
- **Prerequisites**: Required software and tools
- **Environment Setup**: Step-by-step local development setup
- **Project Structure**: Source code organization explanation
- **Development Workflow**: Branch strategy, commit conventions
- **Coding Standards**: Style guide, linting, formatting
- **Running the Application**: Development server, CLI usage
- **Debugging Tips**: Common debugging approaches
- **IDE Configuration**: VS Code settings, recommended extensions
- **Contributing Guidelines**: How to submit changes
- **Code Review Process**: Review checklist and expectations
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- API reference (see [API.md](API.md))
- Architecture decisions (see [ARCHITECTURE.md](ARCHITECTURE.md))
- Test writing guides (see [TESTING.md](TESTING.md))
- Security guidelines (see [SECURITY.md](SECURITY.md))
### Target Audience
- New Developers joining the project
- Contributors (internal and external)
- Anyone setting up a development environment
---
## Sections to Document
1. Prerequisites
- Python version
- Conda environment
- Node.js (if applicable)
- Git
2. Getting Started
- Clone repository
- Setup conda environment
- Install dependencies
- Configuration setup
3. Project Structure Overview
4. Development Server
- Starting FastAPI server
- Hot reload configuration
- Debug mode
5. CLI Development
6. Code Style
- PEP 8 compliance
- Type hints requirements
- Docstring format
- Import organization
7. Git Workflow
- Branch naming
- Commit message format
- Pull request process
8. Common Development Tasks
9. Troubleshooting Development Issues

39
docs/README.md Normal file
View File

@ -0,0 +1,39 @@
# Aniworld Documentation
## Overview
This directory contains all documentation for the Aniworld anime download manager project.
## Documentation Structure
| Document | Purpose | Target Audience |
| ---------------------------------------- | ---------------------------------------------- | ---------------------------------- |
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture and design decisions | Architects, Senior Developers |
| [API.md](API.md) | REST API reference and WebSocket documentation | Frontend Developers, API Consumers |
| [DEVELOPMENT.md](DEVELOPMENT.md) | Developer setup and contribution guide | All Developers |
| [DEPLOYMENT.md](DEPLOYMENT.md) | Deployment and operations guide | DevOps, System Administrators |
| [DATABASE.md](DATABASE.md) | Database schema and data models | Backend Developers |
| [TESTING.md](TESTING.md) | Testing strategy and guidelines | QA Engineers, Developers |
| [SECURITY.md](SECURITY.md) | Security considerations and guidelines | Security Engineers, All Developers |
| [CONFIGURATION.md](CONFIGURATION.md) | Configuration options reference | Operators, Developers |
| [CHANGELOG.md](CHANGELOG.md) | Version history and changes | All Stakeholders |
| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and solutions | Support, Operators |
| [features.md](features.md) | Feature list and capabilities | Product Owners, Users |
| [instructions.md](instructions.md) | AI agent development instructions | AI Agents, Developers |
## Documentation Standards
- All documentation uses Markdown format
- Keep documentation up-to-date with code changes
- Include code examples where applicable
- Use clear, concise language
- Include diagrams for complex concepts (use Mermaid syntax)
## Contributing to Documentation
When adding or updating documentation:
1. Follow the established format in each document
2. Update the README.md if adding new documents
3. Ensure cross-references are valid
4. Review for spelling and grammar

71
docs/TESTING.md Normal file
View File

@ -0,0 +1,71 @@
# Testing Documentation
## Document Purpose
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
### What This Document Contains
- **Testing Strategy**: Overall approach to quality assurance
- **Test Categories**: Unit, integration, API, performance, security tests
- **Test Structure**: Organization of test files and directories
- **Writing Tests**: Guidelines for writing effective tests
- **Fixtures and Mocking**: Shared test utilities and mock patterns
- **Running Tests**: Commands and configurations
- **Coverage Requirements**: Minimum coverage thresholds
- **CI/CD Integration**: How tests run in automation
- **Test Data Management**: Managing test fixtures and data
- **Best Practices**: Do's and don'ts for testing
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- Security audit procedures (see [SECURITY.md](SECURITY.md))
- Bug tracking and issue management
- Performance benchmarking results
### Target Audience
- Developers writing tests
- QA Engineers
- CI/CD Engineers
- Code reviewers
---
## Sections to Document
1. Testing Philosophy
- Test pyramid approach
- Quality gates
2. Test Categories
- Unit Tests (`tests/unit/`)
- Integration Tests (`tests/integration/`)
- API Tests (`tests/api/`)
- Frontend Tests (`tests/frontend/`)
- Performance Tests (`tests/performance/`)
- Security Tests (`tests/security/`)
3. Test Structure and Naming
- File naming conventions
- Test function naming
- Test class organization
4. Running Tests
- pytest commands
- Running specific tests
- Verbose output
- Coverage reports
5. Fixtures and Conftest
- Shared fixtures
- Database fixtures
- Mock services
6. Mocking Guidelines
- What to mock
- Mock patterns
- External service mocks
7. Coverage Requirements
8. CI/CD Integration
9. Writing Good Tests
- Arrange-Act-Assert pattern
- Test isolation
- Edge cases
10. Common Pitfalls to Avoid

View File

@ -1,426 +0,0 @@
# Series Identifier Standardization - Validation Instructions
## Overview
This document provides comprehensive instructions for AI agents to validate the **Series Identifier Standardization** change across the Aniworld codebase. The change standardizes `key` as the primary identifier for series and relegates `folder` to metadata-only status.
## Summary of the Change
| Field | Purpose | Usage |
| -------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| `key` | **Primary Identifier** - Provider-assigned, URL-safe (e.g., `attack-on-titan`) | All lookups, API operations, database queries, WebSocket events |
| `folder` | **Metadata Only** - Filesystem folder name (e.g., `Attack on Titan (2013)`) | Display purposes, filesystem operations only |
| `id` | **Database Primary Key** - Internal auto-increment integer | Database relationships only |
---
## Validation Checklist
### Phase 2: Application Layer Services
**Files to validate:**
1. **`src/server/services/anime_service.py`**
- [ ] Class docstring explains `key` vs `folder` convention
- [ ] All public methods accept `key` parameter for series identification
- [ ] No methods accept `folder` as an identifier parameter
- [ ] Event handler methods document key/folder convention
- [ ] Progress tracking uses `key` in progress IDs where possible
2. **`src/server/services/download_service.py`**
- [ ] `DownloadItem` uses `serie_id` (which should be the `key`)
- [ ] `serie_folder` is documented as metadata only
- [ ] Queue operations look up series by `key` not `folder`
- [ ] Persistence format includes `serie_id` as the key identifier
3. **`src/server/services/websocket_service.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Broadcast methods include `key` in message payloads
- [ ] `folder` is documented as optional/display only
- [ ] Event broadcasts use `key` as primary identifier
4. **`src/server/services/scan_service.py`**
- [ ] Scan operations use `key` for identification
- [ ] Progress events include `key` field
5. **`src/server/services/progress_service.py`**
- [ ] Progress tracking includes `key` in metadata where applicable
**Validation Commands:**
```bash
# Check service layer for folder-based lookups
grep -rn "by_folder\|folder.*=.*identifier\|folder.*lookup" src/server/services/ --include="*.py"
# Verify key is used in services
grep -rn "serie_id\|series_key\|key.*identifier" src/server/services/ --include="*.py"
```
---
### Phase 3: API Endpoints and Responses
**Files to validate:**
1. **`src/server/api/anime.py`**
- [ ] `AnimeSummary` model has `key` field with proper description
- [ ] `AnimeDetail` model has `key` field with proper description
- [ ] API docstrings explain `key` is the primary identifier
- [ ] `folder` field descriptions state "metadata only"
- [ ] Endpoint paths use `key` parameter (e.g., `/api/anime/{key}`)
- [ ] No endpoints use `folder` as path parameter for lookups
2. **`src/server/api/download.py`**
- [ ] Download endpoints use `serie_id` (key) for operations
- [ ] Request models document key/folder convention
- [ ] Response models include `key` as primary identifier
3. **`src/server/models/anime.py`**
- [ ] Module docstring explains identifier convention
- [ ] `AnimeSeriesResponse` has `key` field properly documented
- [ ] `SearchResult` has `key` field properly documented
- [ ] Field validators normalize `key` to lowercase
- [ ] `folder` fields document metadata-only purpose
4. **`src/server/models/download.py`**
- [ ] `DownloadItem` has `serie_id` documented as the key
- [ ] `serie_folder` documented as metadata only
- [ ] Field descriptions are clear about primary vs metadata
5. **`src/server/models/websocket.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Message models document `key` as primary identifier
- [ ] `folder` documented as optional display metadata
**Validation Commands:**
```bash
# Check API endpoints for folder-based paths
grep -rn "folder.*Path\|/{folder}" src/server/api/ --include="*.py"
# Verify key is used in endpoints
grep -rn "/{key}\|series_key\|serie_id" src/server/api/ --include="*.py"
# Check model field descriptions
grep -rn "Field.*description.*identifier\|Field.*description.*key\|Field.*description.*folder" src/server/models/ --include="*.py"
```
---
### Phase 4: Frontend Integration
**Files to validate:**
1. **`src/server/web/static/js/app.js`**
- [ ] `selectedSeries` Set uses `key` values, not `folder`
- [ ] `seriesData` array comments indicate `key` as primary identifier
- [ ] Selection operations use `key` property
- [ ] API calls pass `key` for series identification
- [ ] WebSocket message handlers extract `key` from data
- [ ] No code uses `folder` for series lookups
2. **`src/server/web/static/js/queue.js`**
- [ ] Queue items reference series by `key` or `serie_id`
- [ ] WebSocket handlers extract `key` from messages
- [ ] UI operations use `key` for identification
- [ ] `serie_folder` used only for display
3. **`src/server/web/static/js/websocket_client.js`**
- [ ] Message handling preserves `key` field
- [ ] No transformation that loses `key` information
4. **HTML Templates** (`src/server/web/templates/`)
- [ ] Data attributes use `key` for identification (e.g., `data-key`)
- [ ] No `data-folder` used for identification purposes
- [ ] Display uses `folder` or `name` appropriately
**Validation Commands:**
```bash
# Check JavaScript for folder-based lookups
grep -rn "\.folder\s*==\|folder.*identifier\|getByFolder" src/server/web/static/js/ --include="*.js"
# Check data attributes in templates
grep -rn "data-key\|data-folder\|data-series" src/server/web/templates/ --include="*.html"
```
---
### Phase 5: Database Operations
**Files to validate:**
1. **`src/server/database/models.py`**
- [ ] `AnimeSeries` model has `key` column with unique constraint
- [ ] `key` column is indexed
- [ ] Model docstring explains identifier convention
- [ ] `folder` column docstring states "metadata only"
- [ ] Validators check `key` is not empty
- [ ] No `folder` uniqueness constraint (unless intentional)
2. **`src/server/database/service.py`**
- [ ] `AnimeSeriesService` has `get_by_key()` method
- [ ] Class docstring explains lookup convention
- [ ] No `get_by_folder()` without deprecation
- [ ] All CRUD operations use `key` for identification
- [ ] Logging uses `key` in messages
3. **`src/server/database/migrations/`**
- [ ] Migration files maintain `key` as unique, indexed column
- [ ] No migrations that use `folder` as identifier
**Validation Commands:**
```bash
# Check database models
grep -rn "unique=True\|index=True" src/server/database/models.py
# Check service lookups
grep -rn "get_by_key\|get_by_folder\|filter.*key\|filter.*folder" src/server/database/service.py
```
---
### Phase 6: WebSocket Events
**Files to validate:**
1. **All WebSocket broadcast calls** should include `key` in payload:
- `download_progress` → includes `key`
- `download_complete` → includes `key`
- `download_failed` → includes `key`
- `scan_progress` → includes `key` (where applicable)
- `queue_status` → items include `key`
2. **Message format validation:**
```json
{
"type": "download_progress",
"data": {
"key": "attack-on-titan", // PRIMARY - always present
"folder": "Attack on Titan (2013)", // OPTIONAL - display only
"progress": 45.5,
...
}
}
```
**Validation Commands:**
```bash
# Check WebSocket broadcast calls
grep -rn "broadcast.*key\|send_json.*key" src/server/services/ --include="*.py"
# Check message construction
grep -rn '"key":\|"folder":' src/server/services/ --include="*.py"
```
---
### Phase 7: Test Coverage
**Test files to validate:**
1. **`tests/unit/test_serie_class.py`**
- [ ] Tests for key validation (empty, whitespace, None)
- [ ] Tests for key as primary identifier
- [ ] Tests for folder as metadata only
2. **`tests/unit/test_anime_service.py`**
- [ ] Service tests use `key` for operations
- [ ] Mock objects have proper `key` attributes
3. **`tests/unit/test_database_models.py`**
- [ ] Tests for `key` uniqueness constraint
- [ ] Tests for `key` validation
4. **`tests/unit/test_database_service.py`**
- [ ] Tests for `get_by_key()` method
- [ ] No tests for deprecated folder lookups
5. **`tests/api/test_anime_endpoints.py`**
- [ ] API tests use `key` in requests
- [ ] Mock `FakeSerie` has proper `key` attribute
- [ ] Comments explain key/folder convention
6. **`tests/unit/test_websocket_service.py`**
- [ ] WebSocket tests verify `key` in messages
- [ ] Broadcast tests include `key` in payload
**Validation Commands:**
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short
# Run specific test files
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v
conda run -n AniWorld python -m pytest tests/unit/test_database_models.py -v
conda run -n AniWorld python -m pytest tests/api/test_anime_endpoints.py -v
# Search tests for identifier usage
grep -rn "key.*identifier\|folder.*metadata" tests/ --include="*.py"
```
---
## Common Issues to Check
### 1. Inconsistent Naming
Look for inconsistent parameter names:
- `serie_key` vs `series_key` vs `key`
- `serie_id` should refer to `key`, not database `id`
- `serie_folder` vs `folder`
### 2. Missing Documentation
Check that ALL models, services, and APIs document:
- What `key` is and how to use it
- That `folder` is metadata only
### 3. Legacy Code Patterns
Search for deprecated patterns:
```python
# Bad - using folder for lookup
series = get_by_folder(folder_name)
# Good - using key for lookup
series = get_by_key(series_key)
```
### 4. API Response Consistency
Verify all API responses include:
- `key` field (primary identifier)
- `folder` field (optional, for display)
### 5. Frontend Data Flow
Verify the frontend:
- Stores `key` in selection sets
- Passes `key` to API calls
- Uses `folder` only for display
---
## Deprecation Warnings
The following should have deprecation warnings (for removal in v3.0.0):
1. Any `get_by_folder()` or `GetByFolder()` methods
2. Any API endpoints that accept `folder` as a lookup parameter
3. Any frontend code that uses `folder` for identification
**Example deprecation:**
```python
import warnings
def get_by_folder(self, folder: str):
"""DEPRECATED: Use get_by_key() instead."""
warnings.warn(
"get_by_folder() is deprecated, use get_by_key(). "
"Will be removed in v3.0.0",
DeprecationWarning,
stacklevel=2
)
# ... implementation
```
---
## Automated Validation Script
Run this script to perform automated checks:
```bash
#!/bin/bash
# identifier_validation.sh
echo "=== Series Identifier Standardization Validation ==="
echo ""
echo "1. Checking core entities..."
grep -rn "PRIMARY IDENTIFIER\|metadata only" src/core/entities/ --include="*.py" | head -20
echo ""
echo "2. Checking for deprecated folder lookups..."
grep -rn "get_by_folder\|GetByFolder" src/ --include="*.py"
echo ""
echo "3. Checking API models for key field..."
grep -rn 'key.*Field\|Field.*key' src/server/models/ --include="*.py" | head -20
echo ""
echo "4. Checking database models..."
grep -rn "key.*unique\|key.*index" src/server/database/models.py
echo ""
echo "5. Checking frontend key usage..."
grep -rn "selectedSeries\|\.key\|data-key" src/server/web/static/js/ --include="*.js" | head -20
echo ""
echo "6. Running tests..."
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v --tb=short
echo ""
echo "=== Validation Complete ==="
```
---
## Expected Results
After validation, you should confirm:
1. ✅ All core entities use `key` as primary identifier
2. ✅ All services look up series by `key`
3. ✅ All API endpoints use `key` for operations
4. ✅ All database queries use `key` for lookups
5. ✅ Frontend uses `key` for selection and API calls
6. ✅ WebSocket events include `key` in payload
7. ✅ All tests pass
8. ✅ Documentation clearly explains the convention
9. ✅ Deprecation warnings exist for legacy patterns
---
## Sign-off
Once validation is complete, update this section:
- [x] Phase 1: Core Entities - Validated by: **AI Agent** Date: **28 Nov 2025**
- [x] Phase 2: Services - Validated by: **AI Agent** Date: **28 Nov 2025**
- [ ] Phase 3: API - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 4: Frontend - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 5: Database - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 6: WebSocket - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 7: Tests - Validated by: **\_\_\_** Date: **\_\_\_**
**Final Approval:** \***\*\*\*\*\***\_\_\_\***\*\*\*\*\*** Date: **\*\***\_**\*\***

View File

@ -1,337 +0,0 @@
# Aniworld Web Application Infrastructure
```bash
conda activate AniWorld
```
## Project Structure
```
src/
├── core/ # Core application logic
│ ├── SeriesApp.py # Main application class
│ ├── SerieScanner.py # Directory scanner
│ ├── entities/ # Domain entities (series.py, SerieList.py)
│ ├── interfaces/ # Abstract interfaces (providers.py, callbacks.py)
│ ├── providers/ # Content providers (aniworld, streaming)
│ └── exceptions/ # Custom exceptions
├── server/ # FastAPI web application
│ ├── fastapi_app.py # Main FastAPI application
│ ├── controllers/ # Route controllers (health, page, error)
│ ├── api/ # API routes (auth, config, anime, download, websocket)
│ ├── models/ # Pydantic models
│ ├── services/ # Business logic services
│ ├── database/ # SQLAlchemy ORM layer
│ ├── utils/ # Utilities (dependencies, templates, security)
│ └── web/ # Frontend (templates, static assets)
├── cli/ # CLI application
data/ # Config, database, queue state
logs/ # Application logs
tests/ # Test suites
```
## Technology Stack
| Layer | Technology |
| --------- | ---------------------------------------------- |
| Backend | FastAPI, Uvicorn, SQLAlchemy, SQLite, Pydantic |
| Frontend | HTML5, CSS3, Vanilla JS, Bootstrap 5, HTMX |
| Security | JWT (python-jose), bcrypt (passlib) |
| Real-time | Native WebSocket |
## Series Identifier Convention
Throughout the codebase, three identifiers are used for anime series:
| Identifier | Type | Purpose | Example |
| ---------- | --------------- | ----------------------------------------------------------- | -------------------------- |
| `key` | Unique, Indexed | **PRIMARY** - All lookups, API operations, WebSocket events | `"attack-on-titan"` |
| `folder` | String | Display/filesystem metadata only (never for lookups) | `"Attack on Titan (2013)"` |
| `id` | Primary Key | Internal database key for relationships | `1`, `42` |
### Key Format Requirements
- **Lowercase only**: No uppercase letters allowed
- **URL-safe**: Only alphanumeric characters and hyphens
- **Hyphen-separated**: Words separated by single hyphens
- **No leading/trailing hyphens**: Must start and end with alphanumeric
- **No consecutive hyphens**: `attack--titan` is invalid
**Valid examples**: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`, `"re-zero"`
**Invalid examples**: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
### Migration Notes
- **Backward Compatibility**: API endpoints accepting `anime_id` will check `key` first, then fall back to `folder` lookup
- **Deprecation**: Folder-based lookups are deprecated and will be removed in a future version
- **New Code**: Always use `key` for identification; `folder` is metadata only
## API Endpoints
### Authentication (`/api/auth`)
- `POST /login` - Master password authentication (returns JWT)
- `POST /logout` - Invalidate session
- `GET /status` - Check authentication status
### Configuration (`/api/config`)
- `GET /` - Get configuration
- `PUT /` - Update configuration
- `POST /validate` - Validate without applying
- `GET /backups` - List backups
- `POST /backups/{name}/restore` - Restore backup
### Anime (`/api/anime`)
- `GET /` - List anime with missing episodes (returns `key` as identifier)
- `GET /{anime_id}` - Get anime details (accepts `key` or `folder` for backward compatibility)
- `POST /search` - Search for anime (returns `key` as identifier)
- `POST /add` - Add new series (extracts `key` from link URL)
- `POST /rescan` - Trigger library rescan
**Response Models:**
- `AnimeSummary`: `key` (primary identifier), `name`, `site`, `folder` (metadata), `missing_episodes`, `link`
- `AnimeDetail`: `key` (primary identifier), `title`, `folder` (metadata), `episodes`, `description`
### Download Queue (`/api/queue`)
- `GET /status` - Queue status and statistics
- `POST /add` - Add episodes to queue
- `DELETE /{item_id}` - Remove item
- `POST /start` | `/stop` | `/pause` | `/resume` - Queue control
- `POST /retry` - Retry failed downloads
- `DELETE /completed` - Clear completed items
**Request Models:**
- `DownloadRequest`: `serie_id` (key, primary identifier), `serie_folder` (filesystem path), `serie_name` (display), `episodes`, `priority`
**Response Models:**
- `DownloadItem`: `id`, `serie_id` (key), `serie_folder` (metadata), `serie_name`, `episode`, `status`, `progress`
- `QueueStatus`: `is_running`, `is_paused`, `active_downloads`, `pending_queue`, `completed_downloads`, `failed_downloads`
### WebSocket (`/ws/connect`)
Real-time updates for downloads, scans, and queue operations.
**Rooms**: `downloads`, `download_progress`, `scan_progress`
**Message Types**: `download_progress`, `download_complete`, `download_failed`, `queue_status`, `scan_progress`, `scan_complete`, `scan_failed`
**Series Identifier in Messages:**
All series-related WebSocket events include `key` as the primary identifier in their data payload:
```json
{
"type": "download_progress",
"timestamp": "2025-10-17T10:30:00.000Z",
"data": {
"download_id": "abc123",
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"percent": 45.2,
"speed_mbps": 2.5,
"eta_seconds": 180
}
}
```
## Database Models
| Model | Purpose |
| ----------------- | ---------------------------------------- |
| AnimeSeries | Series metadata (key, name, folder, etc) |
| Episode | Episodes linked to series |
| DownloadQueueItem | Queue items with status and progress |
| UserSession | JWT sessions with expiry |
**Mixins**: `TimestampMixin` (created_at, updated_at), `SoftDeleteMixin`
### AnimeSeries Identifier Fields
| Field | Type | Purpose |
| -------- | --------------- | ------------------------------------------------- |
| `id` | Primary Key | Internal database key for relationships |
| `key` | Unique, Indexed | **PRIMARY IDENTIFIER** for all lookups |
| `folder` | String | Filesystem metadata only (not for identification) |
**Database Service Methods:**
- `AnimeSeriesService.get_by_key(key)` - **Primary lookup method**
- `AnimeSeriesService.get_by_id(id)` - Internal lookup by database ID
- No `get_by_folder()` method exists - folder is never used for lookups
## Core Services
### SeriesApp (`src/core/SeriesApp.py`)
Main engine for anime series management with async support, progress callbacks, and cancellation.
### Callback System (`src/core/interfaces/callbacks.py`)
- `ProgressCallback`, `ErrorCallback`, `CompletionCallback`
- Context classes include `key` + optional `folder` fields
- Thread-safe `CallbackManager` for multiple callback registration
### Services (`src/server/services/`)
| Service | Purpose |
| ---------------- | ----------------------------------------- |
| AnimeService | Series management, scans (uses SeriesApp) |
| DownloadService | Queue management, download execution |
| ScanService | Library scan operations with callbacks |
| ProgressService | Centralized progress tracking + WebSocket |
| WebSocketService | Real-time connection management |
| AuthService | JWT authentication, rate limiting |
| ConfigService | Configuration persistence with backups |
## Validation Utilities (`src/server/utils/validators.py`)
Provides data validation functions for ensuring data integrity across the application.
### Series Key Validation
- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only)
- Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`
- Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation
- Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format
- Set `allow_folder=False` to require strict key format
### Other Validators
| Function | Purpose |
| --------------------------- | ------------------------------------------ |
| `validate_series_name` | Series display name validation |
| `validate_episode_range` | Episode range validation (1-1000) |
| `validate_download_quality` | Quality setting (360p-1080p, best, worst) |
| `validate_language` | Language codes (ger-sub, ger-dub, etc.) |
| `validate_anime_url` | Aniworld.to/s.to URL validation |
| `validate_backup_name` | Backup filename validation |
| `validate_config_data` | Configuration data structure validation |
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
## Template Helpers (`src/server/utils/template_helpers.py`)
Provides utilities for template rendering and series data preparation.
### Core Functions
| Function | Purpose |
| -------------------------- | --------------------------------- |
| `get_base_context` | Base context for all templates |
| `render_template` | Render template with context |
| `validate_template_exists` | Check if template file exists |
| `list_available_templates` | List all available template files |
### Series Context Helpers
All series helpers use `key` as the primary identifier:
| Function | Purpose |
| ----------------------------------- | ---------------------------------------------- |
| `prepare_series_context` | Prepare series data for templates (uses `key`) |
| `get_series_by_key` | Find series by `key` (not `folder`) |
| `filter_series_by_missing_episodes` | Filter series with missing episodes |
**Example Usage:**
```python
from src.server.utils.template_helpers import prepare_series_context
series_data = [
{"key": "attack-on-titan", "name": "Attack on Titan", "folder": "Attack on Titan (2013)"},
{"key": "one-piece", "name": "One Piece", "folder": "One Piece (1999)"}
]
prepared = prepare_series_context(series_data, sort_by="name")
# Returns sorted list using 'key' as identifier
```
## Frontend
### Static Files
- CSS: `styles.css` (Fluent UI design), `ux_features.css` (accessibility)
- JS: `app.js`, `queue.js`, `websocket_client.js`, accessibility modules
### WebSocket Client
Native WebSocket wrapper with Socket.IO-compatible API:
```javascript
const socket = io();
socket.join("download_progress");
socket.on("download_progress", (data) => {
/* ... */
});
```
### Authentication
JWT tokens stored in localStorage, included as `Authorization: Bearer <token>`.
## Testing
```bash
# All tests
conda run -n AniWorld python -m pytest tests/ -v
# Unit tests only
conda run -n AniWorld python -m pytest tests/unit/ -v
# API tests
conda run -n AniWorld python -m pytest tests/api/ -v
```
## Production Notes
### Current (Single-Process)
- SQLite with WAL mode
- In-memory WebSocket connections
- File-based config and queue persistence
### Multi-Process Deployment
- Switch to PostgreSQL/MySQL
- Move WebSocket registry to Redis
- Use distributed locking for queue operations
- Consider Redis for session/cache storage
## Code Examples
### API Usage with Key Identifier
```python
# Fetching anime list - response includes 'key' as identifier
response = requests.get("/api/anime", headers={"Authorization": f"Bearer {token}"})
anime_list = response.json()
# Each item has: key="attack-on-titan", folder="Attack on Titan (2013)", ...
# Fetching specific anime by key (preferred)
response = requests.get("/api/anime/attack-on-titan", headers={"Authorization": f"Bearer {token}"})
# Adding to download queue using key
download_request = {
"serie_id": "attack-on-titan", # Use key, not folder
"serie_folder": "Attack on Titan (2013)", # Metadata for filesystem
"serie_name": "Attack on Titan",
"episodes": ["S01E01", "S01E02"],
"priority": 1
}
response = requests.post("/api/queue/add", json=download_request, headers=headers)
```
### WebSocket Event Handling
```javascript
// WebSocket events always include 'key' as identifier
socket.on("download_progress", (data) => {
const key = data.key; // Primary identifier: "attack-on-titan"
const folder = data.folder; // Metadata: "Attack on Titan (2013)"
updateProgressBar(key, data.percent);
});
```

View File

@ -75,7 +75,7 @@ conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.
---
## Final Implementation Notes
## 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
@ -100,23 +100,10 @@ For each task completed:
- [ ] Performance validated
- [ ] Code reviewed
- [ ] Task marked as complete in instructions.md
- [ ] Infrastructure.md updated
- [ ] Infrastructure.md updated and other docs
- [ ] Changes committed to git; keep your messages in git short and clear
- [ ] Take the next task
---
### Prerequisites
1. Server is running: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload`
2. Password: `Hallo123!`
3. Login via browser at `http://127.0.0.1:8000/login`
### Notes
- This is a simplification that removes complexity while maintaining core functionality
- Improves user experience with explicit manual control
- Easier to understand, test, and maintain
- Good foundation for future enhancements if needed
---
## TODO List:

View File

@ -14,5 +14,4 @@ pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2
sqlalchemy>=2.0.35
alembic==1.13.0
aiosqlite>=0.19.0

View File

@ -2,7 +2,8 @@
"""
Startup script for the Aniworld FastAPI application.
This script starts the application with proper logging configuration.
This script starts the application with proper logging configuration
and graceful shutdown support via Ctrl+C (SIGINT) or SIGTERM.
"""
import uvicorn
@ -15,6 +16,11 @@ if __name__ == "__main__":
# Run the application with logging.
# Only watch .py files in src/, explicitly exclude __pycache__.
# This prevents reload loops from .pyc compilation.
#
# Graceful shutdown:
# - Ctrl+C (SIGINT) or SIGTERM triggers graceful shutdown
# - timeout_graceful_shutdown ensures shutdown completes within 30s
# - The FastAPI lifespan handler orchestrates cleanup in proper order
uvicorn.run(
"src.server.fastapi_app:app",
host="127.0.0.1",
@ -24,4 +30,5 @@ if __name__ == "__main__":
reload_includes=["*.py"],
reload_excludes=["*/__pycache__/*", "*.pyc"],
log_config=log_config,
timeout_graceful_shutdown=30, # Allow 30s for graceful shutdown
)

View File

@ -7,7 +7,7 @@
# installs dependencies, sets up the database, and starts the application.
#
# Usage:
# ./start.sh [development|production] [--no-install] [--no-migrate]
# ./start.sh [development|production] [--no-install]
#
# Environment Variables:
# ENVIRONMENT: 'development' or 'production' (default: development)
@ -28,7 +28,6 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CONDA_ENV="${CONDA_ENV:-AniWorld}"
ENVIRONMENT="${1:-development}"
INSTALL_DEPS="${INSTALL_DEPS:-true}"
RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}"
PORT="${PORT:-8000}"
HOST="${HOST:-127.0.0.1}"
@ -104,20 +103,6 @@ install_dependencies() {
log_success "Dependencies installed."
}
# Run database migrations
run_migrations() {
if [[ "$RUN_MIGRATIONS" != "true" ]]; then
log_warning "Skipping database migrations."
return
fi
log_info "Running database migrations..."
cd "$PROJECT_ROOT"
conda run -n "$CONDA_ENV" \
python -m alembic upgrade head 2>/dev/null || log_warning "No migrations to run."
log_success "Database migrations completed."
}
# Initialize database
init_database() {
log_info "Initializing database..."
@ -220,10 +205,6 @@ main() {
INSTALL_DEPS="false"
shift
;;
--no-migrate)
RUN_MIGRATIONS="false"
shift
;;
*)
ENVIRONMENT="$1"
shift
@ -237,7 +218,6 @@ main() {
create_env_file
install_dependencies
init_database
run_migrations
start_application
}

View File

@ -1,316 +0,0 @@
"""Command-line interface for the Aniworld anime download manager."""
import logging
import os
from typing import Optional, Sequence
from rich.progress import Progress
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp as CoreSeriesApp
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
logger = logging.getLogger(__name__)
class SeriesCLI:
"""Thin wrapper around :class:`SeriesApp` providing an interactive CLI."""
def __init__(self, directory_to_search: str) -> None:
print("Please wait while initializing...")
self.directory_to_search = directory_to_search
self.series_app = CoreSeriesApp(directory_to_search)
self._progress: Optional[Progress] = None
self._overall_task_id: Optional[int] = None
self._series_task_id: Optional[int] = None
self._episode_task_id: Optional[int] = None
self._scan_task_id: Optional[int] = None
# ------------------------------------------------------------------
# Utility helpers
# ------------------------------------------------------------------
def _get_series_list(self) -> Sequence[Serie]:
"""Return the currently cached series with missing episodes."""
return self.series_app.get_series_list()
# ------------------------------------------------------------------
# Display & selection
# ------------------------------------------------------------------
def display_series(self) -> None:
"""Print all series with assigned numbers."""
series = self._get_series_list()
if not series:
print("\nNo series with missing episodes were found.")
return
print("\nCurrent result:")
for index, serie in enumerate(series, start=1):
name = (serie.name or "").strip()
label = name if name else serie.folder
print(f"{index}. {label}")
def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Prompt the user to select one or more series for download."""
series = list(self._get_series_list())
if not series:
print("No series available for download.")
return None
self.display_series()
prompt = (
"\nSelect series by number (e.g. '1', '1,2' or 'all') "
"or type 'exit' to return: "
)
selection = input(prompt).strip().lower()
if selection in {"exit", ""}:
return None
if selection == "all":
return series
try:
indexes = [
int(value.strip()) - 1
for value in selection.split(",")
]
except ValueError:
print("Invalid selection. Returning to main menu.")
return None
chosen = [
series[i]
for i in indexes
if 0 <= i < len(series)
]
if not chosen:
print("No valid series selected.")
return None
return chosen
# ------------------------------------------------------------------
# Download logic
# ------------------------------------------------------------------
def download_series(self, series: Sequence[Serie]) -> None:
"""Download all missing episodes for the provided series list."""
total_episodes = sum(
len(episodes)
for serie in series
for episodes in serie.episodeDict.values()
)
if total_episodes == 0:
print("Selected series do not contain missing episodes.")
return
self._progress = Progress()
with self._progress:
self._overall_task_id = self._progress.add_task(
"[red]Processing...", total=total_episodes
)
self._series_task_id = self._progress.add_task(
"[green]Current series", total=1
)
self._episode_task_id = self._progress.add_task(
"[gray]Download", total=100
)
for serie in series:
serie_total = sum(len(eps) for eps in serie.episodeDict.values())
self._progress.update(
self._series_task_id,
total=max(serie_total, 1),
completed=0,
description=f"[green]{serie.folder}",
)
for season, episodes in serie.episodeDict.items():
for episode in episodes:
if not self.series_app.loader.is_language(
season, episode, serie.key
):
logger.info(
"Skipping %s S%02dE%02d because the desired language is unavailable",
serie.folder,
season,
episode,
)
continue
result = self.series_app.download(
serieFolder=serie.folder,
season=season,
episode=episode,
key=serie.key,
callback=self._update_download_progress,
)
if not result.success:
logger.error("Download failed: %s", result.message)
self._progress.advance(self._overall_task_id)
self._progress.advance(self._series_task_id)
self._progress.update(
self._episode_task_id,
completed=0,
description="[gray]Waiting...",
)
self._progress = None
self.series_app.refresh_series_list()
def _update_download_progress(self, percent: float) -> None:
"""Update the episode progress bar based on download progress."""
if not self._progress or self._episode_task_id is None:
return
description = f"[gray]Download: {percent:.1f}%"
self._progress.update(
self._episode_task_id,
completed=percent,
description=description,
)
# ------------------------------------------------------------------
# Rescan logic
# ------------------------------------------------------------------
def rescan(self) -> None:
"""Trigger a rescan of the anime directory using the core app."""
total_to_scan = self.series_app.SerieScanner.get_total_to_scan()
total_to_scan = max(total_to_scan, 1)
self._progress = Progress()
with self._progress:
self._scan_task_id = self._progress.add_task(
"[red]Scanning folders...",
total=total_to_scan,
)
result = self.series_app.ReScan(
callback=self._wrap_scan_callback(total_to_scan)
)
self._progress = None
self._scan_task_id = None
if result.success:
print(result.message)
else:
print(f"Scan failed: {result.message}")
def _wrap_scan_callback(self, total: int):
"""Create a callback that updates the scan progress bar."""
def _callback(folder: str, current: int) -> None:
if not self._progress or self._scan_task_id is None:
return
self._progress.update(
self._scan_task_id,
completed=min(current, total),
description=f"[green]{folder}",
)
return _callback
# ------------------------------------------------------------------
# Search & add logic
# ------------------------------------------------------------------
def search_mode(self) -> None:
"""Search for a series and add it to the local list if chosen."""
query = input("Enter search string: ").strip()
if not query:
return
results = self.series_app.search(query)
if not results:
print("No results found. Returning to main menu.")
return
print("\nSearch results:")
for index, result in enumerate(results, start=1):
print(f"{index}. {result.get('name', 'Unknown')}")
selection = input(
"\nSelect an option by number or press <enter> to cancel: "
).strip()
if selection == "":
return
try:
chosen_index = int(selection) - 1
except ValueError:
print("Invalid input. Returning to main menu.")
return
if not (0 <= chosen_index < len(results)):
print("Invalid selection. Returning to main menu.")
return
chosen = results[chosen_index]
serie = Serie(
chosen.get("link", ""),
chosen.get("name", "Unknown"),
"aniworld.to",
chosen.get("link", ""),
{},
)
self.series_app.List.add(serie)
self.series_app.refresh_series_list()
print(f"Added '{serie.name}' to the local catalogue.")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def run(self) -> None:
"""Run the interactive CLI loop."""
while True:
action = input(
"\nChoose action ('s' for search, 'i' for rescan, 'd' for download, 'q' to quit): "
).strip().lower()
if action == "s":
self.search_mode()
elif action == "i":
print("\nRescanning series...\n")
self.rescan()
elif action == "d":
selected_series = self.get_user_selection()
if selected_series:
self.download_series(selected_series)
elif action in {"q", "quit", "exit"}:
print("Goodbye!")
break
else:
print("Unknown command. Please choose 's', 'i', 'd', or 'q'.")
def configure_logging() -> None:
"""Set up a basic logging configuration for the CLI."""
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
logging.getLogger("charset_normalizer").setLevel(logging.ERROR)
def main() -> None:
"""Entry point for the CLI application."""
configure_logging()
default_dir = os.getenv("ANIME_DIRECTORY")
if not default_dir:
print(
"Environment variable ANIME_DIRECTORY is not set. Please configure it to the base anime directory."
)
return
app = SeriesCLI(default_dir)
app.run()
if __name__ == "__main__":
main()

View File

@ -3,25 +3,24 @@ SerieScanner - Scans directories for anime series and missing episodes.
This module provides functionality to scan anime directories, identify
missing episodes, and report progress through callback interfaces.
Note:
This module is pure domain logic. Database operations are handled
by the service layer (AnimeService).
"""
from __future__ import annotations
import logging
import os
import re
import traceback
import uuid
from typing import Callable, Iterable, Iterator, Optional
from typing import Iterable, Iterator, Optional
from events import Events
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.base_provider import Loader
logger = logging.getLogger(__name__)
@ -34,13 +33,22 @@ class SerieScanner:
Scans directories for anime series and identifies missing episodes.
Supports progress callbacks for real-time scanning updates.
Note:
This class is pure domain logic. Database operations are handled
by the service layer (AnimeService). Scan results are stored
in keyDict and can be retrieved after scanning.
Example:
scanner = SerieScanner("/path/to/anime", loader)
scanner.scan()
# Results are in scanner.keyDict
"""
def __init__(
self,
basePath: str,
loader: Loader,
callback_manager: Optional[CallbackManager] = None
) -> None:
"""
Initialize the SerieScanner.
@ -67,18 +75,84 @@ class SerieScanner:
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._callback_manager: CallbackManager = (
callback_manager or CallbackManager()
)
self._current_operation_id: Optional[str] = None
self.events = Events()
self.events.on_progress = []
self.events.on_error = []
self.events.on_completion = []
logger.info("Initialized SerieScanner with base path: %s", abs_path)
def _safe_call_event(self, event_handler, data: dict) -> None:
"""Safely call an event handler if it exists.
Args:
event_handler: Event handler attribute (e.g., self.events.on_progress)
data: Data dictionary to pass to the event handler
"""
if event_handler:
try:
# Event handlers are stored as lists, iterate over them
for handler in event_handler:
handler(data)
except Exception as e:
logger.error("Error calling event handler: %s", e, exc_info=True)
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def subscribe_on_progress(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_progress:
self.events.on_progress.append(handler)
def unsubscribe_on_progress(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_progress:
self.events.on_progress.remove(handler)
def subscribe_on_error(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_error:
self.events.on_error.append(handler)
def unsubscribe_on_error(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_error:
self.events.on_error.remove(handler)
def subscribe_on_completion(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_completion:
self.events.on_completion.append(handler)
def unsubscribe_on_completion(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_completion:
self.events.on_completion.remove(handler)
def reinit(self) -> None:
"""Reinitialize the series dictionary (keyed by serie.key)."""
self.keyDict: dict[str, Serie] = {}
@ -92,15 +166,12 @@ class SerieScanner:
result = self.__find_mp4_files()
return sum(1 for _ in result)
def scan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> None:
def scan(self) -> None:
"""
Scan directories for anime series and missing episodes.
Args:
callback: Optional legacy callback function (folder, count)
Results are stored in self.keyDict and can be retrieved after
scanning. Data files are also saved to disk for persistence.
Raises:
Exception: If scan fails critically
@ -111,16 +182,16 @@ class SerieScanner:
logger.info("Starting scan for missing episodes")
# Notify scan starting
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing scan"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "STARTING",
"current": 0,
"total": 0,
"percentage": 0.0,
"message": "Initializing scan"
}
)
try:
@ -144,27 +215,20 @@ class SerieScanner:
else:
percentage = 0.0
# Progress is surfaced both through the callback manager
# (for the web/UI layer) and, for compatibility, through a
# legacy callback that updates CLI progress bars.
# Notify progress
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=counter,
total=total_to_scan,
percentage=percentage,
message=f"Scanning: {folder}",
details=f"Found {len(mp4_files)} episodes"
)
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "IN_PROGRESS",
"current": counter,
"total": total_to_scan,
"percentage": percentage,
"message": f"Scanning: {folder}",
"details": f"Found {len(mp4_files)} episodes"
}
)
# Call legacy callback if provided
if callback:
callback(folder, counter)
serie = self.__read_data_from_file(folder)
if (
serie is not None
@ -211,15 +275,15 @@ class SerieScanner:
error_msg = f"Error processing folder '{folder}': {nkfe}"
logger.error(error_msg)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=nkfe,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": nkfe,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
)
except Exception as e:
# Log error and notify via callback
@ -233,30 +297,30 @@ class SerieScanner:
traceback.format_exc()
)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
)
continue
# Notify scan completion
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=True,
message=f"Scan completed. Processed {counter} folders.",
statistics={
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": True,
"message": f"Scan completed. Processed {counter} folders.",
"statistics": {
"total_folders": counter,
"series_found": len(self.keyDict)
}
)
}
)
logger.info(
@ -270,23 +334,23 @@ class SerieScanner:
error_msg = f"Critical scan error: {e}"
logger.error("%s\n%s", error_msg, traceback.format_exc())
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": False
}
)
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": False,
"message": error_msg
}
)
raise
@ -306,16 +370,6 @@ class SerieScanner:
has_files = True
yield anime_name, mp4_files if has_files else []
def __remove_year(self, input_string: str) -> str:
"""Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logger.debug(
"Removed year from '%s' -> '%s'",
input_string,
cleaned_string
)
return cleaned_string
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
"""Read serie data from file or key file.
@ -442,3 +496,185 @@ class SerieScanner:
episodes_dict[season] = missing_episodes
return episodes_dict, "aniworld.to"
def scan_single_series(
self,
key: str,
folder: str,
) -> dict[int, list[int]]:
"""
Scan a single series for missing episodes.
This method performs a targeted scan for only the specified series,
without triggering a full library rescan. It fetches available
episodes from the provider and compares with local files.
Args:
key: The unique provider key for the series
folder: The filesystem folder name where the series is stored
Returns:
dict[int, list[int]]: Dictionary mapping season numbers to lists
of missing episode numbers. Empty dict if no missing episodes.
Raises:
ValueError: If key or folder is empty
Example:
>>> scanner = SerieScanner("/path/to/anime", loader)
>>> missing = scanner.scan_single_series(
... "attack-on-titan",
... "Attack on Titan"
... )
>>> print(missing)
{1: [5, 6, 7], 2: [1, 2]}
"""
if not key or not key.strip():
raise ValueError("Series key cannot be empty")
if not folder or not folder.strip():
raise ValueError("Series folder cannot be empty")
logger.info(
"Starting targeted scan for series: %s (folder: %s)",
key,
folder
)
# Generate unique operation ID for this targeted scan
operation_id = str(uuid.uuid4())
# Notify scan starting
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "STARTING",
"current": 0,
"total": 1,
"percentage": 0.0,
"message": f"Scanning series: {folder}",
"details": f"Key: {key}"
}
)
try:
# Get the folder path
folder_path = os.path.join(self.directory, folder)
# Check if folder exists
if not os.path.isdir(folder_path):
logger.info(
"Series folder does not exist yet: %s - "
"will scan for available episodes from provider",
folder_path
)
mp4_files: list[str] = []
else:
# Find existing MP4 files in the folder
mp4_files = []
for root, _, files in os.walk(folder_path):
for file in files:
if file.endswith(".mp4"):
mp4_files.append(os.path.join(root, file))
logger.debug(
"Found %d existing MP4 files in folder %s",
len(mp4_files),
folder
)
# Get missing episodes from provider
missing_episodes, site = self.__get_missing_episodes_and_season(
key, mp4_files
)
# Update progress
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "IN_PROGRESS",
"current": 1,
"total": 1,
"percentage": 100.0,
"message": f"Scanned: {folder}",
"details": f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
}
)
# Create or update Serie in keyDict
if key in self.keyDict:
# Update existing serie
self.keyDict[key].episodeDict = missing_episodes
logger.debug(
"Updated existing series %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
else:
# Create new serie entry
serie = Serie(
key=key,
name="", # Will be populated by caller if needed
site=site,
folder=folder,
episodeDict=missing_episodes
)
self.keyDict[key] = serie
logger.debug(
"Created new series entry for %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
# Notify completion
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": True,
"message": f"Scan completed for {folder}",
"statistics": {
"missing_episodes": sum(
len(eps) for eps in missing_episodes.values()
),
"seasons_with_missing": len(missing_episodes)
}
}
)
logger.info(
"Targeted scan completed for %s: %d missing episodes across %d seasons",
key,
sum(len(eps) for eps in missing_episodes.values()),
len(missing_episodes)
)
return missing_episodes
except Exception as e:
error_msg = f"Failed to scan series {key}: {e}"
logger.error(error_msg, exc_info=True)
# Notify error
self._safe_call_event(
self.events.on_error,
{
"operation_id": operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"key": key, "folder": folder}
}
)
# Notify completion with failure
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": False,
"message": error_msg
}
)
# Return empty dict on error (scan failed but not critical)
return {}

View File

@ -4,10 +4,15 @@ SeriesApp - Core application logic for anime series management.
This module provides the main application interface for searching,
downloading, and managing anime series with support for async callbacks,
progress reporting, and error handling.
Note:
This module is pure domain logic with no database dependencies.
Database operations are handled by the service layer (AnimeService).
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from events import Events
@ -119,6 +124,10 @@ class SeriesApp:
- Managing series lists
Supports async callbacks for progress reporting.
Note:
This class is now pure domain logic with no database dependencies.
Database operations are handled by the service layer (AnimeService).
Events:
download_status: Raised when download status changes.
@ -140,15 +149,19 @@ class SeriesApp:
self.directory_to_search = directory_to_search
# Initialize thread pool executor
self.executor = ThreadPoolExecutor(max_workers=3)
# Initialize events
self._events = Events()
self._events.download_status = None
self._events.scan_status = None
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(directory_to_search, self.loader)
self.serie_scanner = SerieScanner(
directory_to_search, self.loader
)
self.list = SerieList(self.directory_to_search)
self.series_list: List[Any] = []
# Synchronous init used during constructor to avoid awaiting
# in __init__
self._init_list_sync()
@ -187,6 +200,26 @@ class SeriesApp:
def scan_status(self, value):
"""Set scan_status event handler."""
self._events.scan_status = value
def load_series_from_list(self, series: list) -> None:
"""
Load series into the in-memory list.
This method is called by the service layer after loading
series from the database.
Args:
series: List of Serie objects to load
"""
self.list.keyDict.clear()
for serie in series:
self.list.keyDict[serie.key] = serie
self.series_list = self.list.GetMissingEpisode()
logger.debug(
"Loaded %d series with %d having missing episodes",
len(series),
len(self.series_list)
)
def _init_list_sync(self) -> None:
"""Synchronous initialization helper for constructor."""
@ -198,7 +231,9 @@ class SeriesApp:
async def _init_list(self) -> None:
"""Initialize the series list with missing episodes (async)."""
self.series_list = await asyncio.to_thread(
loop = asyncio.get_running_loop()
self.series_list = await loop.run_in_executor(
self.executor,
self.list.GetMissingEpisode
)
logger.debug(
@ -220,7 +255,12 @@ class SeriesApp:
RuntimeError: If search fails
"""
logger.info("Searching for: %s", words)
results = await asyncio.to_thread(self.loader.search, words)
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
self.executor,
self.loader.search,
words
)
logger.info("Found %d results", len(results))
return results
@ -255,6 +295,7 @@ class SeriesApp:
lookups. The 'serie_folder' parameter is only used for
filesystem operations.
"""
logger.info(
"Starting download: %s (key: %s) S%02dE%02d",
serie_folder,
@ -277,9 +318,10 @@ class SeriesApp:
)
try:
def download_callback(progress_info):
def download_progress_handler(progress_info):
"""Handle download progress events from loader."""
logger.debug(
"wrapped_callback called with: %s", progress_info
"download_progress_handler called with: %s", progress_info
)
downloaded = progress_info.get('downloaded_bytes', 0)
@ -309,17 +351,28 @@ class SeriesApp:
item_id=item_id,
)
)
# Perform download in thread to avoid blocking event loop
download_success = await asyncio.to_thread(
self.loader.download,
self.directory_to_search,
serie_folder,
season,
episode,
key,
language,
download_callback
)
# Subscribe to loader's download progress events
self.loader.subscribe_download_progress(download_progress_handler)
try:
# Perform download in thread to avoid blocking event loop
loop = asyncio.get_running_loop()
download_success = await loop.run_in_executor(
self.executor,
self.loader.download,
self.directory_to_search,
serie_folder,
season,
episode,
key,
language
)
finally:
# Always unsubscribe after download completes or fails
self.loader.unsubscribe_download_progress(
download_progress_handler
)
if download_success:
logger.info(
@ -367,7 +420,30 @@ class SeriesApp:
return download_success
except Exception as e:
except InterruptedError:
# Download was cancelled - propagate the cancellation
logger.info(
"Download cancelled: %s (key: %s) S%02dE%02d",
serie_folder,
key,
season,
episode,
)
# Fire download cancelled event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="cancelled",
message="Download cancelled by user",
item_id=item_id,
)
)
raise # Re-raise to propagate cancellation
except Exception as e: # pylint: disable=broad-except
logger.error(
"Download error: %s (key: %s) S%02dE%02d - %s",
serie_folder,
@ -394,23 +470,40 @@ class SeriesApp:
return False
async def rescan(self) -> int:
async def rescan(self) -> list:
"""
Rescan directory for missing episodes (async).
This method performs a file-based scan and returns the results.
Database persistence is handled by the service layer (AnimeService).
Returns:
Number of series with missing episodes after rescan.
List of Serie objects found during scan with their
missing episodes.
Note:
This method no longer saves to database directly. The returned
list should be persisted by the caller (AnimeService).
"""
logger.info("Starting directory rescan")
total_to_scan = 0
try:
# Get total items to scan
total_to_scan = await asyncio.to_thread(
logger.info("Getting total items to scan...")
loop = asyncio.get_running_loop()
total_to_scan = await loop.run_in_executor(
self.executor,
self.serie_scanner.get_total_to_scan
)
logger.info("Total folders to scan: %d", total_to_scan)
# Fire scan started event
logger.info(
"Firing scan_status 'started' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=0,
@ -423,37 +516,60 @@ class SeriesApp:
)
# Reinitialize scanner
await asyncio.to_thread(self.serie_scanner.reinit)
def scan_callback(folder: str, current: int):
# Calculate progress
if total_to_scan > 0:
progress = current / total_to_scan
else:
progress = 0.0
await loop.run_in_executor(
self.executor,
self.serie_scanner.reinit
)
def scan_progress_handler(progress_data):
"""Handle scan progress events from scanner."""
# Fire scan progress event
message = progress_data.get('message', '')
folder = message.replace('Scanning: ', '')
self._events.scan_status(
ScanStatusEventArgs(
current=current,
total=total_to_scan,
current=progress_data.get('current', 0),
total=progress_data.get('total', total_to_scan),
folder=folder,
status="progress",
progress=progress,
message=f"Scanning: {folder}",
progress=(
progress_data.get('percentage', 0.0) / 100.0
),
message=message,
)
)
# Perform scan
await asyncio.to_thread(self.serie_scanner.scan, scan_callback)
# Subscribe to scanner's progress events
self.serie_scanner.subscribe_on_progress(scan_progress_handler)
try:
# Perform scan (file-based, returns results in scanner.keyDict)
await loop.run_in_executor(
self.executor,
self.serie_scanner.scan
)
finally:
# Always unsubscribe after scan completes or fails
self.serie_scanner.unsubscribe_on_progress(
scan_progress_handler
)
# Get scanned series from scanner
scanned_series = list(self.serie_scanner.keyDict.values())
# Reinitialize list
self.list = SerieList(self.directory_to_search)
await self._init_list()
# Update in-memory list with scan results
self.list.keyDict.clear()
for serie in scanned_series:
self.list.keyDict[serie.key] = serie
self.series_list = self.list.GetMissingEpisode()
logger.info("Directory rescan completed successfully")
# Fire scan completed event
logger.info(
"Firing scan_status 'completed' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=total_to_scan,
@ -468,7 +584,7 @@ class SeriesApp:
)
)
return len(self.series_list)
return scanned_series
except InterruptedError:
logger.warning("Scan cancelled by user")
@ -477,7 +593,7 @@ class SeriesApp:
self._events.scan_status(
ScanStatusEventArgs(
current=0,
total=total_to_scan if 'total_to_scan' in locals() else 0,
total=total_to_scan,
folder="",
status="cancelled",
message="Scan cancelled by user",
@ -492,7 +608,7 @@ class SeriesApp:
self._events.scan_status(
ScanStatusEventArgs(
current=0,
total=total_to_scan if 'total_to_scan' in locals() else 0,
total=total_to_scan,
folder="",
status="failed",
error=e,
@ -536,3 +652,66 @@ class SeriesApp:
looks up series by their unique key, not by folder name.
"""
return self.list.get_by_key(key)
def get_all_series_from_data_files(self) -> List[Serie]:
"""
Get all series from data files in the anime directory.
Scans the directory_to_search for all 'data' files and loads
the Serie metadata from each file. This method is synchronous
and can be wrapped with asyncio.to_thread if needed for async
contexts.
Returns:
List of Serie objects found in data files. Returns an empty
list if no data files are found or if the directory doesn't
exist.
Example:
series_app = SeriesApp("/path/to/anime")
all_series = series_app.get_all_series_from_data_files()
for serie in all_series:
print(f"Found: {serie.name} (key={serie.key})")
"""
logger.info(
"Scanning for data files in directory: %s",
self.directory_to_search
)
# Create a fresh SerieList instance for file-based loading
# This ensures we get all series from data files without
# interfering with the main instance's state
try:
temp_list = SerieList(
self.directory_to_search,
skip_load=False # Allow automatic loading
)
except (OSError, ValueError) as e:
logger.error(
"Failed to scan directory for data files: %s",
str(e),
exc_info=True
)
return []
# Get all series from the temporary list
all_series = temp_list.get_all()
logger.info(
"Found %d series from data files in %s",
len(all_series),
self.directory_to_search
)
return all_series
def shutdown(self) -> None:
"""
Shutdown the thread pool executor.
Should be called when the SeriesApp instance is no longer needed
to properly clean up resources.
"""
if hasattr(self, 'executor'):
self.executor.shutdown(wait=True)
logger.info("ThreadPoolExecutor shut down successfully")

View File

@ -1,4 +1,14 @@
"""Utilities for loading and managing stored anime series metadata."""
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata. It uses file-based storage only.
Note:
This module is part of the core domain layer and has no database
dependencies. All database operations are handled by the service layer.
"""
from __future__ import annotations
import logging
import os
@ -8,6 +18,8 @@ from typing import Dict, Iterable, List, Optional
from src.core.entities.series import Serie
logger = logging.getLogger(__name__)
class SerieList:
"""
@ -15,34 +27,84 @@ class SerieList:
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
This class manages in-memory series data loaded from filesystem.
It has no database dependencies - all persistence is handled by
the service layer.
Example:
# File-based mode
serie_list = SerieList("/path/to/anime")
series = serie_list.get_all()
Attributes:
directory: Path to the anime directory
keyDict: Internal dictionary mapping serie.key to Serie objects
"""
def __init__(self, base_path: str) -> None:
def __init__(
self,
base_path: str,
skip_load: bool = False
) -> None:
"""Initialize the SerieList.
Args:
base_path: Path to the anime directory
skip_load: If True, skip automatic loading of series from files.
Useful when planning to load from database instead.
"""
self.directory: str = base_path
# Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, Serie] = {}
self.load_series()
def add(self, serie: Serie) -> None:
"""
Persist a new series if it is not already present.
Uses serie.key for identification. The serie.folder is used for
filesystem operations only.
# Only auto-load from files if not skipping
if not skip_load:
self.load_series()
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
"""
Persist a new series if it is not already present (file-based mode).
Uses serie.key for identification. Creates the filesystem folder
using either the sanitized display name (default) or the existing
folder property.
Args:
serie: The Serie instance to add
use_sanitized_folder: If True (default), use serie.sanitized_folder
for the filesystem folder name based on display name.
If False, use serie.folder as-is for backward compatibility.
Returns:
str: The folder path that was created/used
Note:
This method creates data files on disk. For database storage,
use add_to_db() instead.
"""
if self.contains(serie.key):
return
# Return existing folder path
existing = self.keyDict[serie.key]
return os.path.join(self.directory, existing.folder)
data_path = os.path.join(self.directory, serie.folder, "data")
anime_path = os.path.join(self.directory, serie.folder)
# Determine folder name to use
if use_sanitized_folder:
folder_name = serie.sanitized_folder
# Update the serie's folder property to match what we create
serie.folder = folder_name
else:
folder_name = serie.folder
data_path = os.path.join(self.directory, folder_name, "data")
anime_path = os.path.join(self.directory, folder_name)
os.makedirs(anime_path, exist_ok=True)
if not os.path.isfile(data_path):
serie.save_to_file(data_path)
# Store by key, not folder
self.keyDict[serie.key] = serie
return anime_path
def contains(self, key: str) -> bool:
"""

View File

@ -1,4 +1,7 @@
import json
import warnings
from src.server.utils.filesystem import sanitize_folder_name
class Serie:
@ -126,6 +129,35 @@ class Serie:
def episodeDict(self, value: dict[int, list[int]]):
self._episodeDict = value
@property
def sanitized_folder(self) -> str:
"""
Get a filesystem-safe folder name derived from the display name.
This property returns a sanitized version of the series name
suitable for use as a filesystem folder name. It removes/replaces
characters that are invalid for filesystems while preserving
Unicode characters.
Use this property when creating folders for the series on disk.
The `folder` property stores the actual folder name used.
Returns:
str: Filesystem-safe folder name based on display name
Example:
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ...)
>>> serie.sanitized_folder
'Attack on Titan Final'
"""
# Use name if available, fall back to folder, then key
name_to_sanitize = self._name or self._folder or self._key
try:
return sanitize_folder_name(name_to_sanitize)
except ValueError:
# Fallback to key if name cannot be sanitized
return sanitize_folder_name(self._key)
def to_dict(self):
"""Convert Serie object to dictionary for JSON serialization."""
return {
@ -154,13 +186,46 @@ class Serie:
)
def save_to_file(self, filename: str):
"""Save Serie object to JSON file."""
"""Save Serie object to JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.create()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to save the JSON file
"""
warnings.warn(
"save_to_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService.create() instead.",
DeprecationWarning,
stacklevel=2
)
with open(filename, "w", encoding="utf-8") as file:
json.dump(self.to_dict(), file, indent=4)
@classmethod
def load_from_file(cls, filename: str) -> "Serie":
"""Load Serie object from JSON file."""
"""Load Serie object from JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.get_by_key()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to load the JSON file from
Returns:
Serie: The loaded Serie object
"""
warnings.warn(
"load_from_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService instead.",
DeprecationWarning,
stacklevel=2
)
with open(filename, "r", encoding="utf-8") as file:
data = json.load(file)
return cls.from_dict(data)

View File

@ -1,18 +1,22 @@
import html
import json
import logging
import os
import re
import shutil
import threading
from pathlib import Path
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup
from events import Events
from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadCancelled
from ..interfaces.providers import Providers
from .base_provider import Loader
@ -71,6 +75,9 @@ class AniworldLoader(Loader):
self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session()
# Cancellation flag for graceful shutdown
self._cancel_flag = threading.Event()
# Configure retries with backoff
retries = Retry(
total=5, # Number of retries
@ -91,6 +98,23 @@ class AniworldLoader(Loader):
self._EpisodeHTMLDict = {}
self.Providers = Providers()
# Events: download_progress is triggered with progress dict
self.events = Events()
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
self.events.download_progress += handler
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
self.events.download_progress -= handler
def clear_cache(self):
"""Clear the cached HTML data."""
logging.debug("Clearing HTML cache")
@ -196,7 +220,7 @@ class AniworldLoader(Loader):
is_available = language_code in languages
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}")
return is_available
return is_available
def download(
self,
@ -205,8 +229,7 @@ class AniworldLoader(Loader):
season: int,
episode: int,
key: str,
language: str = "German Dub",
progress_callback=None
language: str = "German Dub"
) -> bool:
"""Download episode to specified directory.
@ -219,8 +242,6 @@ class AniworldLoader(Loader):
key: Series unique identifier from provider (used for
identification and API calls)
language: Audio language preference (default: German Dub)
progress_callback: Optional callback for download progress
Returns:
bool: True if download succeeded, False otherwise
"""
@ -266,6 +287,16 @@ class AniworldLoader(Loader):
season, episode, key, language
)
logging.debug("Direct link obtained from provider")
cancel_flag = self._cancel_flag
def events_progress_hook(d):
if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook")
raise DownloadCancelled("Download cancelled by user")
# Fire the event for progress
self.events.download_progress(d)
ydl_opts = {
'fragment_retries': float('inf'),
'outtmpl': temp_path,
@ -273,30 +304,18 @@ class AniworldLoader(Loader):
'no_warnings': True,
'progress_with_newline': False,
'nocheckcertificate': True,
'progress_hooks': [events_progress_hook],
}
if header:
ydl_opts['http_headers'] = header
logging.debug("Using custom headers for download")
if progress_callback:
# Wrap the callback to add logging
def logged_progress_callback(d):
logging.debug(
f"YT-DLP progress: status={d.get('status')}, "
f"downloaded={d.get('downloaded_bytes')}, "
f"total={d.get('total_bytes')}, "
f"speed={d.get('speed')}"
)
progress_callback(d)
ydl_opts['progress_hooks'] = [logged_progress_callback]
logging.debug("Progress callback registered with YT-DLP")
try:
logging.debug("Starting YoutubeDL download")
logging.debug(f"Download link: {link[:100]}...")
logging.debug(f"YDL options: {ydl_opts}")
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(link, download=True)
logging.debug(
@ -325,17 +344,15 @@ class AniworldLoader(Loader):
f"Broken pipe error with provider {provider}: {e}. "
f"This usually means the stream connection was closed."
)
# Try next provider if available
continue
except Exception as e:
logging.error(
f"YoutubeDL download failed with provider {provider}: "
f"{type(e).__name__}: {e}"
)
# Try next provider if available
continue
break
# If we get here, all providers failed
logging.error("All download providers failed")
self.clear_cache()

View File

@ -1,9 +1,21 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Dict, List
class Loader(ABC):
"""Abstract base class for anime data loaders/providers."""
@abstractmethod
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
@abstractmethod
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
@abstractmethod
def search(self, word: str) -> List[Dict[str, Any]]:
@ -44,8 +56,7 @@ class Loader(ABC):
season: int,
episode: int,
key: str,
language: str = "German Dub",
progress_callback: Optional[Callable[[str, Dict], None]] = None,
language: str = "German Dub"
) -> bool:
"""Download episode to specified directory.
@ -56,8 +67,6 @@ class Loader(ABC):
episode: Episode number within season
key: Unique series identifier/key
language: Language version to download (default: German Dub)
progress_callback: Optional callback for progress updates
called with (event_type: str, data: Dict)
Returns:
True if download successful, False otherwise

View File

@ -229,37 +229,6 @@ class DatabaseIntegrityChecker:
logger.warning(msg)
issues_found += count
# Check for invalid progress percentages
stmt = select(DownloadQueueItem).where(
(DownloadQueueItem.progress < 0) |
(DownloadQueueItem.progress > 100)
)
invalid_progress = self.session.execute(stmt).scalars().all()
if invalid_progress:
count = len(invalid_progress)
msg = (
f"Found {count} queue items with invalid progress "
f"percentages"
)
self.issues.append(msg)
logger.warning(msg)
issues_found += count
# Check for queue items with invalid status
valid_statuses = {'pending', 'downloading', 'completed', 'failed'}
stmt = select(DownloadQueueItem).where(
~DownloadQueueItem.status.in_(valid_statuses)
)
invalid_status = self.session.execute(stmt).scalars().all()
if invalid_status:
count = len(invalid_status)
msg = f"Found {count} queue items with invalid status"
self.issues.append(msg)
logger.warning(msg)
issues_found += count
if issues_found == 0:
logger.info("No data consistency issues found")

View File

@ -1,17 +1,28 @@
import logging
import os
import warnings
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
NotFoundError,
ServerError,
ValidationError,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.utils.dependencies import (
get_anime_service,
get_optional_database_session,
get_series_app,
require_auth,
)
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
@ -52,9 +63,8 @@ async def get_anime_status(
"series_count": series_count
}
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get status: {str(exc)}",
raise ServerError(
message=f"Failed to get status: {str(exc)}"
) from exc
@ -73,6 +83,7 @@ class AnimeSummary(BaseModel):
site: Provider site URL
folder: Filesystem folder name (metadata only)
missing_episodes: Episode dictionary mapping seasons to episode numbers
has_missing: Boolean flag indicating if series has missing episodes
link: Optional link to the series page (used when adding new series)
"""
key: str = Field(
@ -95,6 +106,10 @@ class AnimeSummary(BaseModel):
...,
description="Episode dictionary: {season: [episode_numbers]}"
)
has_missing: bool = Field(
default=False,
description="Whether the series has any missing episodes"
)
link: Optional[str] = Field(
default="",
description="Link to the series page (for adding new series)"
@ -109,6 +124,7 @@ class AnimeSummary(BaseModel):
"site": "aniworld.to",
"folder": "beheneko the elf girls cat (2025)",
"missing_episodes": {"1": [1, 2, 3, 4]},
"has_missing": True,
"link": "https://aniworld.to/anime/stream/beheneko"
}
}
@ -173,11 +189,14 @@ async def list_anime(
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""List library series that still have missing episodes.
"""List all library series with their missing episodes status.
Returns AnimeSummary objects where `key` is the primary identifier
used for all operations. The `folder` field is metadata only and
should not be used for lookups.
All series are returned, with `has_missing` flag indicating whether
a series has any missing episodes.
Args:
page: Page number for pagination (must be positive)
@ -196,6 +215,7 @@ async def list_anime(
- site: Provider site
- folder: Filesystem folder name (metadata only)
- missing_episodes: Dict mapping seasons to episode numbers
- has_missing: Whether the series has any missing episodes
Raises:
HTTPException: When the underlying lookup fails or params invalid.
@ -205,35 +225,30 @@ async def list_anime(
try:
page_num = int(page)
if page_num < 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Page number must be positive"
raise ValidationError(
message="Page number must be positive"
)
page = page_num
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Page must be a valid number"
raise ValidationError(
message="Page must be a valid number"
)
if per_page is not None:
try:
per_page_num = int(per_page)
if per_page_num < 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page must be positive"
raise ValidationError(
message="Per page must be positive"
)
if per_page_num > 1000:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page cannot exceed 1000"
raise ValidationError(
message="Per page cannot exceed 1000"
)
per_page = per_page_num
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page must be a valid number"
raise ValidationError(
message="Per page must be a valid number"
)
# Validate sort_by parameter to prevent ORM injection
@ -242,9 +257,8 @@ async def list_anime(
allowed_sort_fields = ["title", "id", "missing_episodes", "name"]
if sort_by not in allowed_sort_fields:
allowed = ", ".join(allowed_sort_fields)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid sort_by parameter. Allowed: {allowed}"
raise ValidationError(
message=f"Invalid sort_by parameter. Allowed: {allowed}"
)
# Validate filter parameter
@ -257,17 +271,16 @@ async def list_anime(
lower_filter = filter.lower()
for pattern in dangerous_patterns:
if pattern in lower_filter:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid filter parameter"
raise ValidationError(
message="Invalid filter parameter"
)
try:
# Get missing episodes from series app
# Get all series from series app
if not hasattr(series_app, "list"):
return []
series = series_app.list.GetMissingEpisode()
series = series_app.list.GetList()
summaries: List[AnimeSummary] = []
for serie in series:
# Get all properties from the serie object
@ -280,6 +293,9 @@ async def list_anime(
# Convert episode dict keys to strings for JSON serialization
missing_episodes = {str(k): v for k, v in episode_dict.items()}
# Determine if series has missing episodes
has_missing = bool(episode_dict)
summaries.append(
AnimeSummary(
key=key,
@ -287,6 +303,7 @@ async def list_anime(
site=site,
folder=folder,
missing_episodes=missing_episodes,
has_missing=has_missing,
)
)
@ -307,12 +324,11 @@ async def list_anime(
)
return summaries
except HTTPException:
except (ValidationError, BadRequestError, NotFoundError, ServerError):
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve anime list",
raise ServerError(
message="Failed to retrieve anime list"
) from exc
@ -343,17 +359,40 @@ async def trigger_rescan(
"message": "Rescan started successfully",
}
except AnimeServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Rescan failed: {str(e)}",
raise ServerError(
message=f"Rescan failed: {str(e)}"
) from e
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start rescan",
raise ServerError(
message="Failed to start rescan"
) from exc
@router.get("/scan/status")
async def get_scan_status(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Get the current scan status.
Returns the current state of any ongoing library scan,
useful for restoring UI state after page reload.
Args:
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency.
Returns:
Dict[str, Any]: Current scan status including:
- is_scanning: Whether a scan is in progress
- total_items: Total items to scan
- directories_scanned: Items scanned so far
- current_directory: Current item being scanned
- directory: Root scan directory
"""
return anime_service.get_scan_status()
class AddSeriesRequest(BaseModel):
"""Request model for adding a new series."""
@ -582,13 +621,21 @@ async def add_series(
request: AddSeriesRequest,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
db: Optional[AsyncSession] = Depends(get_optional_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Add a new series to the library.
"""Add a new series to the library with full initialization.
Extracts the series `key` from the provided link URL.
This endpoint performs the complete series addition flow:
1. Validates inputs and extracts the series key from the link URL
2. Creates a sanitized folder name from the display name
3. Saves the series to the database (if available)
4. Creates the folder on disk with the sanitized name
5. Triggers a targeted scan for missing episodes (only this series)
The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata along with a
filesystem-friendly `folder` name derived from the name.
The `name` is stored as display metadata and used to derive
the filesystem folder name (sanitized for filesystem safety).
Args:
request: Request containing the series link and name.
@ -596,15 +643,24 @@ async def add_series(
- name: Display name for the series
_auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance provided via dependency
db: Optional database session for async operations
anime_service: AnimeService for scanning operations
Returns:
Dict[str, Any]: Status payload with success message and key
Dict[str, Any]: Status payload with:
- status: "success" or "exists"
- message: Human-readable status message
- key: Series unique identifier
- folder: Created folder path
- db_id: Database ID (if saved to DB)
- missing_episodes: Dict of missing episodes by season
- total_missing: Total count of missing episodes
Raises:
HTTPException: If adding the series fails or link is invalid
"""
try:
# Validate inputs
# Step A: Validate inputs
if not request.link or not request.link.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -617,13 +673,6 @@ async def add_series(
detail="Series name cannot be empty",
)
# Check if series_app has the list attribute
if not hasattr(series_app, "list"):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Series list functionality not available",
)
# Extract key from link URL
# Expected format: https://aniworld.to/anime/stream/{key}
link = request.link.strip()
@ -644,38 +693,150 @@ async def add_series(
detail="Could not extract series key from link",
)
# Create folder from name (filesystem-friendly)
folder = request.name.strip()
# Step B: Create sanitized folder name from display name
name = request.name.strip()
try:
folder = sanitize_folder_name(name)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid series name for folder: {str(e)}",
)
# Create a new Serie object
# key: unique identifier extracted from link
# name: display name from request
# folder: filesystem folder name (derived from name)
# episodeDict: empty for new series
serie = Serie(
key=key,
name=request.name.strip(),
site="aniworld.to",
folder=folder,
episodeDict={}
)
db_id = None
missing_episodes: dict = {}
scan_error: Optional[str] = None
# Add the series to the list
series_app.list.add(serie)
# Step C: Save to database if available
if db is not None:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
if existing:
return {
"status": "exists",
"message": f"Series already exists: {name}",
"key": key,
"folder": existing.folder,
"db_id": existing.id,
"missing_episodes": {},
"total_missing": 0
}
# Save to database using AnimeSeriesService
anime_series = await AnimeSeriesService.create(
db=db,
key=key,
name=name,
site="aniworld.to",
folder=folder,
)
db_id = anime_series.id
logger.info(
"Added series to database: %s (key=%s, db_id=%d)",
name,
key,
db_id
)
# Refresh the series list to update the cache
if hasattr(series_app, "refresh_series_list"):
series_app.refresh_series_list()
# Step D: Create folder on disk and add to SerieList
folder_path = None
if series_app and hasattr(series_app, "list"):
serie = Serie(
key=key,
name=name,
site="aniworld.to",
folder=folder,
episodeDict={}
)
# Add to SerieList - this creates the folder with sanitized name
if hasattr(series_app.list, 'add'):
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
folder_path = series_app.list.add(serie, use_sanitized_folder=True)
# Update folder to reflect what was actually created
folder = serie.folder
elif hasattr(series_app.list, 'keyDict'):
# Manual folder creation and cache update
if hasattr(series_app.list, 'directory'):
folder_path = os.path.join(series_app.list.directory, folder)
os.makedirs(folder_path, exist_ok=True)
series_app.list.keyDict[key] = serie
logger.info(
"Created folder for series: %s at %s",
name,
folder_path or folder
)
return {
"status": "success",
"message": f"Successfully added series: {request.name}",
"key": key,
"folder": folder
# Step E: Trigger targeted scan for missing episodes
try:
if series_app and hasattr(series_app, "scanner"):
missing_episodes = series_app.scanner.scan_single_series(
key=key,
folder=folder
)
logger.info(
"Targeted scan completed for %s: found %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
# Update the serie in keyDict with the missing episodes
if hasattr(series_app, "list") and hasattr(series_app.list, "keyDict"):
if key in series_app.list.keyDict:
series_app.list.keyDict[key].episodeDict = missing_episodes
elif anime_service:
# Fallback to anime_service if scanner not directly available
# Note: This is a lightweight scan, not a full rescan
logger.info(
"Scanner not directly available, "
"skipping targeted scan for %s",
key
)
except Exception as e:
# Scan failure is not critical - series was still added
scan_error = str(e)
logger.warning(
"Targeted scan failed for %s: %s (series still added)",
key,
e
)
# Convert missing episodes keys to strings for JSON serialization
missing_episodes_serializable = {
str(season): episodes
for season, episodes in missing_episodes.items()
}
# Calculate total missing
total_missing = sum(len(eps) for eps in missing_episodes.values())
# Step F: Return response
response = {
"status": "success",
"message": f"Successfully added series: {name}",
"key": key,
"folder": folder_path or folder,
"db_id": db_id,
"missing_episodes": missing_episodes_serializable,
"total_missing": total_missing
}
if scan_error:
response["scan_warning"] = f"Scan partially failed: {scan_error}"
return response
except HTTPException:
raise
except Exception as exc:
logger.error("Failed to add series: %s", exc, exc_info=True)
# Attempt to rollback database entry if folder creation failed
# (This is a best-effort cleanup)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add series: {str(exc)}",

View File

@ -26,7 +26,7 @@ optional_bearer = HTTPBearer(auto_error=False)
@router.post("/setup", status_code=http_status.HTTP_201_CREATED)
def setup_auth(req: SetupRequest):
async def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.
This endpoint also initializes the configuration with default values
@ -57,17 +57,44 @@ def setup_auth(req: SetupRequest):
config.other['master_password_hash'] = password_hash
# Store anime directory in config's other field if provided
anime_directory = None
if hasattr(req, 'anime_directory') and req.anime_directory:
config.other['anime_directory'] = req.anime_directory
anime_directory = req.anime_directory.strip()
if anime_directory:
config.other['anime_directory'] = anime_directory
# Save the config with the password hash and anime directory
config_service.save_config(config, create_backup=False)
# Sync series from data files to database if anime directory is set
if anime_directory:
try:
import structlog
from src.server.services.anime_service import (
sync_series_from_data_files,
)
logger = structlog.get_logger(__name__)
sync_count = await sync_series_from_data_files(
anime_directory, logger
)
logger.info(
"Setup complete: synced series from data files",
count=sync_count
)
except Exception as e:
# Log but don't fail setup if sync fails
import structlog
structlog.get_logger(__name__).warning(
"Failed to sync series after setup",
error=str(e)
)
return {"status": "ok"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"status": "ok"}
@router.post("/login", response_model=LoginResponse)
def login(req: LoginRequest):

View File

@ -1,4 +1,4 @@
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
@ -210,10 +210,10 @@ def update_advanced_config(
) from e
@router.post("/directory", response_model=Dict[str, str])
def update_directory(
@router.post("/directory", response_model=Dict[str, Any])
async def update_directory(
directory_config: Dict[str, str], auth: dict = Depends(require_auth)
) -> Dict[str, str]:
) -> Dict[str, Any]:
"""Update anime directory configuration.
Args:
@ -235,13 +235,37 @@ def update_directory(
app_config = config_service.load_config()
# Store directory in other section
if "anime_directory" not in app_config.other:
app_config.other["anime_directory"] = directory
else:
app_config.other["anime_directory"] = directory
app_config.other["anime_directory"] = directory
config_service.save_config(app_config)
return {"message": "Anime directory updated successfully"}
# Sync series from data files to database
sync_count = 0
try:
import structlog
from src.server.services.anime_service import sync_series_from_data_files
logger = structlog.get_logger(__name__)
sync_count = await sync_series_from_data_files(directory, logger)
logger.info(
"Directory updated: synced series from data files",
directory=directory,
count=sync_count
)
except Exception as e:
# Log but don't fail the directory update if sync fails
import structlog
structlog.get_logger(__name__).warning(
"Failed to sync series after directory update",
error=str(e)
)
response: Dict[str, Any] = {
"message": "Anime directory updated successfully",
"synced_series": sync_count
}
return response
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@ -4,9 +4,10 @@ This module provides REST API endpoints for managing the anime download queue,
including adding episodes, removing items, controlling queue processing, and
retrieving queue status and statistics.
"""
from fastapi import APIRouter, Depends, HTTPException, Path, status
from fastapi import APIRouter, Depends, Path, status
from fastapi.responses import JSONResponse
from src.server.exceptions import BadRequestError, NotFoundError, ServerError
from src.server.models.download import (
DownloadRequest,
QueueOperationRequest,
@ -52,9 +53,8 @@ async def get_queue_status(
return response
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve queue status: {str(e)}",
raise ServerError(
message=f"Failed to retrieve queue status: {str(e)}"
)
@ -91,9 +91,8 @@ async def add_to_queue(
try:
# Validate request
if not request.episodes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one episode must be specified",
raise BadRequestError(
message="At least one episode must be specified"
)
# Add to queue
@ -122,16 +121,12 @@ async def add_to_queue(
)
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add episodes to queue: {str(e)}",
raise ServerError(
message=f"Failed to add episodes to queue: {str(e)}"
)
@ -163,9 +158,8 @@ async def clear_completed(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear completed items: {str(e)}",
raise ServerError(
message=f"Failed to clear completed items: {str(e)}"
)
@ -197,9 +191,8 @@ async def clear_failed(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear failed items: {str(e)}",
raise ServerError(
message=f"Failed to clear failed items: {str(e)}"
)
@ -231,9 +224,8 @@ async def clear_pending(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear pending items: {str(e)}",
raise ServerError(
message=f"Failed to clear pending items: {str(e)}"
)
@ -262,22 +254,19 @@ async def remove_from_queue(
removed_ids = await download_service.remove_from_queue([item_id])
if not removed_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Download item {item_id} not found in queue",
raise NotFoundError(
message=f"Download item {item_id} not found in queue",
resource_type="download_item",
resource_id=item_id
)
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove item from queue: {str(e)}",
raise ServerError(
message=f"Failed to remove item from queue: {str(e)}"
)
@ -307,22 +296,18 @@ async def remove_multiple_from_queue(
)
if not removed_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No matching items found in queue",
raise NotFoundError(
message="No matching items found in queue",
resource_type="download_items"
)
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove items from queue: {str(e)}",
raise ServerError(
message=f"Failed to remove items from queue: {str(e)}"
)
@ -354,9 +339,8 @@ async def start_queue(
result = await download_service.start_queue_processing()
if result is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No pending downloads in queue",
raise BadRequestError(
message="No pending downloads in queue"
)
return {
@ -365,16 +349,12 @@ async def start_queue(
}
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start queue processing: {str(e)}",
raise ServerError(
message=f"Failed to start queue processing: {str(e)}"
)
@ -408,9 +388,8 @@ async def stop_queue(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stop queue processing: {str(e)}",
raise ServerError(
message=f"Failed to stop queue processing: {str(e)}"
)
@ -442,9 +421,8 @@ async def pause_queue(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to pause queue processing: {str(e)}",
raise ServerError(
message=f"Failed to pause queue processing: {str(e)}"
)
@ -480,9 +458,8 @@ async def reorder_queue(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reorder queue: {str(e)}",
raise ServerError(
message=f"Failed to reorder queue: {str(e)}"
)
@ -522,7 +499,6 @@ async def retry_failed(
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retry downloads: {str(e)}",
raise ServerError(
message=f"Failed to retry downloads: {str(e)}"
)

View File

@ -23,6 +23,9 @@ class HealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
class DatabaseHealth(BaseModel):
@ -170,14 +173,24 @@ def get_system_metrics() -> SystemMetrics:
@router.get("", response_model=HealthStatus)
async def basic_health_check() -> HealthStatus:
"""Basic health check endpoint.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
Includes service information for identification.
Returns:
HealthStatus: Simple health status with timestamp.
HealthStatus: Simple health status with timestamp and service info.
"""
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
logger.debug("Basic health check requested")
return HealthStatus(
status="healthy",
timestamp=datetime.now().isoformat(),
service="aniworld-api",
series_app_initialized=_series_app is not None,
anime_directory_configured=bool(settings.anime_directory),
)

View File

@ -13,8 +13,9 @@ in their data payload. The `folder` field is optional for display purposes.
"""
from __future__ import annotations
import time
import uuid
from typing import Optional
from typing import Dict, Optional, Set
import structlog
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, status
@ -34,6 +35,73 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/ws", tags=["websocket"])
# Valid room names - explicit allow-list for security
VALID_ROOMS: Set[str] = {
"downloads", # Download progress updates
"queue", # Queue status changes
"scan", # Scan progress updates
"system", # System notifications
"errors", # Error notifications
}
# Rate limiting configuration for WebSocket messages
WS_RATE_LIMIT_MESSAGES_PER_MINUTE = 60
WS_RATE_LIMIT_WINDOW_SECONDS = 60
# In-memory rate limiting for WebSocket connections
# WARNING: This resets on process restart. For production, consider Redis.
_ws_rate_limits: Dict[str, Dict[str, float]] = {}
def _check_ws_rate_limit(connection_id: str) -> bool:
"""Check if a WebSocket connection has exceeded its rate limit.
Args:
connection_id: Unique identifier for the WebSocket connection
Returns:
bool: True if within rate limit, False if exceeded
"""
now = time.time()
if connection_id not in _ws_rate_limits:
_ws_rate_limits[connection_id] = {
"count": 0,
"window_start": now,
}
record = _ws_rate_limits[connection_id]
# Reset window if expired
if now - record["window_start"] > WS_RATE_LIMIT_WINDOW_SECONDS:
record["window_start"] = now
record["count"] = 0
record["count"] += 1
return record["count"] <= WS_RATE_LIMIT_MESSAGES_PER_MINUTE
def _cleanup_ws_rate_limits(connection_id: str) -> None:
"""Remove rate limit record for a disconnected connection.
Args:
connection_id: Unique identifier for the WebSocket connection
"""
_ws_rate_limits.pop(connection_id, None)
def _validate_room_name(room: str) -> bool:
"""Validate that a room name is in the allowed set.
Args:
room: Room name to validate
Returns:
bool: True if room is valid, False otherwise
"""
return room in VALID_ROOMS
@router.websocket("/connect")
async def websocket_endpoint(
@ -130,6 +198,19 @@ async def websocket_endpoint(
# Receive message from client
data = await websocket.receive_json()
# Check rate limit
if not _check_ws_rate_limit(connection_id):
logger.warning(
"WebSocket rate limit exceeded",
connection_id=connection_id,
)
await ws_service.send_error(
connection_id,
"Rate limit exceeded. Please slow down.",
"RATE_LIMIT_EXCEEDED",
)
continue
# Parse client message
try:
client_msg = ClientMessage(**data)
@ -149,9 +230,26 @@ async def websocket_endpoint(
# Handle room subscription requests
if client_msg.action in ["join", "leave"]:
try:
room_name = client_msg.data.get("room", "")
# Validate room name against allow-list
if not _validate_room_name(room_name):
logger.warning(
"Invalid room name requested",
connection_id=connection_id,
room=room_name,
)
await ws_service.send_error(
connection_id,
f"Invalid room name: {room_name}. "
f"Valid rooms: {', '.join(sorted(VALID_ROOMS))}",
"INVALID_ROOM",
)
continue
room_req = RoomSubscriptionRequest(
action=client_msg.action,
room=client_msg.data.get("room", ""),
room=room_name,
)
if room_req.action == "join":
@ -241,7 +339,8 @@ async def websocket_endpoint(
error=str(e),
)
finally:
# Cleanup connection
# Cleanup connection and rate limit record
_cleanup_ws_rate_limits(connection_id)
await ws_service.disconnect(connection_id)
logger.info("WebSocket connection closed", connection_id=connection_id)
@ -263,5 +362,6 @@ async def websocket_status(
"status": "operational",
"active_connections": connection_count,
"supported_message_types": [t.value for t in WebSocketMessageType],
"valid_rooms": sorted(VALID_ROOMS),
},
)

View File

@ -8,7 +8,7 @@ Environment Variables:
JWT_SECRET_KEY: Secret key for JWT token signing (default: dev-secret)
PASSWORD_SALT: Salt for password hashing (default: dev-salt)
DATABASE_URL: Development database connection string (default: SQLite)
LOG_LEVEL: Logging level (default: DEBUG)
LOG_LEVEL: Logging level (default: INFO)
CORS_ORIGINS: Comma-separated list of allowed CORS origins
API_RATE_LIMIT: API rate limit per minute (default: 1000)
"""
@ -91,8 +91,8 @@ class DevelopmentSettings(BaseSettings):
# Logging Settings
# ============================================================================
log_level: str = Field(default="DEBUG", env="LOG_LEVEL")
"""Logging level (DEBUG for detailed output)."""
log_level: str = Field(default="INFO", env="LOG_LEVEL")
"""Logging level (INFO for standard output)."""
log_file: str = Field(default="logs/development.log", env="LOG_FILE")
"""Path to development log file."""

View File

@ -60,7 +60,7 @@ def setup_logging() -> Dict[str, logging.Logger]:
# File handler for general server logs
server_file_handler = logging.FileHandler(server_log_file, mode='a', encoding='utf-8')
server_file_handler.setLevel(logging.DEBUG)
server_file_handler.setLevel(logging.INFO)
server_file_handler.setFormatter(detailed_format)
root_logger.addHandler(server_file_handler)

View File

@ -1,27 +0,0 @@
"""
Health check controller for monitoring and status endpoints.
This module provides health check endpoints for application monitoring.
"""
from fastapi import APIRouter
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health_check():
"""Health check endpoint for monitoring.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
"""
return {
"status": "healthy",
"service": "aniworld-api",
"version": "1.0.0",
"series_app_initialized": _series_app is not None,
"anime_directory_configured": bool(settings.anime_directory)
}

View File

@ -13,7 +13,7 @@ This package provides persistent storage for anime series, episodes, download qu
Install required dependencies:
```bash
pip install sqlalchemy alembic aiosqlite
pip install sqlalchemy aiosqlite
```
Or use the project requirements:
@ -163,24 +163,6 @@ from src.config.settings import settings
settings.database_url = "sqlite:///./data/aniworld.db"
```
## Migrations (Future)
Alembic is installed for database migrations:
```bash
# Initialize Alembic
alembic init alembic
# Generate migration
alembic revision --autogenerate -m "Description"
# Apply migrations
alembic upgrade head
# Rollback
alembic downgrade -1
```
## Testing
Run database tests:
@ -196,8 +178,7 @@ The test suite uses an in-memory SQLite database for isolation and speed.
- **base.py**: Base declarative class and mixins
- **models.py**: SQLAlchemy ORM models (4 models)
- **connection.py**: Engine, session factory, dependency injection
- **migrations.py**: Alembic migration placeholder
- ****init**.py**: Package exports
- \***\*init**.py\*\*: Package exports
- **service.py**: Service layer with CRUD operations
## Service Layer
@ -432,5 +413,4 @@ Solution: Ensure referenced records exist before creating relationships.
## Further Reading
- [SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/)
- [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
- [FastAPI with Databases](https://fastapi.tiangolo.com/tutorial/sql-databases/)

View File

@ -30,7 +30,6 @@ from src.server.database.init import (
create_database_backup,
create_database_schema,
get_database_info,
get_migration_guide,
get_schema_version,
initialize_database,
seed_initial_data,
@ -64,7 +63,6 @@ __all__ = [
"check_database_health",
"create_database_backup",
"get_database_info",
"get_migration_guide",
"CURRENT_SCHEMA_VERSION",
"EXPECTED_TABLES",
# Models

View File

@ -7,7 +7,11 @@ Functions:
- init_db: Initialize database engine and create tables
- close_db: Close database connections and cleanup
- get_db_session: FastAPI dependency for database sessions
- get_transactional_session: Session without auto-commit for transactions
- get_engine: Get database engine instance
Classes:
- TransactionManager: Helper class for manual transaction control
"""
from __future__ import annotations
@ -86,19 +90,24 @@ async def init_db() -> None:
db_url = _get_database_url()
logger.info(f"Initializing database: {db_url}")
# Build engine kwargs based on database type
is_sqlite = "sqlite" in db_url
engine_kwargs = {
"echo": settings.log_level == "DEBUG",
"poolclass": pool.StaticPool if is_sqlite else pool.QueuePool,
"pool_pre_ping": True,
}
# Only add pool_size and max_overflow for non-SQLite databases
if not is_sqlite:
engine_kwargs["pool_size"] = 5
engine_kwargs["max_overflow"] = 10
# Create async engine
_engine = create_async_engine(
db_url,
echo=settings.log_level == "DEBUG",
poolclass=pool.StaticPool if "sqlite" in db_url else pool.QueuePool,
pool_size=5 if "sqlite" not in db_url else None,
max_overflow=10 if "sqlite" not in db_url else None,
pool_pre_ping=True,
future=True,
)
_engine = create_async_engine(db_url, **engine_kwargs)
# Configure SQLite if needed
if "sqlite" in db_url:
if is_sqlite:
_configure_sqlite_engine(_engine)
# Create async session factory
@ -112,12 +121,13 @@ async def init_db() -> None:
# Create sync engine for initial setup
sync_url = settings.database_url
_sync_engine = create_engine(
sync_url,
echo=settings.log_level == "DEBUG",
poolclass=pool.StaticPool if "sqlite" in sync_url else pool.QueuePool,
pool_pre_ping=True,
)
is_sqlite_sync = "sqlite" in sync_url
sync_engine_kwargs = {
"echo": settings.log_level == "DEBUG",
"poolclass": pool.StaticPool if is_sqlite_sync else pool.QueuePool,
"pool_pre_ping": True,
}
_sync_engine = create_engine(sync_url, **sync_engine_kwargs)
# Create sync session factory
_sync_session_factory = sessionmaker(
@ -140,11 +150,29 @@ async def init_db() -> None:
async def close_db() -> None:
"""Close database connections and cleanup resources.
Performs a WAL checkpoint for SQLite databases to ensure all
pending writes are flushed to the main database file before
closing connections. This prevents database corruption during
shutdown.
Should be called during application shutdown.
"""
global _engine, _sync_engine, _session_factory, _sync_session_factory
try:
# For SQLite: checkpoint WAL to ensure all writes are flushed
if _sync_engine and "sqlite" in str(_sync_engine.url):
logger.info("Running SQLite WAL checkpoint before shutdown...")
try:
from sqlalchemy import text
with _sync_engine.connect() as conn:
# TRUNCATE mode: checkpoint and truncate WAL file
conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
conn.commit()
logger.info("SQLite WAL checkpoint completed")
except Exception as e:
logger.warning(f"WAL checkpoint failed (non-critical): {e}")
if _engine:
logger.info("Closing async database engine...")
await _engine.dispose()
@ -258,3 +286,307 @@ def get_sync_session() -> Session:
)
return _sync_session_factory()
def get_async_session_factory() -> AsyncSession:
"""Get a new async database session (factory function).
Creates a new session instance for use in repository patterns.
The caller is responsible for committing/rolling back and closing.
Returns:
AsyncSession: New database session for async operations
Raises:
RuntimeError: If database is not initialized
Example:
session = get_async_session_factory()
try:
result = await session.execute(select(AnimeSeries))
await session.commit()
return result.scalars().all()
except Exception:
await session.rollback()
raise
finally:
await session.close()
"""
if _session_factory is None:
raise RuntimeError(
"Database not initialized. Call init_db() first."
)
return _session_factory()
@asynccontextmanager
async def get_transactional_session() -> AsyncGenerator[AsyncSession, None]:
"""Get a database session without auto-commit for explicit transaction control.
Unlike get_db_session(), this does NOT auto-commit on success.
Use this when you need explicit transaction control with the
@transactional decorator or atomic() context manager.
Yields:
AsyncSession: Database session for async operations
Raises:
RuntimeError: If database is not initialized
Example:
async with get_transactional_session() as session:
async with atomic(session) as tx:
# Multiple operations in transaction
await operation1(session)
await operation2(session)
# Committed when exiting atomic() context
"""
if _session_factory is None:
raise RuntimeError(
"Database not initialized. Call init_db() first."
)
session = _session_factory()
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
class TransactionManager:
"""Helper class for manual transaction control.
Provides a cleaner interface for managing transactions across
multiple service calls within a single request.
Attributes:
_session_factory: Factory for creating new sessions
_session: Current active session
_in_transaction: Whether currently in a transaction
Example:
async with TransactionManager() as tm:
session = await tm.get_session()
await tm.begin()
try:
await service1.operation(session)
await service2.operation(session)
await tm.commit()
except Exception:
await tm.rollback()
raise
"""
def __init__(
self,
session_factory: Optional[async_sessionmaker] = None
) -> None:
"""Initialize transaction manager.
Args:
session_factory: Optional custom session factory.
Uses global factory if not provided.
"""
self._session_factory = session_factory or _session_factory
self._session: Optional[AsyncSession] = None
self._in_transaction = False
if self._session_factory is None:
raise RuntimeError(
"Database not initialized. Call init_db() first."
)
async def __aenter__(self) -> "TransactionManager":
"""Enter context manager and create session."""
self._session = self._session_factory()
logger.debug("TransactionManager: Created new session")
return self
async def __aexit__(
self,
exc_type: Optional[type],
exc_val: Optional[BaseException],
exc_tb: Optional[object],
) -> bool:
"""Exit context manager and cleanup session.
Automatically rolls back if an exception occurred and
transaction wasn't explicitly committed.
"""
if self._session:
if exc_type is not None and self._in_transaction:
logger.warning(
"TransactionManager: Rolling back due to exception: %s",
exc_val,
)
await self._session.rollback()
await self._session.close()
self._session = None
self._in_transaction = False
logger.debug("TransactionManager: Session closed")
return False
async def get_session(self) -> AsyncSession:
"""Get the current session.
Returns:
Current AsyncSession instance
Raises:
RuntimeError: If not within context manager
"""
if self._session is None:
raise RuntimeError(
"TransactionManager must be used as async context manager"
)
return self._session
async def begin(self) -> None:
"""Begin a new transaction.
Raises:
RuntimeError: If already in a transaction or no session
"""
if self._session is None:
raise RuntimeError("No active session")
if self._in_transaction:
raise RuntimeError("Already in a transaction")
await self._session.begin()
self._in_transaction = True
logger.debug("TransactionManager: Transaction started")
async def commit(self) -> None:
"""Commit the current transaction.
Raises:
RuntimeError: If not in a transaction
"""
if not self._in_transaction or self._session is None:
raise RuntimeError("Not in a transaction")
await self._session.commit()
self._in_transaction = False
logger.debug("TransactionManager: Transaction committed")
async def rollback(self) -> None:
"""Rollback the current transaction.
Raises:
RuntimeError: If not in a transaction
"""
if self._session is None:
raise RuntimeError("No active session")
await self._session.rollback()
self._in_transaction = False
logger.debug("TransactionManager: Transaction rolled back")
async def savepoint(self, name: Optional[str] = None) -> "SavepointHandle":
"""Create a savepoint within the current transaction.
Args:
name: Optional savepoint name
Returns:
SavepointHandle for controlling the savepoint
Raises:
RuntimeError: If not in a transaction
"""
if not self._in_transaction or self._session is None:
raise RuntimeError("Must be in a transaction to create savepoint")
nested = await self._session.begin_nested()
return SavepointHandle(nested, name or "unnamed")
def is_in_transaction(self) -> bool:
"""Check if currently in a transaction.
Returns:
True if in an active transaction
"""
return self._in_transaction
def get_transaction_depth(self) -> int:
"""Get current transaction nesting depth.
Returns:
0 if not in transaction, 1+ for nested transactions
"""
if not self._in_transaction:
return 0
return 1 # Basic implementation - could be extended
class SavepointHandle:
"""Handle for controlling a database savepoint.
Attributes:
_nested: SQLAlchemy nested transaction
_name: Savepoint name for logging
_released: Whether savepoint has been released
"""
def __init__(self, nested: object, name: str) -> None:
"""Initialize savepoint handle.
Args:
nested: SQLAlchemy nested transaction object
name: Savepoint name
"""
self._nested = nested
self._name = name
self._released = False
logger.debug("Created savepoint: %s", name)
async def rollback(self) -> None:
"""Rollback to this savepoint."""
if not self._released:
await self._nested.rollback()
self._released = True
logger.debug("Rolled back savepoint: %s", self._name)
async def release(self) -> None:
"""Release (commit) this savepoint."""
if not self._released:
# Nested transactions commit automatically in SQLAlchemy
self._released = True
logger.debug("Released savepoint: %s", self._name)
def is_session_in_transaction(session: AsyncSession | Session) -> bool:
"""Check if a session is currently in a transaction.
Args:
session: SQLAlchemy session (sync or async)
Returns:
True if session is in an active transaction
"""
return session.in_transaction()
def get_session_transaction_depth(session: AsyncSession | Session) -> int:
"""Get the transaction nesting depth of a session.
Args:
session: SQLAlchemy session (sync or async)
Returns:
Number of nested transactions (0 if not in transaction)
"""
if not session.in_transaction():
return 0
# Check for nested transaction state
# Note: SQLAlchemy doesn't directly expose nesting depth
return 1

View File

@ -1,479 +0,0 @@
"""Example integration of database service with existing services.
This file demonstrates how to integrate the database service layer with
existing application services like AnimeService and DownloadService.
These examples show patterns for:
- Persisting scan results to database
- Loading queue from database on startup
- Syncing download progress to database
- Maintaining consistency between in-memory state and database
"""
from __future__ import annotations
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.server.database.models import DownloadPriority, DownloadStatus
from src.server.database.service import (
AnimeSeriesService,
DownloadQueueService,
EpisodeService,
)
logger = logging.getLogger(__name__)
# ============================================================================
# Example 1: Persist Scan Results
# ============================================================================
async def persist_scan_results(
db: AsyncSession,
series_list: List[Serie],
) -> None:
"""Persist scan results to database.
Updates or creates anime series and their episodes based on
scan results from SerieScanner.
Args:
db: Database session
series_list: List of Serie objects from scan
"""
logger.info(f"Persisting {len(series_list)} series to database")
for serie in series_list:
# Check if series exists
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
# Update existing series
await AnimeSeriesService.update(
db,
existing.id,
name=serie.name,
site=serie.site,
folder=serie.folder,
episode_dict=serie.episode_dict,
)
series_id = existing.id
else:
# Create new series
new_series = await AnimeSeriesService.create(
db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
episode_dict=serie.episode_dict,
)
series_id = new_series.id
# Update episodes for this series
await _update_episodes(db, series_id, serie)
await db.commit()
logger.info("Scan results persisted successfully")
async def _update_episodes(
db: AsyncSession,
series_id: int,
serie: Serie,
) -> None:
"""Update episodes for a series.
Args:
db: Database session
series_id: Series ID in database
serie: Serie object with episode information
"""
# Get existing episodes
existing_episodes = await EpisodeService.get_by_series(db, series_id)
existing_map = {
(ep.season, ep.episode_number): ep
for ep in existing_episodes
}
# Iterate through episode_dict to create/update episodes
for season, episodes in serie.episode_dict.items():
for ep_num in episodes:
key = (int(season), int(ep_num))
if key in existing_map:
# Episode exists, check if downloaded
episode = existing_map[key]
# Update if needed (e.g., file path changed)
if not episode.is_downloaded:
# Check if file exists locally
# This would be done by checking serie.local_episodes
pass
else:
# Create new episode
await EpisodeService.create(
db,
series_id=series_id,
season=int(season),
episode_number=int(ep_num),
is_downloaded=False,
)
# ============================================================================
# Example 2: Load Queue from Database
# ============================================================================
async def load_queue_from_database(
db: AsyncSession,
) -> List[dict]:
"""Load download queue from database.
Retrieves pending and active download items from database and
converts them to format suitable for DownloadService.
Args:
db: Database session
Returns:
List of download items as dictionaries
"""
logger.info("Loading download queue from database")
# Get pending and active items
pending = await DownloadQueueService.get_pending(db)
active = await DownloadQueueService.get_active(db)
all_items = pending + active
# Convert to dictionary format for DownloadService
queue_items = []
for item in all_items:
queue_items.append({
"id": item.id,
"series_id": item.series_id,
"season": item.season,
"episode_number": item.episode_number,
"status": item.status.value,
"priority": item.priority.value,
"progress_percent": item.progress_percent,
"downloaded_bytes": item.downloaded_bytes,
"total_bytes": item.total_bytes,
"download_speed": item.download_speed,
"error_message": item.error_message,
"retry_count": item.retry_count,
})
logger.info(f"Loaded {len(queue_items)} items from database")
return queue_items
# ============================================================================
# Example 3: Sync Download Progress to Database
# ============================================================================
async def sync_download_progress(
db: AsyncSession,
item_id: int,
progress_percent: float,
downloaded_bytes: int,
total_bytes: Optional[int] = None,
download_speed: Optional[float] = None,
) -> None:
"""Sync download progress to database.
Updates download queue item progress in database. This would be called
from the download progress callback.
Args:
db: Database session
item_id: Download queue item ID
progress_percent: Progress percentage (0-100)
downloaded_bytes: Bytes downloaded
total_bytes: Optional total file size
download_speed: Optional current speed (bytes/sec)
"""
await DownloadQueueService.update_progress(
db,
item_id,
progress_percent,
downloaded_bytes,
total_bytes,
download_speed,
)
await db.commit()
async def mark_download_complete(
db: AsyncSession,
item_id: int,
file_path: str,
file_size: int,
) -> None:
"""Mark download as complete in database.
Updates download queue item status and marks episode as downloaded.
Args:
db: Database session
item_id: Download queue item ID
file_path: Path to downloaded file
file_size: File size in bytes
"""
# Get download item
item = await DownloadQueueService.get_by_id(db, item_id)
if not item:
logger.error(f"Download item {item_id} not found")
return
# Update download status
await DownloadQueueService.update_status(
db,
item_id,
DownloadStatus.COMPLETED,
)
# Find or create episode and mark as downloaded
episode = await EpisodeService.get_by_episode(
db,
item.series_id,
item.season,
item.episode_number,
)
if episode:
await EpisodeService.mark_downloaded(
db,
episode.id,
file_path,
file_size,
)
else:
# Create episode
episode = await EpisodeService.create(
db,
series_id=item.series_id,
season=item.season,
episode_number=item.episode_number,
file_path=file_path,
file_size=file_size,
is_downloaded=True,
)
await db.commit()
logger.info(
f"Marked download complete: S{item.season:02d}E{item.episode_number:02d}"
)
async def mark_download_failed(
db: AsyncSession,
item_id: int,
error_message: str,
) -> None:
"""Mark download as failed in database.
Args:
db: Database session
item_id: Download queue item ID
error_message: Error description
"""
await DownloadQueueService.update_status(
db,
item_id,
DownloadStatus.FAILED,
error_message=error_message,
)
await db.commit()
# ============================================================================
# Example 4: Add Episodes to Download Queue
# ============================================================================
async def add_episodes_to_queue(
db: AsyncSession,
series_key: str,
episodes: List[tuple[int, int]], # List of (season, episode) tuples
priority: DownloadPriority = DownloadPriority.NORMAL,
) -> int:
"""Add multiple episodes to download queue.
Args:
db: Database session
series_key: Series provider key
episodes: List of (season, episode_number) tuples
priority: Download priority
Returns:
Number of episodes added to queue
"""
# Get series
series = await AnimeSeriesService.get_by_key(db, series_key)
if not series:
logger.error(f"Series not found: {series_key}")
return 0
added_count = 0
for season, episode_number in episodes:
# Check if already in queue
existing_items = await DownloadQueueService.get_all(db)
already_queued = any(
item.series_id == series.id
and item.season == season
and item.episode_number == episode_number
and item.status in (DownloadStatus.PENDING, DownloadStatus.DOWNLOADING)
for item in existing_items
)
if not already_queued:
await DownloadQueueService.create(
db,
series_id=series.id,
season=season,
episode_number=episode_number,
priority=priority,
)
added_count += 1
await db.commit()
logger.info(f"Added {added_count} episodes to download queue")
return added_count
# ============================================================================
# Example 5: Integration with AnimeService
# ============================================================================
class EnhancedAnimeService:
"""Enhanced AnimeService with database persistence.
This is an example of how to wrap the existing AnimeService with
database persistence capabilities.
"""
def __init__(self, db_session_factory):
"""Initialize enhanced anime service.
Args:
db_session_factory: Async session factory for database access
"""
self.db_session_factory = db_session_factory
async def rescan_with_persistence(self, directory: str) -> dict:
"""Rescan directory and persist results.
Args:
directory: Directory to scan
Returns:
Scan results dictionary
"""
# Import here to avoid circular dependencies
from src.core.SeriesApp import SeriesApp
# Perform scan
app = SeriesApp(directory)
series_list = app.ReScan()
# Persist to database
async with self.db_session_factory() as db:
await persist_scan_results(db, series_list)
return {
"total_series": len(series_list),
"message": "Scan completed and persisted to database",
}
async def get_series_with_missing_episodes(self) -> List[dict]:
"""Get series with missing episodes from database.
Returns:
List of series with missing episodes
"""
async with self.db_session_factory() as db:
# Get all series
all_series = await AnimeSeriesService.get_all(
db,
with_episodes=True,
)
# Filter series with missing episodes
series_with_missing = []
for series in all_series:
if series.episode_dict:
total_episodes = sum(
len(eps) for eps in series.episode_dict.values()
)
downloaded_episodes = sum(
1 for ep in series.episodes if ep.is_downloaded
)
if downloaded_episodes < total_episodes:
series_with_missing.append({
"id": series.id,
"key": series.key,
"name": series.name,
"total_episodes": total_episodes,
"downloaded_episodes": downloaded_episodes,
"missing_episodes": total_episodes - downloaded_episodes,
})
return series_with_missing
# ============================================================================
# Usage Example
# ============================================================================
async def example_usage():
"""Example usage of database service integration."""
from src.server.database import get_db_session
# Get database session
async with get_db_session() as db:
# Example 1: Add episodes to queue
added = await add_episodes_to_queue(
db,
series_key="attack-on-titan",
episodes=[(1, 1), (1, 2), (1, 3)],
priority=DownloadPriority.HIGH,
)
print(f"Added {added} episodes to queue")
# Example 2: Load queue
queue_items = await load_queue_from_database(db)
print(f"Queue has {len(queue_items)} items")
# Example 3: Update progress
if queue_items:
await sync_download_progress(
db,
item_id=queue_items[0]["id"],
progress_percent=50.0,
downloaded_bytes=500000,
total_bytes=1000000,
)
# Example 4: Mark complete
if queue_items:
await mark_download_complete(
db,
item_id=queue_items[0]["id"],
file_path="/path/to/file.mp4",
file_size=1000000,
)
if __name__ == "__main__":
import asyncio
asyncio.run(example_usage())

View File

@ -2,12 +2,9 @@
This module provides comprehensive database initialization functionality:
- Schema creation and validation
- Initial data migration
- Database health checks
- Schema versioning support
- Migration utilities
For production deployments, consider using Alembic for managed migrations.
"""
from __future__ import annotations
@ -47,7 +44,7 @@ EXPECTED_INDEXES = {
"episodes": ["ix_episodes_series_id"],
"download_queue": [
"ix_download_queue_series_id",
"ix_download_queue_status",
"ix_download_queue_episode_id",
],
"user_sessions": [
"ix_user_sessions_session_id",
@ -316,7 +313,6 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
"""Get current database schema version.
Returns version string based on existing tables and structure.
For production, consider using Alembic versioning.
Args:
engine: Optional database engine (uses default if not provided)
@ -354,8 +350,6 @@ async def create_schema_version_table(
) -> None:
"""Create schema version tracking table.
Future enhancement for tracking schema migrations with Alembic.
Args:
engine: Optional database engine (uses default if not provided)
"""
@ -587,60 +581,6 @@ def get_database_info() -> Dict[str, Any]:
}
def get_migration_guide() -> str:
"""Get migration guide for production deployments.
Returns:
Migration guide text
"""
return """
Database Migration Guide
========================
Current Setup: SQLAlchemy create_all()
- Automatically creates tables on startup
- Suitable for development and single-instance deployments
- Schema changes require manual handling
For Production with Alembic:
============================
1. Initialize Alembic (already installed):
alembic init alembic
2. Configure alembic/env.py:
from src.server.database.base import Base
target_metadata = Base.metadata
3. Configure alembic.ini:
sqlalchemy.url = <your-database-url>
4. Generate initial migration:
alembic revision --autogenerate -m "Initial schema v1.0.0"
5. Review migration in alembic/versions/
6. Apply migration:
alembic upgrade head
7. For future schema changes:
- Modify models in src/server/database/models.py
- Generate migration: alembic revision --autogenerate -m "Description"
- Review generated migration
- Test in staging environment
- Apply: alembic upgrade head
- For rollback: alembic downgrade -1
Best Practices:
==============
- Always backup database before migrations
- Test migrations in staging first
- Review auto-generated migrations carefully
- Keep migrations in version control
- Document breaking changes
"""
# =============================================================================
# Public API
# =============================================================================
@ -656,7 +596,6 @@ __all__ = [
"check_database_health",
"create_database_backup",
"get_database_info",
"get_migration_guide",
"CURRENT_SCHEMA_VERSION",
"EXPECTED_TABLES",
]

View File

@ -1,167 +0,0 @@
"""Database migration utilities.
This module provides utilities for database migrations and schema versioning.
Alembic integration can be added when needed for production environments.
For now, we use SQLAlchemy's create_all for automatic schema creation.
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
from src.server.database.base import Base
from src.server.database.connection import get_engine, get_sync_engine
logger = logging.getLogger(__name__)
async def initialize_schema(engine: Optional[AsyncEngine] = None) -> None:
"""Initialize database schema.
Creates all tables defined in Base metadata if they don't exist.
This is a simple migration strategy suitable for single-instance deployments.
For production with multiple instances, consider using Alembic:
- alembic init alembic
- alembic revision --autogenerate -m "Initial schema"
- alembic upgrade head
Args:
engine: Optional database engine (uses default if not provided)
Raises:
RuntimeError: If database is not initialized
"""
if engine is None:
engine = get_engine()
logger.info("Initializing database schema...")
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database schema initialized successfully")
async def check_schema_version(engine: Optional[AsyncEngine] = None) -> str:
"""Check current database schema version.
Returns a simple version identifier based on existing tables.
For production, consider using Alembic for proper versioning.
Args:
engine: Optional database engine (uses default if not provided)
Returns:
Schema version string
Raises:
RuntimeError: If database is not initialized
"""
if engine is None:
engine = get_engine()
async with engine.connect() as conn:
# Check which tables exist
result = await conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
)
tables = [row[0] for row in result]
if not tables:
return "empty"
elif len(tables) == 4 and all(
t in tables for t in [
"anime_series",
"episodes",
"download_queue",
"user_sessions",
]
):
return "v1.0"
else:
return "custom"
def get_migration_info() -> str:
"""Get information about database migration setup.
Returns:
Migration setup information
"""
return """
Database Migration Information
==============================
Current Strategy: SQLAlchemy create_all()
- Automatically creates tables on startup
- Suitable for development and single-instance deployments
- Schema changes require manual handling
For Production Migrations (Alembic):
====================================
1. Initialize Alembic:
alembic init alembic
2. Configure alembic/env.py:
- Import Base from src.server.database.base
- Set target_metadata = Base.metadata
3. Configure alembic.ini:
- Set sqlalchemy.url to your database URL
4. Generate initial migration:
alembic revision --autogenerate -m "Initial schema"
5. Apply migrations:
alembic upgrade head
6. For future changes:
- Modify models in src/server/database/models.py
- Generate migration: alembic revision --autogenerate -m "Description"
- Review generated migration in alembic/versions/
- Apply: alembic upgrade head
Benefits of Alembic:
- Version control for database schema
- Automatic migration generation from model changes
- Rollback support with downgrade scripts
- Multi-instance deployment support
- Safe schema changes in production
"""
# =============================================================================
# Future Alembic Integration
# =============================================================================
#
# When ready to use Alembic, follow these steps:
#
# 1. Install Alembic (already in requirements.txt):
# pip install alembic
#
# 2. Initialize Alembic from project root:
# alembic init alembic
#
# 3. Update alembic/env.py to use our Base:
# from src.server.database.base import Base
# target_metadata = Base.metadata
#
# 4. Configure alembic.ini with DATABASE_URL from settings
#
# 5. Generate initial migration:
# alembic revision --autogenerate -m "Initial schema"
#
# 6. Review generated migration and apply:
# alembic upgrade head
#
# =============================================================================

View File

@ -1,236 +0,0 @@
"""
Initial database schema migration.
This migration creates the base tables for the Aniworld application,
including users, anime, downloads, and configuration tables.
Version: 20250124_001
Created: 2025-01-24
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from ..migrations.base import Migration, MigrationError
logger = logging.getLogger(__name__)
class InitialSchemaMigration(Migration):
"""
Creates initial database schema.
This migration sets up all core tables needed for the application:
- users: User accounts and authentication
- anime: Anime series metadata
- episodes: Episode information
- downloads: Download queue and history
- config: Application configuration
"""
def __init__(self):
"""Initialize the initial schema migration."""
super().__init__(
version="20250124_001",
description="Create initial database schema",
)
async def upgrade(self, session: AsyncSession) -> None:
"""
Create all initial tables.
Args:
session: Database session
Raises:
MigrationError: If table creation fails
"""
try:
# Create users table
await session.execute(
text(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT,
password_hash TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
# Create anime table
await session.execute(
text(
"""
CREATE TABLE IF NOT EXISTS anime (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
original_title TEXT,
description TEXT,
genres TEXT,
release_year INTEGER,
status TEXT,
total_episodes INTEGER,
cover_image_url TEXT,
aniworld_url TEXT,
mal_id INTEGER,
anilist_id INTEGER,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
# Create episodes table
await session.execute(
text(
"""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anime_id INTEGER NOT NULL,
episode_number INTEGER NOT NULL,
season_number INTEGER DEFAULT 1,
title TEXT,
description TEXT,
duration_minutes INTEGER,
air_date DATE,
stream_url TEXT,
download_url TEXT,
file_path TEXT,
file_size_bytes INTEGER,
is_downloaded BOOLEAN DEFAULT 0,
download_progress REAL DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anime_id) REFERENCES anime(id)
ON DELETE CASCADE,
UNIQUE (anime_id, season_number, episode_number)
)
"""
)
)
# Create downloads table
await session.execute(
text(
"""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_id INTEGER NOT NULL,
user_id INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
priority INTEGER DEFAULT 5,
progress REAL DEFAULT 0.0,
download_speed_mbps REAL,
eta_seconds INTEGER,
started_at TIMESTAMP,
completed_at TIMESTAMP,
failed_at TIMESTAMP,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (episode_id) REFERENCES episodes(id)
ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL
)
"""
)
)
# Create config table
await session.execute(
text(
"""
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT DEFAULT 'general',
description TEXT,
is_secret BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
# Create indexes for better performance
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_anime_title "
"ON anime(title)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_episodes_anime_id "
"ON episodes(anime_id)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_downloads_status "
"ON downloads(status)"
)
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS "
"idx_downloads_episode_id ON downloads(episode_id)"
)
)
logger.info("Initial schema created successfully")
except Exception as e:
logger.error(f"Failed to create initial schema: {e}")
raise MigrationError(
f"Initial schema creation failed: {e}"
) from e
async def downgrade(self, session: AsyncSession) -> None:
"""
Drop all initial tables.
Args:
session: Database session
Raises:
MigrationError: If table dropping fails
"""
try:
# Drop tables in reverse order to respect foreign keys
tables = [
"downloads",
"episodes",
"anime",
"users",
"config",
]
for table in tables:
await session.execute(text(f"DROP TABLE IF EXISTS {table}"))
logger.debug(f"Dropped table: {table}")
logger.info("Initial schema rolled back successfully")
except Exception as e:
logger.error(f"Failed to rollback initial schema: {e}")
raise MigrationError(
f"Initial schema rollback failed: {e}"
) from e

View File

@ -1,17 +0,0 @@
"""
Database migration system for Aniworld application.
This package provides tools for managing database schema changes,
including migration creation, execution, and rollback capabilities.
"""
from .base import Migration, MigrationError
from .runner import MigrationRunner
from .validator import MigrationValidator
__all__ = [
"Migration",
"MigrationError",
"MigrationRunner",
"MigrationValidator",
]

View File

@ -1,128 +0,0 @@
"""
Base migration classes and utilities.
This module provides the foundation for database migrations,
including the abstract Migration class and error handling.
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
class MigrationError(Exception):
"""Base exception for migration-related errors."""
pass
class Migration(ABC):
"""
Abstract base class for database migrations.
Each migration should inherit from this class and implement
the upgrade and downgrade methods.
Attributes:
version: Unique version identifier (e.g., "20250124_001")
description: Human-readable description of the migration
created_at: Timestamp when migration was created
"""
def __init__(
self,
version: str,
description: str,
created_at: Optional[datetime] = None,
):
"""
Initialize migration.
Args:
version: Unique version identifier
description: Human-readable description
created_at: Creation timestamp (defaults to now)
"""
self.version = version
self.description = description
self.created_at = created_at or datetime.now()
@abstractmethod
async def upgrade(self, session: AsyncSession) -> None:
"""
Apply the migration.
Args:
session: Database session for executing changes
Raises:
MigrationError: If migration fails
"""
pass
@abstractmethod
async def downgrade(self, session: AsyncSession) -> None:
"""
Revert the migration.
Args:
session: Database session for reverting changes
Raises:
MigrationError: If rollback fails
"""
pass
def __repr__(self) -> str:
"""Return string representation of migration."""
return f"Migration({self.version}: {self.description})"
def __eq__(self, other: object) -> bool:
"""Check equality based on version."""
if not isinstance(other, Migration):
return False
return self.version == other.version
def __hash__(self) -> int:
"""Return hash based on version."""
return hash(self.version)
class MigrationHistory:
"""
Tracks applied migrations in the database.
This model stores information about which migrations have been
applied, when they were applied, and their execution status.
"""
__tablename__ = "migration_history"
def __init__(
self,
version: str,
description: str,
applied_at: datetime,
execution_time_ms: int,
success: bool = True,
error_message: Optional[str] = None,
):
"""
Initialize migration history record.
Args:
version: Migration version identifier
description: Migration description
applied_at: Timestamp when migration was applied
execution_time_ms: Time taken to execute in milliseconds
success: Whether migration succeeded
error_message: Error message if migration failed
"""
self.version = version
self.description = description
self.applied_at = applied_at
self.execution_time_ms = execution_time_ms
self.success = success
self.error_message = error_message

View File

@ -1,323 +0,0 @@
"""
Migration runner for executing database migrations.
This module handles the execution of migrations in the correct order,
tracks migration history, and provides rollback capabilities.
"""
import importlib.util
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from .base import Migration, MigrationError, MigrationHistory
logger = logging.getLogger(__name__)
class MigrationRunner:
"""
Manages database migration execution and tracking.
This class handles loading migrations, executing them in order,
tracking their status, and rolling back when needed.
"""
def __init__(self, migrations_dir: Path, session: AsyncSession):
"""
Initialize migration runner.
Args:
migrations_dir: Directory containing migration files
session: Database session for executing migrations
"""
self.migrations_dir = migrations_dir
self.session = session
self._migrations: List[Migration] = []
async def initialize(self) -> None:
"""
Initialize migration system by creating tracking table if needed.
Raises:
MigrationError: If initialization fails
"""
try:
# Create migration_history table if it doesn't exist
create_table_sql = """
CREATE TABLE IF NOT EXISTS migration_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL,
execution_time_ms INTEGER NOT NULL,
success BOOLEAN NOT NULL DEFAULT 1,
error_message TEXT
)
"""
await self.session.execute(text(create_table_sql))
await self.session.commit()
logger.info("Migration system initialized")
except Exception as e:
logger.error(f"Failed to initialize migration system: {e}")
raise MigrationError(f"Initialization failed: {e}") from e
def load_migrations(self) -> None:
"""
Load all migration files from the migrations directory.
Migration files should be named in format: {version}_{description}.py
and contain a Migration class that inherits from base.Migration.
Raises:
MigrationError: If loading migrations fails
"""
try:
self._migrations.clear()
if not self.migrations_dir.exists():
logger.warning(f"Migrations directory does not exist: {self.migrations_dir}")
return
# Find all Python files in migrations directory
migration_files = sorted(self.migrations_dir.glob("*.py"))
migration_files = [f for f in migration_files if f.name != "__init__.py"]
for file_path in migration_files:
try:
# Import the migration module dynamically
spec = importlib.util.spec_from_file_location(
f"migration.{file_path.stem}", file_path
)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find Migration subclass in module
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, Migration)
and attr != Migration
):
migration_instance = attr()
self._migrations.append(migration_instance)
logger.debug(f"Loaded migration: {migration_instance.version}")
break
except Exception as e:
logger.error(f"Failed to load migration {file_path.name}: {e}")
raise MigrationError(f"Failed to load {file_path.name}: {e}") from e
# Sort migrations by version
self._migrations.sort(key=lambda m: m.version)
logger.info(f"Loaded {len(self._migrations)} migrations")
except Exception as e:
logger.error(f"Failed to load migrations: {e}")
raise MigrationError(f"Loading migrations failed: {e}") from e
async def get_applied_migrations(self) -> List[str]:
"""
Get list of already applied migration versions.
Returns:
List of migration versions that have been applied
Raises:
MigrationError: If query fails
"""
try:
result = await self.session.execute(
text("SELECT version FROM migration_history WHERE success = 1 ORDER BY version")
)
versions = [row[0] for row in result.fetchall()]
return versions
except Exception as e:
logger.error(f"Failed to get applied migrations: {e}")
raise MigrationError(f"Query failed: {e}") from e
async def get_pending_migrations(self) -> List[Migration]:
"""
Get list of migrations that haven't been applied yet.
Returns:
List of pending Migration objects
Raises:
MigrationError: If check fails
"""
applied = await self.get_applied_migrations()
pending = [m for m in self._migrations if m.version not in applied]
return pending
async def apply_migration(self, migration: Migration) -> None:
"""
Apply a single migration.
Args:
migration: Migration to apply
Raises:
MigrationError: If migration fails
"""
start_time = time.time()
success = False
error_message = None
try:
logger.info(f"Applying migration: {migration.version} - {migration.description}")
# Execute the migration
await migration.upgrade(self.session)
await self.session.commit()
success = True
execution_time_ms = int((time.time() - start_time) * 1000)
logger.info(
f"Migration {migration.version} applied successfully in {execution_time_ms}ms"
)
except Exception as e:
error_message = str(e)
execution_time_ms = int((time.time() - start_time) * 1000)
logger.error(f"Migration {migration.version} failed: {e}")
await self.session.rollback()
raise MigrationError(f"Migration {migration.version} failed: {e}") from e
finally:
# Record migration in history
try:
history_record = MigrationHistory(
version=migration.version,
description=migration.description,
applied_at=datetime.now(),
execution_time_ms=execution_time_ms,
success=success,
error_message=error_message,
)
insert_sql = """
INSERT INTO migration_history
(version, description, applied_at, execution_time_ms, success, error_message)
VALUES (:version, :description, :applied_at, :execution_time_ms, :success, :error_message)
"""
await self.session.execute(
text(insert_sql),
{
"version": history_record.version,
"description": history_record.description,
"applied_at": history_record.applied_at,
"execution_time_ms": history_record.execution_time_ms,
"success": history_record.success,
"error_message": history_record.error_message,
},
)
await self.session.commit()
except Exception as e:
logger.error(f"Failed to record migration history: {e}")
async def run_migrations(self, target_version: Optional[str] = None) -> int:
"""
Run all pending migrations up to target version.
Args:
target_version: Stop at this version (None = run all)
Returns:
Number of migrations applied
Raises:
MigrationError: If migrations fail
"""
pending = await self.get_pending_migrations()
if target_version:
pending = [m for m in pending if m.version <= target_version]
if not pending:
logger.info("No pending migrations to apply")
return 0
logger.info(f"Applying {len(pending)} pending migrations")
for migration in pending:
await self.apply_migration(migration)
return len(pending)
async def rollback_migration(self, migration: Migration) -> None:
"""
Rollback a single migration.
Args:
migration: Migration to rollback
Raises:
MigrationError: If rollback fails
"""
start_time = time.time()
try:
logger.info(f"Rolling back migration: {migration.version}")
# Execute the downgrade
await migration.downgrade(self.session)
await self.session.commit()
execution_time_ms = int((time.time() - start_time) * 1000)
# Remove from history
delete_sql = "DELETE FROM migration_history WHERE version = :version"
await self.session.execute(text(delete_sql), {"version": migration.version})
await self.session.commit()
logger.info(
f"Migration {migration.version} rolled back successfully in {execution_time_ms}ms"
)
except Exception as e:
logger.error(f"Rollback of {migration.version} failed: {e}")
await self.session.rollback()
raise MigrationError(f"Rollback of {migration.version} failed: {e}") from e
async def rollback(self, steps: int = 1) -> int:
"""
Rollback the last N migrations.
Args:
steps: Number of migrations to rollback
Returns:
Number of migrations rolled back
Raises:
MigrationError: If rollback fails
"""
applied = await self.get_applied_migrations()
if not applied:
logger.info("No migrations to rollback")
return 0
# Get migrations to rollback (in reverse order)
to_rollback = applied[-steps:]
to_rollback.reverse()
migrations_to_rollback = [m for m in self._migrations if m.version in to_rollback]
logger.info(f"Rolling back {len(migrations_to_rollback)} migrations")
for migration in migrations_to_rollback:
await self.rollback_migration(migration)
return len(migrations_to_rollback)

View File

@ -1,222 +0,0 @@
"""
Migration validator for ensuring migration safety and integrity.
This module provides validation utilities to check migrations
before they are executed, ensuring they meet quality standards.
"""
import logging
from typing import List, Optional, Set
from .base import Migration, MigrationError
logger = logging.getLogger(__name__)
class MigrationValidator:
"""
Validates migrations before execution.
Performs various checks to ensure migrations are safe to run,
including version uniqueness, naming conventions, and
dependency resolution.
"""
def __init__(self):
"""Initialize migration validator."""
self.errors: List[str] = []
self.warnings: List[str] = []
def reset(self) -> None:
"""Clear validation results."""
self.errors.clear()
self.warnings.clear()
def validate_migration(self, migration: Migration) -> bool:
"""
Validate a single migration.
Args:
migration: Migration to validate
Returns:
True if migration is valid, False otherwise
"""
self.reset()
# Check version format
if not self._validate_version_format(migration.version):
self.errors.append(
f"Invalid version format: {migration.version}. "
"Expected format: YYYYMMDD_NNN"
)
# Check description
if not migration.description or len(migration.description) < 5:
self.errors.append(
f"Migration {migration.version} has invalid "
f"description: '{migration.description}'"
)
# Check for implementation
if not hasattr(migration, "upgrade") or not callable(
getattr(migration, "upgrade")
):
self.errors.append(
f"Migration {migration.version} missing upgrade method"
)
if not hasattr(migration, "downgrade") or not callable(
getattr(migration, "downgrade")
):
self.errors.append(
f"Migration {migration.version} missing downgrade method"
)
return len(self.errors) == 0
def validate_migrations(self, migrations: List[Migration]) -> bool:
"""
Validate a list of migrations.
Args:
migrations: List of migrations to validate
Returns:
True if all migrations are valid, False otherwise
"""
self.reset()
if not migrations:
self.warnings.append("No migrations to validate")
return True
# Check for duplicate versions
versions: Set[str] = set()
for migration in migrations:
if migration.version in versions:
self.errors.append(
f"Duplicate migration version: {migration.version}"
)
versions.add(migration.version)
# Return early if duplicates found
if self.errors:
return False
# Validate each migration
for migration in migrations:
if not self.validate_migration(migration):
logger.error(
f"Migration {migration.version} "
f"validation failed: {self.errors}"
)
return False
# Check version ordering
sorted_versions = sorted([m.version for m in migrations])
actual_versions = [m.version for m in migrations]
if sorted_versions != actual_versions:
self.warnings.append(
"Migrations are not in chronological order"
)
return len(self.errors) == 0
def _validate_version_format(self, version: str) -> bool:
"""
Validate version string format.
Args:
version: Version string to validate
Returns:
True if format is valid
"""
# Expected format: YYYYMMDD_NNN or YYYYMMDD_NNN_description
if not version:
return False
parts = version.split("_")
if len(parts) < 2:
return False
# Check date part (YYYYMMDD)
date_part = parts[0]
if len(date_part) != 8 or not date_part.isdigit():
return False
# Check sequence part (NNN)
seq_part = parts[1]
if not seq_part.isdigit():
return False
return True
def check_migration_conflicts(
self,
pending: List[Migration],
applied: List[str],
) -> Optional[str]:
"""
Check for conflicts between pending and applied migrations.
Args:
pending: List of pending migrations
applied: List of applied migration versions
Returns:
Error message if conflicts found, None otherwise
"""
# Check if any pending migration has version lower than applied
if not applied:
return None
latest_applied = max(applied)
for migration in pending:
if migration.version < latest_applied:
return (
f"Migration {migration.version} is older than "
f"latest applied migration {latest_applied}. "
"This may indicate a merge conflict."
)
return None
def get_validation_report(self) -> str:
"""
Get formatted validation report.
Returns:
Formatted report string
"""
report = []
if self.errors:
report.append("Validation Errors:")
for error in self.errors:
report.append(f" - {error}")
if self.warnings:
report.append("Validation Warnings:")
for warning in self.warnings:
report.append(f" - {warning}")
if not self.errors and not self.warnings:
report.append("All validations passed")
return "\n".join(report)
def raise_if_invalid(self) -> None:
"""
Raise exception if validation failed.
Raises:
MigrationError: If validation errors exist
"""
if self.errors:
error_msg = "\n".join(self.errors)
raise MigrationError(
f"Migration validation failed:\n{error_msg}"
)

View File

@ -15,18 +15,7 @@ from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy import Enum as SQLEnum
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from src.server.database.base import Base, TimestampMixin
@ -51,10 +40,6 @@ class AnimeSeries(Base, TimestampMixin):
name: Display name of the series
site: Provider site URL
folder: Filesystem folder name (metadata only, not for lookups)
description: Optional series description
status: Current status (ongoing, completed, etc.)
total_episodes: Total number of episodes
cover_url: URL to series cover image
episodes: Relationship to Episode models (via id foreign key)
download_items: Relationship to DownloadQueueItem models (via id foreign key)
created_at: Creation timestamp (from TimestampMixin)
@ -89,30 +74,6 @@ class AnimeSeries(Base, TimestampMixin):
doc="Filesystem folder name - METADATA ONLY, not for lookups"
)
# Metadata
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
doc="Series description"
)
status: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True,
doc="Series status (ongoing, completed, etc.)"
)
total_episodes: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="Total number of episodes"
)
cover_url: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True,
doc="URL to cover image"
)
# JSON field for episode dictionary (season -> [episodes])
episode_dict: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True,
doc="Episode dictionary {season: [episodes]}"
)
# Relationships
episodes: Mapped[List["Episode"]] = relationship(
"Episode",
@ -161,22 +122,6 @@ class AnimeSeries(Base, TimestampMixin):
raise ValueError("Folder path must be 1000 characters or less")
return value.strip()
@validates('cover_url')
def validate_cover_url(self, key: str, value: Optional[str]) -> Optional[str]:
"""Validate cover URL length."""
if value is not None and len(value) > 1000:
raise ValueError("Cover URL must be 1000 characters or less")
return value
@validates('total_episodes')
def validate_total_episodes(self, key: str, value: Optional[int]) -> Optional[int]:
"""Validate total episodes is positive."""
if value is not None and value < 0:
raise ValueError("Total episodes must be non-negative")
if value is not None and value > 10000:
raise ValueError("Total episodes must be 10000 or less")
return value
def __repr__(self) -> str:
return f"<AnimeSeries(id={self.id}, key='{self.key}', name='{self.name}')>"
@ -194,9 +139,7 @@ class Episode(Base, TimestampMixin):
episode_number: Episode number within season
title: Episode title
file_path: Local file path if downloaded
file_size: File size in bytes
is_downloaded: Whether episode is downloaded
download_date: When episode was downloaded
series: Relationship to AnimeSeries
created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin)
@ -234,18 +177,10 @@ class Episode(Base, TimestampMixin):
String(1000), nullable=True,
doc="Local file path"
)
file_size: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="File size in bytes"
)
is_downloaded: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False,
doc="Whether episode is downloaded"
)
download_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="When episode was downloaded"
)
# Relationship
series: Mapped["AnimeSeries"] = relationship(
@ -287,13 +222,6 @@ class Episode(Base, TimestampMixin):
raise ValueError("File path must be 1000 characters or less")
return value
@validates('file_size')
def validate_file_size(self, key: str, value: Optional[int]) -> Optional[int]:
"""Validate file size is non-negative."""
if value is not None and value < 0:
raise ValueError("File size must be non-negative")
return value
def __repr__(self) -> str:
return (
f"<Episode(id={self.id}, series_id={self.series_id}, "
@ -321,27 +249,20 @@ class DownloadPriority(str, Enum):
class DownloadQueueItem(Base, TimestampMixin):
"""SQLAlchemy model for download queue items.
Tracks download queue with status, progress, and error information.
Tracks download queue with error information.
Provides persistence for the DownloadService queue state.
Attributes:
id: Primary key
series_id: Foreign key to AnimeSeries
season: Season number
episode_number: Episode number
status: Current download status
priority: Download priority
progress_percent: Download progress (0-100)
downloaded_bytes: Bytes downloaded
total_bytes: Total file size
download_speed: Current speed in bytes/sec
episode_id: Foreign key to Episode
error_message: Error description if failed
retry_count: Number of retry attempts
download_url: Provider download URL
file_destination: Target file path
started_at: When download started
completed_at: When download completed
series: Relationship to AnimeSeries
episode: Relationship to Episode
created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin)
"""
@ -359,47 +280,11 @@ class DownloadQueueItem(Base, TimestampMixin):
index=True
)
# Episode identification
season: Mapped[int] = mapped_column(
Integer, nullable=False,
doc="Season number"
)
episode_number: Mapped[int] = mapped_column(
Integer, nullable=False,
doc="Episode number"
)
# Queue management
status: Mapped[str] = mapped_column(
SQLEnum(DownloadStatus),
default=DownloadStatus.PENDING,
# Foreign key to episode
episode_id: Mapped[int] = mapped_column(
ForeignKey("episodes.id", ondelete="CASCADE"),
nullable=False,
index=True,
doc="Current download status"
)
priority: Mapped[str] = mapped_column(
SQLEnum(DownloadPriority),
default=DownloadPriority.NORMAL,
nullable=False,
doc="Download priority"
)
# Progress tracking
progress_percent: Mapped[float] = mapped_column(
Float, default=0.0, nullable=False,
doc="Progress percentage (0-100)"
)
downloaded_bytes: Mapped[int] = mapped_column(
Integer, default=0, nullable=False,
doc="Bytes downloaded"
)
total_bytes: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="Total file size"
)
download_speed: Mapped[Optional[float]] = mapped_column(
Float, nullable=True,
doc="Current download speed (bytes/sec)"
index=True
)
# Error handling
@ -407,10 +292,6 @@ class DownloadQueueItem(Base, TimestampMixin):
Text, nullable=True,
doc="Error description"
)
retry_count: Mapped[int] = mapped_column(
Integer, default=0, nullable=False,
doc="Number of retry attempts"
)
# Download details
download_url: Mapped[Optional[str]] = mapped_column(
@ -437,67 +318,9 @@ class DownloadQueueItem(Base, TimestampMixin):
"AnimeSeries",
back_populates="download_items"
)
@validates('season')
def validate_season(self, key: str, value: int) -> int:
"""Validate season number is positive."""
if value < 0:
raise ValueError("Season number must be non-negative")
if value > 1000:
raise ValueError("Season number must be 1000 or less")
return value
@validates('episode_number')
def validate_episode_number(self, key: str, value: int) -> int:
"""Validate episode number is positive."""
if value < 0:
raise ValueError("Episode number must be non-negative")
if value > 10000:
raise ValueError("Episode number must be 10000 or less")
return value
@validates('progress_percent')
def validate_progress_percent(self, key: str, value: float) -> float:
"""Validate progress is between 0 and 100."""
if value < 0.0:
raise ValueError("Progress percent must be non-negative")
if value > 100.0:
raise ValueError("Progress percent cannot exceed 100")
return value
@validates('downloaded_bytes')
def validate_downloaded_bytes(self, key: str, value: int) -> int:
"""Validate downloaded bytes is non-negative."""
if value < 0:
raise ValueError("Downloaded bytes must be non-negative")
return value
@validates('total_bytes')
def validate_total_bytes(
self, key: str, value: Optional[int]
) -> Optional[int]:
"""Validate total bytes is non-negative."""
if value is not None and value < 0:
raise ValueError("Total bytes must be non-negative")
return value
@validates('download_speed')
def validate_download_speed(
self, key: str, value: Optional[float]
) -> Optional[float]:
"""Validate download speed is non-negative."""
if value is not None and value < 0.0:
raise ValueError("Download speed must be non-negative")
return value
@validates('retry_count')
def validate_retry_count(self, key: str, value: int) -> int:
"""Validate retry count is non-negative."""
if value < 0:
raise ValueError("Retry count must be non-negative")
if value > 100:
raise ValueError("Retry count cannot exceed 100")
return value
episode: Mapped["Episode"] = relationship(
"Episode"
)
@validates('download_url')
def validate_download_url(
@ -523,8 +346,7 @@ class DownloadQueueItem(Base, TimestampMixin):
return (
f"<DownloadQueueItem(id={self.id}, "
f"series_id={self.series_id}, "
f"S{self.season:02d}E{self.episode_number:02d}, "
f"status={self.status})>"
f"episode_id={self.episode_id})>"
)

View File

@ -9,13 +9,22 @@ Services:
- DownloadQueueService: CRUD operations for download queue
- UserSessionService: CRUD operations for user sessions
Transaction Support:
All services are designed to work within transaction boundaries.
Individual operations use flush() instead of commit() to allow
the caller to control transaction boundaries.
For compound operations spanning multiple services, use the
@transactional decorator or atomic() context manager from
src.server.database.transaction.
All services support both async and sync operations for flexibility.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from typing import List, Optional
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -23,9 +32,7 @@ from sqlalchemy.orm import Session, selectinload
from src.server.database.models import (
AnimeSeries,
DownloadPriority,
DownloadQueueItem,
DownloadStatus,
Episode,
UserSession,
)
@ -57,11 +64,6 @@ class AnimeSeriesService:
name: str,
site: str,
folder: str,
description: Optional[str] = None,
status: Optional[str] = None,
total_episodes: Optional[int] = None,
cover_url: Optional[str] = None,
episode_dict: Optional[Dict] = None,
) -> AnimeSeries:
"""Create a new anime series.
@ -71,11 +73,6 @@ class AnimeSeriesService:
name: Series name
site: Provider site URL
folder: Local filesystem path
description: Optional series description
status: Optional series status
total_episodes: Optional total episode count
cover_url: Optional cover image URL
episode_dict: Optional episode dictionary
Returns:
Created AnimeSeries instance
@ -88,11 +85,6 @@ class AnimeSeriesService:
name=name,
site=site,
folder=folder,
description=description,
status=status,
total_episodes=total_episodes,
cover_url=cover_url,
episode_dict=episode_dict,
)
db.add(series)
await db.flush()
@ -262,7 +254,6 @@ class EpisodeService:
episode_number: int,
title: Optional[str] = None,
file_path: Optional[str] = None,
file_size: Optional[int] = None,
is_downloaded: bool = False,
) -> Episode:
"""Create a new episode.
@ -274,7 +265,6 @@ class EpisodeService:
episode_number: Episode number within season
title: Optional episode title
file_path: Optional local file path
file_size: Optional file size in bytes
is_downloaded: Whether episode is downloaded
Returns:
@ -286,9 +276,7 @@ class EpisodeService:
episode_number=episode_number,
title=title,
file_path=file_path,
file_size=file_size,
is_downloaded=is_downloaded,
download_date=datetime.now(timezone.utc) if is_downloaded else None,
)
db.add(episode)
await db.flush()
@ -372,7 +360,6 @@ class EpisodeService:
db: AsyncSession,
episode_id: int,
file_path: str,
file_size: int,
) -> Optional[Episode]:
"""Mark episode as downloaded.
@ -380,7 +367,6 @@ class EpisodeService:
db: Database session
episode_id: Episode primary key
file_path: Local file path
file_size: File size in bytes
Returns:
Updated Episode instance or None if not found
@ -391,8 +377,6 @@ class EpisodeService:
episode.is_downloaded = True
episode.file_path = file_path
episode.file_size = file_size
episode.download_date = datetime.now(timezone.utc)
await db.flush()
await db.refresh(episode)
@ -418,6 +402,96 @@ class EpisodeService:
)
return result.rowcount > 0
@staticmethod
async def delete_by_series_and_episode(
db: AsyncSession,
series_key: str,
season: int,
episode_number: int,
) -> bool:
"""Delete episode by series key, season, and episode number.
Used to remove episodes from the missing list when they are
downloaded successfully.
Args:
db: Database session
series_key: Unique provider key for the series
season: Season number
episode_number: Episode number within season
Returns:
True if deleted, False if not found
"""
# First get the series by key
series = await AnimeSeriesService.get_by_key(db, series_key)
if not series:
logger.warning(
f"Series not found for key: {series_key}"
)
return False
# Then delete the episode
result = await db.execute(
delete(Episode).where(
Episode.series_id == series.id,
Episode.season == season,
Episode.episode_number == episode_number,
)
)
deleted = result.rowcount > 0
if deleted:
logger.info(
f"Removed episode from missing list: "
f"{series_key} S{season:02d}E{episode_number:02d}"
)
return deleted
@staticmethod
async def bulk_mark_downloaded(
db: AsyncSession,
episode_ids: List[int],
file_paths: Optional[List[str]] = None,
) -> int:
"""Mark multiple episodes as downloaded atomically.
This operation should be wrapped in a transaction for atomicity.
All episodes will be updated or none if an error occurs.
Args:
db: Database session
episode_ids: List of episode primary keys to update
file_paths: Optional list of file paths (parallel to episode_ids)
Returns:
Number of episodes updated
Note:
Use within @transactional or atomic() for guaranteed atomicity:
async with atomic(db) as tx:
count = await EpisodeService.bulk_mark_downloaded(
db, episode_ids, file_paths
)
"""
if not episode_ids:
return 0
updated_count = 0
for i, episode_id in enumerate(episode_ids):
episode = await EpisodeService.get_by_id(db, episode_id)
if episode:
episode.is_downloaded = True
if file_paths and i < len(file_paths):
episode.file_path = file_paths[i]
updated_count += 1
await db.flush()
logger.info(f"Bulk marked {updated_count} episodes as downloaded")
return updated_count
# ============================================================================
# Download Queue Service
@ -427,17 +501,18 @@ class EpisodeService:
class DownloadQueueService:
"""Service for download queue CRUD operations.
Provides methods for managing the download queue with status tracking,
priority management, and progress updates.
Provides methods for managing the download queue.
Transaction Support:
All operations use flush() for transaction-safe operation.
For bulk operations, use @transactional or atomic() context.
"""
@staticmethod
async def create(
db: AsyncSession,
series_id: int,
season: int,
episode_number: int,
priority: DownloadPriority = DownloadPriority.NORMAL,
episode_id: int,
download_url: Optional[str] = None,
file_destination: Optional[str] = None,
) -> DownloadQueueItem:
@ -446,9 +521,7 @@ class DownloadQueueService:
Args:
db: Database session
series_id: Foreign key to AnimeSeries
season: Season number
episode_number: Episode number
priority: Download priority
episode_id: Foreign key to Episode
download_url: Optional provider download URL
file_destination: Optional target file path
@ -457,10 +530,7 @@ class DownloadQueueService:
"""
item = DownloadQueueItem(
series_id=series_id,
season=season,
episode_number=episode_number,
status=DownloadStatus.PENDING,
priority=priority,
episode_id=episode_id,
download_url=download_url,
file_destination=file_destination,
)
@ -468,8 +538,8 @@ class DownloadQueueService:
await db.flush()
await db.refresh(item)
logger.info(
f"Added to download queue: S{season:02d}E{episode_number:02d} "
f"for series_id={series_id} with priority={priority}"
f"Added to download queue: episode_id={episode_id} "
f"for series_id={series_id}"
)
return item
@ -493,68 +563,25 @@ class DownloadQueueService:
return result.scalar_one_or_none()
@staticmethod
async def get_by_status(
async def get_by_episode(
db: AsyncSession,
status: DownloadStatus,
limit: Optional[int] = None,
) -> List[DownloadQueueItem]:
"""Get download queue items by status.
episode_id: int,
) -> Optional[DownloadQueueItem]:
"""Get download queue item by episode ID.
Args:
db: Database session
status: Download status filter
limit: Optional limit for results
episode_id: Foreign key to Episode
Returns:
List of DownloadQueueItem instances
DownloadQueueItem instance or None if not found
"""
query = select(DownloadQueueItem).where(
DownloadQueueItem.status == status
)
# Order by priority (HIGH first) then creation time
query = query.order_by(
DownloadQueueItem.priority.desc(),
DownloadQueueItem.created_at.asc(),
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_pending(
db: AsyncSession,
limit: Optional[int] = None,
) -> List[DownloadQueueItem]:
"""Get pending download queue items.
Args:
db: Database session
limit: Optional limit for results
Returns:
List of pending DownloadQueueItem instances ordered by priority
"""
return await DownloadQueueService.get_by_status(
db, DownloadStatus.PENDING, limit
)
@staticmethod
async def get_active(db: AsyncSession) -> List[DownloadQueueItem]:
"""Get active download queue items.
Args:
db: Database session
Returns:
List of downloading DownloadQueueItem instances
"""
return await DownloadQueueService.get_by_status(
db, DownloadStatus.DOWNLOADING
result = await db.execute(
select(DownloadQueueItem).where(
DownloadQueueItem.episode_id == episode_id
)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
@ -576,7 +603,6 @@ class DownloadQueueService:
query = query.options(selectinload(DownloadQueueItem.series))
query = query.order_by(
DownloadQueueItem.priority.desc(),
DownloadQueueItem.created_at.asc(),
)
@ -584,19 +610,17 @@ class DownloadQueueService:
return list(result.scalars().all())
@staticmethod
async def update_status(
async def set_error(
db: AsyncSession,
item_id: int,
status: DownloadStatus,
error_message: Optional[str] = None,
error_message: str,
) -> Optional[DownloadQueueItem]:
"""Update download queue item status.
"""Set error message on download queue item.
Args:
db: Database session
item_id: Item primary key
status: New download status
error_message: Optional error message for failed status
error_message: Error description
Returns:
Updated DownloadQueueItem instance or None if not found
@ -605,61 +629,11 @@ class DownloadQueueService:
if not item:
return None
item.status = status
# Update timestamps based on status
if status == DownloadStatus.DOWNLOADING and not item.started_at:
item.started_at = datetime.now(timezone.utc)
elif status in (DownloadStatus.COMPLETED, DownloadStatus.FAILED):
item.completed_at = datetime.now(timezone.utc)
# Set error message for failed downloads
if status == DownloadStatus.FAILED and error_message:
item.error_message = error_message
item.retry_count += 1
await db.flush()
await db.refresh(item)
logger.debug(f"Updated download queue item {item_id} status to {status}")
return item
@staticmethod
async def update_progress(
db: AsyncSession,
item_id: int,
progress_percent: float,
downloaded_bytes: int,
total_bytes: Optional[int] = None,
download_speed: Optional[float] = None,
) -> Optional[DownloadQueueItem]:
"""Update download progress.
Args:
db: Database session
item_id: Item primary key
progress_percent: Progress percentage (0-100)
downloaded_bytes: Bytes downloaded
total_bytes: Optional total file size
download_speed: Optional current speed (bytes/sec)
Returns:
Updated DownloadQueueItem instance or None if not found
"""
item = await DownloadQueueService.get_by_id(db, item_id)
if not item:
return None
item.progress_percent = progress_percent
item.downloaded_bytes = downloaded_bytes
if total_bytes is not None:
item.total_bytes = total_bytes
if download_speed is not None:
item.download_speed = download_speed
item.error_message = error_message
await db.flush()
await db.refresh(item)
logger.debug(f"Set error on download queue item {item_id}")
return item
@staticmethod
@ -682,57 +656,87 @@ class DownloadQueueService:
return deleted
@staticmethod
async def clear_completed(db: AsyncSession) -> int:
"""Clear completed downloads from queue.
async def delete_by_episode(
db: AsyncSession,
episode_id: int,
) -> bool:
"""Delete download queue item by episode ID.
Args:
db: Database session
episode_id: Foreign key to Episode
Returns:
Number of items cleared
True if deleted, False if not found
"""
result = await db.execute(
delete(DownloadQueueItem).where(
DownloadQueueItem.status == DownloadStatus.COMPLETED
DownloadQueueItem.episode_id == episode_id
)
)
count = result.rowcount
logger.info(f"Cleared {count} completed downloads from queue")
return count
deleted = result.rowcount > 0
if deleted:
logger.info(
f"Deleted download queue item with episode_id={episode_id}"
)
return deleted
@staticmethod
async def retry_failed(
async def bulk_delete(
db: AsyncSession,
max_retries: int = 3,
) -> List[DownloadQueueItem]:
"""Retry failed downloads that haven't exceeded max retries.
item_ids: List[int],
) -> int:
"""Delete multiple download queue items atomically.
This operation should be wrapped in a transaction for atomicity.
All items will be deleted or none if an error occurs.
Args:
db: Database session
max_retries: Maximum number of retry attempts
item_ids: List of item primary keys to delete
Returns:
List of items marked for retry
Number of items deleted
Note:
Use within @transactional or atomic() for guaranteed atomicity:
async with atomic(db) as tx:
count = await DownloadQueueService.bulk_delete(db, item_ids)
"""
if not item_ids:
return 0
result = await db.execute(
select(DownloadQueueItem).where(
DownloadQueueItem.status == DownloadStatus.FAILED,
DownloadQueueItem.retry_count < max_retries,
delete(DownloadQueueItem).where(
DownloadQueueItem.id.in_(item_ids)
)
)
items = list(result.scalars().all())
for item in items:
item.status = DownloadStatus.PENDING
item.error_message = None
item.progress_percent = 0.0
item.downloaded_bytes = 0
item.started_at = None
item.completed_at = None
count = result.rowcount
logger.info(f"Bulk deleted {count} download queue items")
await db.flush()
logger.info(f"Marked {len(items)} failed downloads for retry")
return items
return count
@staticmethod
async def clear_all(
db: AsyncSession,
) -> int:
"""Clear all download queue items.
Deletes all items from the download queue. This operation
should be wrapped in a transaction.
Args:
db: Database session
Returns:
Number of items deleted
"""
result = await db.execute(delete(DownloadQueueItem))
count = result.rowcount
logger.info(f"Cleared all {count} download queue items")
return count
# ============================================================================
@ -744,6 +748,10 @@ class UserSessionService:
"""Service for user session CRUD operations.
Provides methods for managing user authentication sessions with JWT tokens.
Transaction Support:
Session rotation and cleanup operations should use transactions
for atomicity when multiple sessions are involved.
"""
@staticmethod
@ -875,6 +883,9 @@ class UserSessionService:
async def cleanup_expired(db: AsyncSession) -> int:
"""Clean up expired sessions.
This is a bulk delete operation that should be wrapped in
a transaction for atomicity when multiple sessions are deleted.
Args:
db: Database session
@ -889,3 +900,66 @@ class UserSessionService:
count = result.rowcount
logger.info(f"Cleaned up {count} expired sessions")
return count
@staticmethod
async def rotate_session(
db: AsyncSession,
old_session_id: str,
new_session_id: str,
new_token_hash: str,
new_expires_at: datetime,
user_id: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
) -> Optional[UserSession]:
"""Rotate a session by revoking old and creating new atomically.
This compound operation revokes the old session and creates a new
one. Should be wrapped in a transaction for atomicity.
Args:
db: Database session
old_session_id: Session ID to revoke
new_session_id: New session ID
new_token_hash: New token hash
new_expires_at: New expiration time
user_id: Optional user identifier
ip_address: Optional client IP
user_agent: Optional user agent
Returns:
New UserSession instance, or None if old session not found
Note:
Use within @transactional or atomic() for atomicity:
async with atomic(db) as tx:
new_session = await UserSessionService.rotate_session(
db, old_id, new_id, hash, expires
)
"""
# Revoke old session
old_revoked = await UserSessionService.revoke(db, old_session_id)
if not old_revoked:
logger.warning(
f"Could not rotate: old session {old_session_id} not found"
)
return None
# Create new session
new_session = await UserSessionService.create(
db=db,
session_id=new_session_id,
token_hash=new_token_hash,
expires_at=new_expires_at,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
)
logger.info(
f"Rotated session: {old_session_id} -> {new_session_id}"
)
return new_session

View File

@ -0,0 +1,715 @@
"""Transaction management utilities for SQLAlchemy.
This module provides transaction management utilities including decorators,
context managers, and helper functions for ensuring data consistency
across database operations.
Components:
- @transactional decorator: Wraps functions in transaction boundaries
- TransactionContext: Sync context manager for explicit transaction control
- atomic(): Async context manager for async operations
- TransactionPropagation: Enum for transaction propagation modes
Usage:
@transactional
async def compound_operation(session: AsyncSession, data: Model) -> Result:
# Multiple write operations here
# All succeed or all fail
pass
async with atomic(session) as tx:
# Operations here
async with tx.savepoint() as sp:
# Nested operations with partial rollback capability
pass
"""
from __future__ import annotations
import functools
import logging
from contextlib import asynccontextmanager, contextmanager
from enum import Enum
from typing import (
Any,
AsyncGenerator,
Callable,
Generator,
Optional,
ParamSpec,
TypeVar,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Type variables for generic typing
T = TypeVar("T")
P = ParamSpec("P")
class TransactionPropagation(Enum):
"""Transaction propagation behavior options.
Defines how transactions should behave when called within
an existing transaction context.
Values:
REQUIRED: Use existing transaction or create new one (default)
REQUIRES_NEW: Always create a new transaction (suspend existing)
NESTED: Create a savepoint within existing transaction
"""
REQUIRED = "required"
REQUIRES_NEW = "requires_new"
NESTED = "nested"
class TransactionError(Exception):
"""Exception raised for transaction-related errors."""
class TransactionContext:
"""Synchronous context manager for explicit transaction control.
Provides a clean interface for managing database transactions with
automatic commit/rollback semantics and savepoint support.
Attributes:
session: SQLAlchemy Session instance
_savepoint_count: Counter for nested savepoints
Example:
with TransactionContext(session) as tx:
# Database operations here
with tx.savepoint() as sp:
# Nested operations with partial rollback
pass
"""
def __init__(self, session: Session) -> None:
"""Initialize transaction context.
Args:
session: SQLAlchemy sync session
"""
self.session = session
self._savepoint_count = 0
self._committed = False
def __enter__(self) -> "TransactionContext":
"""Enter transaction context.
Begins a new transaction if not already in one.
Returns:
Self for context manager protocol
"""
logger.debug("Entering transaction context")
# Check if session is already in a transaction
if not self.session.in_transaction():
self.session.begin()
logger.debug("Started new transaction")
return self
def __exit__(
self,
exc_type: Optional[type],
exc_val: Optional[BaseException],
exc_tb: Optional[Any],
) -> bool:
"""Exit transaction context.
Commits on success, rolls back on exception.
Args:
exc_type: Exception type if raised
exc_val: Exception value if raised
exc_tb: Exception traceback if raised
Returns:
False to propagate exceptions
"""
if exc_type is not None:
logger.warning(
"Transaction rollback due to exception: %s: %s",
exc_type.__name__,
exc_val,
)
self.session.rollback()
return False
if not self._committed:
self.session.commit()
logger.debug("Transaction committed")
self._committed = True
return False
@contextmanager
def savepoint(self, name: Optional[str] = None) -> Generator["SavepointContext", None, None]:
"""Create a savepoint for partial rollback capability.
Savepoints allow nested transactions where inner operations
can be rolled back without affecting outer operations.
Args:
name: Optional savepoint name (auto-generated if not provided)
Yields:
SavepointContext for nested transaction control
Example:
with tx.savepoint() as sp:
# Operations here can be rolled back independently
if error_condition:
sp.rollback()
"""
self._savepoint_count += 1
savepoint_name = name or f"sp_{self._savepoint_count}"
logger.debug("Creating savepoint: %s", savepoint_name)
nested = self.session.begin_nested()
sp_context = SavepointContext(nested, savepoint_name)
try:
yield sp_context
if not sp_context._rolled_back:
# Commit the savepoint (release it)
logger.debug("Releasing savepoint: %s", savepoint_name)
except Exception as e:
if not sp_context._rolled_back:
logger.warning(
"Rolling back savepoint %s due to exception: %s",
savepoint_name,
e,
)
nested.rollback()
raise
def commit(self) -> None:
"""Explicitly commit the transaction.
Use this for early commit within the context.
"""
if not self._committed:
self.session.commit()
self._committed = True
logger.debug("Transaction explicitly committed")
def rollback(self) -> None:
"""Explicitly rollback the transaction.
Use this for early rollback within the context.
"""
self.session.rollback()
self._committed = True # Prevent double commit
logger.debug("Transaction explicitly rolled back")
class SavepointContext:
"""Context for managing a database savepoint.
Provides explicit control over savepoint commit/rollback.
Attributes:
_nested: SQLAlchemy nested transaction object
_name: Savepoint name for logging
_rolled_back: Whether rollback has been called
"""
def __init__(self, nested: Any, name: str) -> None:
"""Initialize savepoint context.
Args:
nested: SQLAlchemy nested transaction
name: Savepoint name for logging
"""
self._nested = nested
self._name = name
self._rolled_back = False
def rollback(self) -> None:
"""Rollback to this savepoint.
Undoes all changes since the savepoint was created.
"""
if not self._rolled_back:
self._nested.rollback()
self._rolled_back = True
logger.debug("Savepoint %s rolled back", self._name)
def commit(self) -> None:
"""Commit (release) this savepoint.
Makes changes since the savepoint permanent within
the parent transaction.
"""
if not self._rolled_back:
# SQLAlchemy commits nested transactions automatically
# when exiting the context without rollback
logger.debug("Savepoint %s committed", self._name)
class AsyncTransactionContext:
"""Asynchronous context manager for explicit transaction control.
Provides async interface for managing database transactions with
automatic commit/rollback semantics and savepoint support.
Attributes:
session: SQLAlchemy AsyncSession instance
_savepoint_count: Counter for nested savepoints
Example:
async with AsyncTransactionContext(session) as tx:
# Database operations here
async with tx.savepoint() as sp:
# Nested operations with partial rollback
pass
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize async transaction context.
Args:
session: SQLAlchemy async session
"""
self.session = session
self._savepoint_count = 0
self._committed = False
async def __aenter__(self) -> "AsyncTransactionContext":
"""Enter async transaction context.
Begins a new transaction if not already in one.
Returns:
Self for context manager protocol
"""
logger.debug("Entering async transaction context")
# Check if session is already in a transaction
if not self.session.in_transaction():
await self.session.begin()
logger.debug("Started new async transaction")
return self
async def __aexit__(
self,
exc_type: Optional[type],
exc_val: Optional[BaseException],
exc_tb: Optional[Any],
) -> bool:
"""Exit async transaction context.
Commits on success, rolls back on exception.
Args:
exc_type: Exception type if raised
exc_val: Exception value if raised
exc_tb: Exception traceback if raised
Returns:
False to propagate exceptions
"""
if exc_type is not None:
logger.warning(
"Async transaction rollback due to exception: %s: %s",
exc_type.__name__,
exc_val,
)
await self.session.rollback()
return False
if not self._committed:
await self.session.commit()
logger.debug("Async transaction committed")
self._committed = True
return False
@asynccontextmanager
async def savepoint(
self, name: Optional[str] = None
) -> AsyncGenerator["AsyncSavepointContext", None]:
"""Create an async savepoint for partial rollback capability.
Args:
name: Optional savepoint name (auto-generated if not provided)
Yields:
AsyncSavepointContext for nested transaction control
"""
self._savepoint_count += 1
savepoint_name = name or f"sp_{self._savepoint_count}"
logger.debug("Creating async savepoint: %s", savepoint_name)
nested = await self.session.begin_nested()
sp_context = AsyncSavepointContext(nested, savepoint_name, self.session)
try:
yield sp_context
if not sp_context._rolled_back:
logger.debug("Releasing async savepoint: %s", savepoint_name)
except Exception as e:
if not sp_context._rolled_back:
logger.warning(
"Rolling back async savepoint %s due to exception: %s",
savepoint_name,
e,
)
await nested.rollback()
raise
async def commit(self) -> None:
"""Explicitly commit the async transaction."""
if not self._committed:
await self.session.commit()
self._committed = True
logger.debug("Async transaction explicitly committed")
async def rollback(self) -> None:
"""Explicitly rollback the async transaction."""
await self.session.rollback()
self._committed = True # Prevent double commit
logger.debug("Async transaction explicitly rolled back")
class AsyncSavepointContext:
"""Async context for managing a database savepoint.
Attributes:
_nested: SQLAlchemy nested transaction object
_name: Savepoint name for logging
_session: Parent session for async operations
_rolled_back: Whether rollback has been called
"""
def __init__(
self, nested: Any, name: str, session: AsyncSession
) -> None:
"""Initialize async savepoint context.
Args:
nested: SQLAlchemy nested transaction
name: Savepoint name for logging
session: Parent async session
"""
self._nested = nested
self._name = name
self._session = session
self._rolled_back = False
async def rollback(self) -> None:
"""Rollback to this savepoint asynchronously."""
if not self._rolled_back:
await self._nested.rollback()
self._rolled_back = True
logger.debug("Async savepoint %s rolled back", self._name)
async def commit(self) -> None:
"""Commit (release) this savepoint asynchronously."""
if not self._rolled_back:
logger.debug("Async savepoint %s committed", self._name)
@asynccontextmanager
async def atomic(
session: AsyncSession,
propagation: TransactionPropagation = TransactionPropagation.REQUIRED,
) -> AsyncGenerator[AsyncTransactionContext, None]:
"""Async context manager for atomic database operations.
Provides a clean interface for wrapping database operations in
a transaction boundary with automatic commit/rollback.
Args:
session: SQLAlchemy async session
propagation: Transaction propagation behavior
Yields:
AsyncTransactionContext for transaction control
Example:
async with atomic(session) as tx:
await some_operation(session)
await another_operation(session)
# All operations committed together or rolled back
async with atomic(session) as tx:
await outer_operation(session)
async with tx.savepoint() as sp:
await risky_operation(session)
if error:
await sp.rollback() # Only rollback nested ops
"""
logger.debug(
"Starting atomic block with propagation: %s",
propagation.value,
)
if propagation == TransactionPropagation.NESTED:
# Use savepoint for nested propagation
if session.in_transaction():
nested = await session.begin_nested()
sp_context = AsyncSavepointContext(nested, "atomic_nested", session)
try:
# Create a wrapper context for consistency
wrapper = AsyncTransactionContext(session)
wrapper._committed = True # Parent manages commit
yield wrapper
if not sp_context._rolled_back:
logger.debug("Releasing nested atomic savepoint")
except Exception as e:
if not sp_context._rolled_back:
logger.warning(
"Rolling back nested atomic savepoint due to: %s", e
)
await nested.rollback()
raise
else:
# No existing transaction, start new one
async with AsyncTransactionContext(session) as tx:
yield tx
else:
# REQUIRED or REQUIRES_NEW
async with AsyncTransactionContext(session) as tx:
yield tx
@contextmanager
def atomic_sync(
session: Session,
propagation: TransactionPropagation = TransactionPropagation.REQUIRED,
) -> Generator[TransactionContext, None, None]:
"""Sync context manager for atomic database operations.
Args:
session: SQLAlchemy sync session
propagation: Transaction propagation behavior
Yields:
TransactionContext for transaction control
"""
logger.debug(
"Starting sync atomic block with propagation: %s",
propagation.value,
)
if propagation == TransactionPropagation.NESTED:
if session.in_transaction():
nested = session.begin_nested()
sp_context = SavepointContext(nested, "atomic_nested")
try:
wrapper = TransactionContext(session)
wrapper._committed = True
yield wrapper
if not sp_context._rolled_back:
logger.debug("Releasing nested sync atomic savepoint")
except Exception as e:
if not sp_context._rolled_back:
logger.warning(
"Rolling back nested sync savepoint due to: %s", e
)
nested.rollback()
raise
else:
with TransactionContext(session) as tx:
yield tx
else:
with TransactionContext(session) as tx:
yield tx
def transactional(
propagation: TransactionPropagation = TransactionPropagation.REQUIRED,
session_param: str = "db",
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator to wrap a function in a transaction boundary.
Automatically handles commit on success and rollback on exception.
Works with both sync and async functions.
Args:
propagation: Transaction propagation behavior
session_param: Name of the session parameter in the function signature
Returns:
Decorated function wrapped in transaction
Example:
@transactional()
async def create_user_with_profile(db: AsyncSession, data: dict):
user = await create_user(db, data['user'])
profile = await create_profile(db, user.id, data['profile'])
return user, profile
@transactional(propagation=TransactionPropagation.NESTED)
async def risky_sub_operation(db: AsyncSession, data: dict):
# This can be rolled back without affecting parent transaction
pass
"""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
import asyncio
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
# Get session from kwargs or args
session = _extract_session(func, args, kwargs, session_param)
if session is None:
raise TransactionError(
f"Could not find session parameter '{session_param}' "
f"in function {func.__name__}"
)
logger.debug(
"Starting transaction for %s with propagation %s",
func.__name__,
propagation.value,
)
async with atomic(session, propagation):
result = await func(*args, **kwargs)
logger.debug(
"Transaction completed for %s",
func.__name__,
)
return result
return async_wrapper # type: ignore
else:
@functools.wraps(func)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
# Get session from kwargs or args
session = _extract_session(func, args, kwargs, session_param)
if session is None:
raise TransactionError(
f"Could not find session parameter '{session_param}' "
f"in function {func.__name__}"
)
logger.debug(
"Starting sync transaction for %s with propagation %s",
func.__name__,
propagation.value,
)
with atomic_sync(session, propagation):
result = func(*args, **kwargs)
logger.debug(
"Sync transaction completed for %s",
func.__name__,
)
return result
return sync_wrapper # type: ignore
return decorator
def _extract_session(
func: Callable,
args: tuple,
kwargs: dict,
session_param: str,
) -> Optional[AsyncSession | Session]:
"""Extract session from function arguments.
Args:
func: The function being called
args: Positional arguments
kwargs: Keyword arguments
session_param: Name of the session parameter
Returns:
Session instance or None if not found
"""
import inspect
# Check kwargs first
if session_param in kwargs:
return kwargs[session_param]
# Get function signature to find positional index
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if session_param in params:
idx = params.index(session_param)
# Account for 'self' parameter in methods
if len(args) > idx:
return args[idx]
return None
def is_in_transaction(session: AsyncSession | Session) -> bool:
"""Check if session is currently in a transaction.
Args:
session: SQLAlchemy session (sync or async)
Returns:
True if session is in an active transaction
"""
return session.in_transaction()
def get_transaction_depth(session: AsyncSession | Session) -> int:
"""Get the current transaction nesting depth.
Args:
session: SQLAlchemy session (sync or async)
Returns:
Number of nested transactions (0 if not in transaction)
"""
# SQLAlchemy doesn't expose nesting depth directly,
# but we can check transaction state
if not session.in_transaction():
return 0
# Check for nested transaction
if hasattr(session, '_nested_transaction') and session._nested_transaction:
return 2 # At least one savepoint
return 1
__all__ = [
"TransactionPropagation",
"TransactionError",
"TransactionContext",
"AsyncTransactionContext",
"SavepointContext",
"AsyncSavepointContext",
"atomic",
"atomic_sync",
"transactional",
"is_in_transaction",
"get_transaction_depth",
]

View File

@ -144,6 +144,23 @@ class ConflictError(AniWorldAPIException):
)
class BadRequestError(AniWorldAPIException):
"""Exception raised for bad request (400) errors."""
def __init__(
self,
message: str = "Bad request",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize bad request error."""
super().__init__(
message=message,
status_code=400,
error_code="BAD_REQUEST",
details=details,
)
class RateLimitError(AniWorldAPIException):
"""Exception raised when rate limit is exceeded."""

View File

@ -5,6 +5,7 @@ This module provides the main FastAPI application with proper CORS
configuration, middleware setup, static file serving, and Jinja2 template
integration.
"""
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
@ -21,6 +22,7 @@ from src.server.api.anime import router as anime_router
from src.server.api.auth import router as auth_router
from src.server.api.config import router as config_router
from src.server.api.download import router as download_router
from src.server.api.health import router as health_router
from src.server.api.scheduler import router as scheduler_router
from src.server.api.websocket import router as websocket_router
from src.server.controllers.error_controller import (
@ -29,11 +31,11 @@ from src.server.controllers.error_controller import (
)
# 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
from src.server.middleware.error_handler import register_exception_handlers
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
from src.server.services.anime_service import sync_series_from_data_files
from src.server.services.progress_service import get_progress_service
from src.server.services.websocket_service import get_websocket_service
@ -42,29 +44,54 @@ from src.server.services.websocket_service import get_websocket_service
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifespan (startup and shutdown)."""
# Setup logging first with DEBUG level
logger = setup_logging(log_level="DEBUG")
async def lifespan(_application: FastAPI):
"""Manage application lifespan (startup and shutdown).
Args:
_application: The FastAPI application instance (unused but required
by the lifespan protocol).
"""
# Setup logging first with INFO level
logger = setup_logging(log_level="INFO")
# Startup
try:
logger.info("Starting FastAPI application...")
# Initialize database first (required for other services)
try:
from src.server.database.connection import init_db
await init_db()
logger.info("Database initialized successfully")
except Exception as e:
logger.error("Failed to initialize database: %s", e, exc_info=True)
raise # Database is required, fail startup if it fails
# Load configuration from config.json and sync with settings
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
logger.debug(
"Config loaded: other=%s", config.other
)
# Sync anime_directory from config.json to settings
if config.other and config.other.get("anime_directory"):
settings.anime_directory = str(config.other["anime_directory"])
# config.other is Dict[str, object] - pylint doesn't infer this
other_settings = dict(config.other) if config.other else {}
if other_settings.get("anime_directory"):
anime_dir = other_settings["anime_directory"]
settings.anime_directory = str(anime_dir)
logger.info(
"Loaded anime_directory from config: %s",
settings.anime_directory
)
except Exception as e:
else:
logger.debug(
"anime_directory not found in config.other"
)
except (OSError, ValueError, KeyError) as e:
logger.warning("Failed to load config from config.json: %s", e)
# Initialize progress service with event subscription
@ -86,6 +113,37 @@ async def lifespan(app: FastAPI):
# Subscribe to progress events
progress_service.subscribe("progress_updated", progress_event_handler)
# Initialize download service and restore queue from database
# Only if anime directory is configured
try:
from src.server.utils.dependencies import get_download_service
logger.info(
"Checking anime_directory setting: '%s'",
settings.anime_directory
)
if settings.anime_directory:
download_service = get_download_service()
await download_service.initialize()
logger.info("Download service initialized and queue restored")
# Sync series from data files to database
sync_count = await sync_series_from_data_files(
settings.anime_directory
)
logger.info(
"Data file sync complete. Added %d series.", sync_count
)
else:
logger.info(
"Download service initialization skipped - "
"anime directory not configured"
)
except (OSError, RuntimeError, ValueError) as e:
logger.warning("Failed to initialize download service: %s", e)
# Continue startup - download service can be initialized later
logger.info("FastAPI application started successfully")
logger.info("Server running on http://127.0.0.1:8000")
logger.info(
@ -98,20 +156,88 @@ async def lifespan(app: FastAPI):
# Yield control to the application
yield
# Shutdown
logger.info("FastAPI application shutting down")
# Shutdown - execute in proper order with timeout protection
logger.info("FastAPI application shutting down (graceful shutdown initiated)")
# Shutdown download service and its thread pool
# Define shutdown timeout (total time allowed for all shutdown operations)
SHUTDOWN_TIMEOUT = 30.0
import time
shutdown_start = time.monotonic()
def remaining_time() -> float:
"""Calculate remaining shutdown time."""
elapsed = time.monotonic() - shutdown_start
return max(0.0, SHUTDOWN_TIMEOUT - elapsed)
# 1. Broadcast shutdown notification via WebSocket
try:
from src.server.services.download_service import _download_service_instance
ws_service = get_websocket_service()
logger.info("Broadcasting shutdown notification to WebSocket clients...")
await asyncio.wait_for(
ws_service.shutdown(timeout=min(5.0, remaining_time())),
timeout=min(5.0, remaining_time())
)
logger.info("WebSocket shutdown complete")
except asyncio.TimeoutError:
logger.warning("WebSocket shutdown timed out")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error during WebSocket shutdown: %s", e, exc_info=True)
# 2. Shutdown download service and persist active downloads
try:
from src.server.services.download_service import ( # noqa: E501
_download_service_instance,
)
if _download_service_instance is not None:
logger.info("Stopping download service...")
await _download_service_instance.stop()
logger.info("Download service stopped successfully")
except Exception as e:
except asyncio.TimeoutError:
logger.warning("Download service shutdown timed out")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error stopping download service: %s", e, exc_info=True)
logger.info("FastAPI application shutdown complete")
# 3. Shutdown SeriesApp and cleanup thread pool
try:
from src.server.utils.dependencies import _series_app
if _series_app is not None:
logger.info("Shutting down SeriesApp thread pool...")
_series_app.shutdown()
logger.info("SeriesApp shutdown complete")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error during SeriesApp shutdown: %s", e, exc_info=True)
# 4. Cleanup progress service
try:
progress_service = get_progress_service()
logger.info("Cleaning up progress service...")
# Clear any active progress tracking and subscribers
progress_service._active_progress.clear()
logger.info("Progress service cleanup complete")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(
"Error cleaning up progress service: %s", e, exc_info=True
)
# 5. Close database connections with WAL checkpoint
try:
from src.server.database.connection import close_db
logger.info("Closing database connections...")
await asyncio.wait_for(
close_db(),
timeout=min(10.0, remaining_time())
)
logger.info("Database connections closed")
except asyncio.TimeoutError:
logger.warning("Database shutdown timed out")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error closing database: %s", e, exc_info=True)
elapsed_total = time.monotonic() - shutdown_start
logger.info(
"FastAPI application shutdown complete (took %.2fs)",
elapsed_total
)
# Initialize FastAPI app with lifespan
@ -180,5 +306,5 @@ if __name__ == "__main__":
host="127.0.0.1",
port=8000,
reload=True,
log_level="debug"
log_level="info"
)

View File

@ -8,6 +8,17 @@ Responsibilities:
This middleware is intentionally lightweight and synchronous.
For production use consider a distributed rate limiter (Redis) and
a proper token revocation store.
WARNING - SINGLE PROCESS LIMITATION:
Rate limiting state is stored in memory dictionaries which RESET when
the process restarts. This means:
- Attackers can bypass rate limits by triggering a process restart
- Rate limits are not shared across multiple workers/processes
For production deployments, consider:
- Using Redis-backed rate limiting (e.g., slowapi with Redis)
- Running behind a reverse proxy with rate limiting (nginx, HAProxy)
- Using a dedicated rate limiting service
"""
from __future__ import annotations

View File

@ -15,6 +15,7 @@ from src.server.exceptions import (
AniWorldAPIException,
AuthenticationError,
AuthorizationError,
BadRequestError,
ConflictError,
NotFoundError,
RateLimitError,
@ -127,6 +128,26 @@ def register_exception_handlers(app: FastAPI) -> None:
),
)
@app.exception_handler(BadRequestError)
async def bad_request_error_handler(
request: Request, exc: BadRequestError
) -> JSONResponse:
"""Handle bad request errors (400)."""
logger.info(
f"Bad request error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(NotFoundError)
async def not_found_error_handler(
request: Request, exc: NotFoundError

View File

@ -11,7 +11,7 @@ from typing import Callable
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
from starlette.responses import RedirectResponse, Response
from starlette.types import ASGIApp
from src.server.services.auth_service import auth_service
@ -91,11 +91,11 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
config = config_service.load_config()
# Validate the loaded config
validation = config.validate()
validation = config.validate_config()
if not validation.valid:
return True
except Exception:
except (FileNotFoundError, ValueError, OSError, AttributeError):
# If we can't load or validate config, setup is needed
return True
@ -103,7 +103,7 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable
) -> RedirectResponse:
) -> Response:
"""Process the request and redirect to setup if needed.
Args:

View File

@ -70,8 +70,6 @@ class AnimeSeriesResponse(BaseModel):
)
)
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
description: Optional[str] = Field(None, description="Short series description")
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
episodes: List[EpisodeInfo] = Field(default_factory=list, description="Known episodes information")
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")

View File

@ -58,8 +58,9 @@ class ValidationResult(BaseModel):
"""Result of a configuration validation attempt."""
valid: bool = Field(..., description="Whether the configuration is valid")
errors: Optional[List[str]] = Field(
default_factory=list, description="List of validation error messages"
errors: List[str] = Field(
default_factory=lambda: [],
description="List of validation error messages"
)
@ -71,14 +72,16 @@ class AppConfig(BaseModel):
name: str = Field(default="Aniworld", description="Application name")
data_dir: str = Field(default="data", description="Base data directory")
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
scheduler: SchedulerConfig = Field(
default_factory=SchedulerConfig
)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig)
other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings"
)
def validate(self) -> ValidationResult:
def validate_config(self) -> ValidationResult:
"""Perform light-weight validation and return a ValidationResult.
This method intentionally avoids performing IO (no filesystem checks)
@ -98,7 +101,8 @@ class AppConfig(BaseModel):
errors.append(msg)
# backup.path must be set when backups are enabled
if self.backup.enabled and (not self.backup.path):
backup_data = self.model_dump().get("backup", {})
if backup_data.get("enabled") and not backup_data.get("path"):
errors.append(
"backup.path must be set when backups.enabled is true"
)

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
from functools import lru_cache
from typing import Optional
@ -12,6 +13,10 @@ from src.server.services.progress_service import (
ProgressType,
get_progress_service,
)
from src.server.services.websocket_service import (
WebSocketService,
get_websocket_service,
)
logger = structlog.get_logger(__name__)
@ -37,21 +42,37 @@ class AnimeService:
self,
series_app: SeriesApp,
progress_service: Optional[ProgressService] = None,
websocket_service: Optional[WebSocketService] = None,
):
self._app = series_app
self._directory = series_app.directory_to_search
self._progress_service = progress_service or get_progress_service()
self._websocket_service = websocket_service or get_websocket_service()
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Track scan progress for WebSocket updates
self._scan_start_time: Optional[float] = None
self._scan_directories_count: int = 0
self._scan_files_count: int = 0
self._scan_total_items: int = 0
self._is_scanning: bool = False
self._scan_current_directory: str = ""
# Lock to prevent concurrent rescans
self._scan_lock = asyncio.Lock()
# Subscribe to SeriesApp events
# Note: Events library uses assignment (=), not += operator
try:
self._app.download_status = self._on_download_status
self._app.scan_status = self._on_scan_status
logger.debug("Successfully subscribed to SeriesApp events")
logger.info(
"Subscribed to SeriesApp events",
scan_status_handler=str(self._app.scan_status),
series_app_id=id(self._app),
)
except Exception as e:
logger.exception("Failed to subscribe to SeriesApp events")
raise AnimeServiceError("Initialization failed") from e
def _on_download_status(self, args) -> None:
"""Handle download status events from SeriesApp.
@ -142,7 +163,7 @@ class AnimeService:
),
loop
)
except Exception as exc:
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error handling download status event",
error=str(exc)
@ -152,7 +173,8 @@ class AnimeService:
"""Handle scan status events from SeriesApp.
Events include both 'key' (primary identifier) and 'folder'
(metadata for display purposes).
(metadata for display purposes). Also broadcasts via WebSocket
for real-time UI updates.
Args:
args: ScanStatusEventArgs from SeriesApp containing key,
@ -161,23 +183,50 @@ class AnimeService:
try:
scan_id = "library_scan"
logger.info(
"Scan status event received",
status=args.status,
current=args.current,
total=args.total,
folder=args.folder,
)
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
logger.debug("Using running event loop for scan status")
except RuntimeError:
# No running loop in this thread - use stored loop
loop = self._event_loop
logger.debug(
"Using stored event loop for scan status",
has_loop=loop is not None
)
if not loop:
logger.debug(
logger.warning(
"No event loop available for scan status event",
status=args.status
)
return
logger.info(
"Processing scan status event",
status=args.status,
loop_id=id(loop),
)
# Map SeriesApp scan events to progress service
if args.status == "started":
# Track scan start time and reset counters
self._scan_start_time = time.time()
self._scan_directories_count = 0
self._scan_files_count = 0
self._scan_total_items = args.total
self._is_scanning = True
self._scan_current_directory = ""
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=scan_id,
@ -187,7 +236,18 @@ class AnimeService:
),
loop
)
# Broadcast scan started via WebSocket with total items
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_started_safe(total_items=args.total),
loop
)
elif args.status == "progress":
# Update scan counters
self._scan_directories_count = args.current
self._scan_current_directory = args.folder or ""
# Estimate files found (use current as proxy since detailed
# file count isn't available from SerieScanner)
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=scan_id,
@ -197,7 +257,25 @@ class AnimeService:
),
loop
)
# Broadcast scan progress via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_progress_safe(
directories_scanned=args.current,
files_found=args.current, # Use folder count as proxy
current_directory=args.folder or "",
total_items=args.total,
),
loop
)
elif args.status == "completed":
# Calculate elapsed time
elapsed = 0.0
if self._scan_start_time:
elapsed = time.time() - self._scan_start_time
# Mark scan as complete
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=scan_id,
@ -205,7 +283,17 @@ class AnimeService:
),
loop
)
# Broadcast scan completed via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_completed_safe(
total_directories=args.total,
total_files=args.total, # Use folder count as proxy
elapsed_seconds=elapsed,
),
loop
)
elif args.status == "failed":
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
@ -214,6 +302,7 @@ class AnimeService:
loop
)
elif args.status == "cancelled":
self._is_scanning = False
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
@ -221,8 +310,119 @@ class AnimeService:
),
loop
)
except Exception as exc: # pylint: disable=broad-except
logger.error("Error handling scan status event: %s", exc)
async def _broadcast_scan_started_safe(self, total_items: int = 0) -> None:
"""Safely broadcast scan started event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
total_items: Total number of items to scan
"""
try:
logger.info(
"Broadcasting scan_started via WebSocket",
directory=self._directory,
total_items=total_items,
)
await self._websocket_service.broadcast_scan_started(
directory=self._directory,
total_items=total_items,
)
logger.info("scan_started broadcast sent successfully")
except Exception as exc:
logger.error("Error handling scan status event", error=str(exc))
logger.warning(
"Failed to broadcast scan_started via WebSocket",
error=str(exc)
)
async def _broadcast_scan_progress_safe(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
total_items: int = 0,
) -> None:
"""Safely broadcast scan progress event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
directories_scanned: Number of directories scanned so far
files_found: Number of files found so far
current_directory: Current directory being scanned
total_items: Total number of items to scan
"""
try:
await self._websocket_service.broadcast_scan_progress(
directories_scanned=directories_scanned,
files_found=files_found,
current_directory=current_directory,
total_items=total_items,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_progress via WebSocket",
error=str(exc)
)
async def _broadcast_scan_completed_safe(
self,
total_directories: int,
total_files: int,
elapsed_seconds: float,
) -> None:
"""Safely broadcast scan completed event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
cleanup continues even if WebSocket fails.
Args:
total_directories: Total directories scanned
total_files: Total files found
elapsed_seconds: Time taken for the scan
"""
try:
await self._websocket_service.broadcast_scan_completed(
total_directories=total_directories,
total_files=total_files,
elapsed_seconds=elapsed_seconds,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_completed via WebSocket",
error=str(exc)
)
def get_scan_status(self) -> dict:
"""Get the current scan status.
Returns:
Dictionary with scan status information including:
- is_scanning: Whether a scan is currently in progress
- total_items: Total number of items to scan
- directories_scanned: Number of directories scanned so far
- current_directory: Current directory being scanned
- directory: Root directory being scanned
"""
status = {
"is_scanning": self._is_scanning,
"total_items": self._scan_total_items,
"directories_scanned": self._scan_directories_count,
"current_directory": self._scan_current_directory,
"directory": self._directory,
}
logger.debug(
"Scan status requested",
is_scanning=self._is_scanning,
total_items=self._scan_total_items,
directories_scanned=self._scan_directories_count,
)
return status
@lru_cache(maxsize=128)
def _cached_list_missing(self) -> list[dict]:
@ -288,25 +488,322 @@ class AnimeService:
The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
After scanning, results are persisted to the database.
All series are identified by their 'key' (provider identifier),
with 'folder' stored as metadata.
Note:
Only one scan can run at a time. If a scan is already in
progress, this method returns immediately without starting
a new scan.
"""
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
# SeriesApp.rescan is now async and handles events internally
await self._app.rescan()
# invalidate cache
# Check if a scan is already running (non-blocking)
if self._scan_lock.locked():
logger.info("Rescan already in progress, ignoring request")
return
async with self._scan_lock:
try:
self._cached_list_missing.cache_clear()
except Exception:
pass
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
logger.info(
"Rescan started, event loop stored",
loop_id=id(self._event_loop),
series_app_id=id(self._app),
scan_handler=str(self._app.scan_status),
)
# SeriesApp.rescan returns scanned series list
scanned_series = await self._app.rescan()
# Persist scan results to database
if scanned_series:
await self._save_scan_results_to_db(scanned_series)
# Reload series from database to ensure consistency
await self._load_series_from_db()
except Exception as exc:
logger.exception("rescan failed")
raise AnimeServiceError("Rescan failed") from exc
# invalidate cache
try:
self._cached_list_missing.cache_clear()
except Exception: # pylint: disable=broad-except
pass
except Exception as exc: # pylint: disable=broad-except
logger.exception("rescan failed")
raise AnimeServiceError("Rescan failed") from exc
async def _save_scan_results_to_db(self, series_list: list) -> int:
"""
Save scan results to the database.
Creates or updates series records in the database based on
scan results.
Args:
series_list: List of Serie objects from scan
Returns:
Number of series saved/updated
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
saved_count = 0
async with get_db_session() as db:
for serie in series_list:
try:
# Check if series already exists
existing = await AnimeSeriesService.get_by_key(
db, serie.key
)
if existing:
# Update existing series
await self._update_series_in_db(
serie, existing, db
)
else:
# Create new series
await self._create_series_in_db(serie, db)
saved_count += 1
except Exception as e: # pylint: disable=broad-except
logger.warning(
"Failed to save series to database: %s (key=%s) - %s",
serie.name,
serie.key,
str(e)
)
logger.info(
"Saved %d series to database from scan results",
saved_count
)
return saved_count
async def _create_series_in_db(self, serie, db) -> None:
"""Create a new series in the database."""
from src.server.database.service import AnimeSeriesService, EpisodeService
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for ep_num in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep_num,
)
logger.debug(
"Created series in database: %s (key=%s)",
serie.name,
serie.key
)
async def _update_series_in_db(self, serie, existing, db) -> None:
"""Update an existing series in the database.
Syncs the database episodes with the current missing episodes from scan.
- Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem)
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Get existing episodes from database
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}}
existing_dict: dict[int, dict[int, int]] = {}
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id
# Get new missing episodes from scan
new_dict = serie.episodeDict or {}
# Build set of new missing episodes for quick lookup
new_missing_set: set[tuple[int, int]] = set()
for season, episode_numbers in new_dict.items():
for ep_num in episode_numbers:
new_missing_set.add((season, ep_num))
# Add new missing episodes that are not in the database
for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers:
if ep_num not in existing_season_eps:
await EpisodeService.create(
db=db,
series_id=existing.id,
season=season,
episode_number=ep_num,
)
logger.debug(
"Added missing episode to database: %s S%02dE%02d",
serie.key,
season,
ep_num
)
# Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem)
for season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set:
await EpisodeService.delete(db, episode_id)
logger.info(
"Removed episode from database (no longer missing): "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
# Update folder if changed
if existing.folder != serie.folder:
await AnimeSeriesService.update(
db,
existing.id,
folder=serie.folder
)
logger.debug(
"Updated series in database: %s (key=%s)",
serie.name,
serie.key
)
async def _load_series_from_db(self) -> None:
"""
Load series from the database into SeriesApp.
This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database.
"""
from src.core.entities.series import Serie
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
# Convert to Serie objects
series_list = []
for anime_series in anime_series_list:
# Build episode_dict from episodes relationship
episode_dict: dict[int, list[int]] = {}
if anime_series.episodes:
for episode in anime_series.episodes:
season = episode.season
if season not in episode_dict:
episode_dict[season] = []
episode_dict[season].append(episode.episode_number)
# Sort episode numbers
for season in episode_dict:
episode_dict[season].sort()
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict
)
series_list.append(serie)
# Load into SeriesApp
self._app.load_series_from_list(series_list)
async def add_series_to_db(
self,
serie,
db
):
"""
Add a series to the database if it doesn't already exist.
Uses serie.key for identification. Creates a new AnimeSeries
record in the database if it doesn't already exist.
Args:
serie: The Serie instance to add
db: Database session for async operations
Returns:
Created AnimeSeries instance, or None if already exists
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
logger.debug(
"Series already exists in database: %s (key=%s)",
serie.name,
serie.key
)
return None
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records for each episode in episodeDict
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for episode_number in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
logger.info(
"Added series to database: %s (key=%s)",
serie.name,
serie.key
)
return anime_series
async def contains_in_db(self, key: str, db) -> bool:
"""
Check if a series with the given key exists in the database.
Args:
key: The unique provider identifier for the series
db: Database session for async operations
Returns:
True if the series exists in the database
"""
from src.server.database.service import AnimeSeriesService
existing = await AnimeSeriesService.get_by_key(db, key)
return existing is not None
async def download(
self,
@ -335,6 +832,7 @@ class AnimeService:
Raises:
AnimeServiceError: If download fails
InterruptedError: If download was cancelled
Note:
The 'key' parameter is the primary identifier used for all
@ -353,6 +851,10 @@ class AnimeService:
key=key,
item_id=item_id,
)
except InterruptedError:
# Download was cancelled - re-raise for proper handling
logger.info("Download cancelled, propagating cancellation")
raise
except Exception as exc:
logger.exception("download failed")
raise AnimeServiceError("Download failed") from exc
@ -361,3 +863,135 @@ class AnimeService:
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""
return AnimeService(series_app)
async def sync_series_from_data_files(
anime_directory: str,
log_instance=None # pylint: disable=unused-argument
) -> int:
"""
Sync series from data files to the database.
Scans the anime directory for data files and adds any new series
to the database. Existing series are skipped (no duplicates).
This function is typically called during application startup to ensure
series metadata stored in filesystem data files is available in the
database.
Args:
anime_directory: Path to the anime directory with data files
log_instance: Optional logger instance (unused, kept for API
compatibility). This function always uses structlog internally.
Returns:
Number of new series added to the database
"""
# Always use structlog for structured logging with keyword arguments
log = structlog.get_logger(__name__)
try:
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService, EpisodeService
log.info(
"Starting data file to database sync",
directory=anime_directory
)
# Get all series from data files using SeriesApp
series_app = SeriesApp(anime_directory)
all_series = await asyncio.to_thread(
series_app.get_all_series_from_data_files
)
if not all_series:
log.info("No series found in data files to sync")
return 0
log.info(
"Found series in data files, syncing to database",
count=len(all_series)
)
async with get_db_session() as db:
added_count = 0
skipped_count = 0
for serie in all_series:
# Handle series with empty name - use folder as fallback
if not serie.name or not serie.name.strip():
if serie.folder and serie.folder.strip():
serie.name = serie.folder.strip()
log.debug(
"Using folder as name fallback",
key=serie.key,
folder=serie.folder
)
else:
log.warning(
"Skipping series with empty name and folder",
key=serie.key
)
skipped_count += 1
continue
try:
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
log.debug(
"Series already exists in database",
name=serie.name,
key=serie.key
)
continue
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records for each episode in episodeDict
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for episode_number in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
added_count += 1
log.debug(
"Added series to database",
name=serie.name,
key=serie.key
)
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to add series to database",
key=serie.key,
name=serie.name,
error=str(e)
)
skipped_count += 1
log.info(
"Data file sync complete",
added=added_count,
skipped=len(all_series) - added_count
)
return added_count
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to sync series to database",
error=str(e),
exc_info=True
)
return 0

View File

@ -42,6 +42,17 @@ class AuthService:
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.
WARNING - SINGLE PROCESS LIMITATION:
Failed login attempts are stored in memory dictionaries which RESET
when the process restarts. This means:
- Attackers can bypass lockouts by triggering a process restart
- Lockout state is not shared across multiple workers/processes
For production deployments, consider:
- Storing failed attempts in database with TTL-based expiration
- Using Redis for distributed lockout state
- Implementing account-based (not just IP-based) lockout tracking
"""
def __init__(self) -> None:

View File

@ -4,7 +4,7 @@ This service handles:
- Loading and saving configuration to JSON files
- Configuration validation
- Backup and restore functionality
- Configuration migration for version updates
- Configuration version management
"""
import json
@ -35,8 +35,8 @@ class ConfigBackupError(ConfigServiceError):
class ConfigService:
"""Service for managing application configuration persistence.
Handles loading, saving, validation, backup, and migration of
configuration files. Uses JSON format for human-readable and
Handles loading, saving, validation, backup, and version management
of configuration files. Uses JSON format for human-readable and
version-control friendly storage.
"""
@ -84,18 +84,13 @@ class ConfigService:
with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if migration is needed
file_version = data.get("version", "1.0.0")
if file_version != self.CONFIG_VERSION:
data = self._migrate_config(data, file_version)
# Remove version key before constructing AppConfig
data.pop("version", None)
config = AppConfig(**data)
# Validate configuration
validation = config.validate()
validation = config.validate_config()
if not validation.valid:
errors = ', '.join(validation.errors or [])
raise ConfigValidationError(
@ -128,7 +123,7 @@ class ConfigService:
ConfigValidationError: If config validation fails
"""
# Validate before saving
validation = config.validate()
validation = config.validate_config()
if not validation.valid:
errors = ', '.join(validation.errors or [])
raise ConfigValidationError(
@ -185,7 +180,7 @@ class ConfigService:
Returns:
ValidationResult: Validation result with errors if any
"""
return config.validate()
return config.validate_config()
def create_backup(self, name: Optional[str] = None) -> Path:
"""Create backup of current configuration.
@ -328,26 +323,6 @@ class ConfigService:
except (OSError, IOError):
# Ignore errors during cleanup
continue
def _migrate_config(
self, data: Dict, from_version: str # noqa: ARG002
) -> Dict:
"""Migrate configuration from old version to current.
Args:
data: Configuration data to migrate
from_version: Version to migrate from (reserved for future use)
Returns:
Dict: Migrated configuration data
"""
# Currently only one version exists
# Future migrations would go here
# Example:
# if from_version == "1.0.0" and self.CONFIG_VERSION == "2.0.0":
# data = self._migrate_1_0_to_2_0(data)
return data
# Singleton instance

View File

@ -2,18 +2,19 @@
This module provides a simplified queue management system for handling
anime episode downloads with manual start/stop controls, progress tracking,
persistence, and retry functionality.
database persistence, and retry functionality.
The service uses SQLite database for persistent storage via QueueRepository
while maintaining an in-memory cache for performance.
"""
from __future__ import annotations
import asyncio
import json
import uuid
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
from typing import TYPE_CHECKING, Dict, List, Optional
import structlog
@ -28,6 +29,9 @@ from src.server.models.download import (
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.progress_service import ProgressService, get_progress_service
if TYPE_CHECKING:
from src.server.services.queue_repository import QueueRepository
logger = structlog.get_logger(__name__)
@ -42,7 +46,7 @@ class DownloadService:
- Manual download start/stop
- FIFO queue processing
- Real-time progress tracking
- Queue persistence and recovery
- Database persistence via QueueRepository
- Automatic retry logic
- WebSocket broadcast support
"""
@ -50,24 +54,28 @@ class DownloadService:
def __init__(
self,
anime_service: AnimeService,
queue_repository: Optional["QueueRepository"] = None,
max_retries: int = 3,
persistence_path: str = "./data/download_queue.json",
progress_service: Optional[ProgressService] = None,
):
"""Initialize the download service.
Args:
anime_service: Service for anime operations
queue_repository: Optional repository for database persistence.
If not provided, will use default singleton.
max_retries: Maximum retry attempts for failed downloads
persistence_path: Path to persist queue state
progress_service: Optional progress service for tracking
"""
self._anime_service = anime_service
self._max_retries = max_retries
self._persistence_path = Path(persistence_path)
self._progress_service = progress_service or get_progress_service()
# Database repository for persistence
self._queue_repository = queue_repository
self._db_initialized = False
# Queue storage by status
# In-memory cache for performance (synced with database)
self._pending_queue: deque[DownloadItem] = deque()
# Helper dict for O(1) lookup of pending items by ID
self._pending_items_by_id: Dict[str, DownloadItem] = {}
@ -92,14 +100,159 @@ class DownloadService:
# Track if queue progress has been initialized
self._queue_progress_initialized: bool = False
# Load persisted queue
self._load_queue()
logger.info(
"DownloadService initialized",
max_retries=max_retries,
)
def _get_repository(self) -> "QueueRepository":
"""Get the queue repository, initializing if needed.
Returns:
QueueRepository instance
"""
if self._queue_repository is None:
from src.server.services.queue_repository import get_queue_repository
self._queue_repository = get_queue_repository()
return self._queue_repository
async def initialize(self) -> None:
"""Initialize the service by loading queue state from database.
Should be called after database is initialized during app startup.
Note: With the simplified model, status/priority/progress are now
managed in-memory only. The database stores the queue items
for persistence across restarts.
"""
if self._db_initialized:
return
try:
repository = self._get_repository()
# Load all items from database - they all start as PENDING
# since status is now managed in-memory only
all_items = await repository.get_all_items()
for item in all_items:
# All items from database are treated as pending
item.status = DownloadStatus.PENDING
self._add_to_pending_queue(item)
self._db_initialized = True
logger.info(
"Queue restored from database: pending_count=%d",
len(self._pending_queue),
)
except Exception as e:
logger.error("Failed to load queue from database: %s", e, exc_info=True)
# Continue without persistence - queue will work in memory only
self._db_initialized = True
async def _save_to_database(self, item: DownloadItem) -> DownloadItem:
"""Save or update an item in the database.
Args:
item: Download item to save
Returns:
Saved item with database ID
"""
try:
repository = self._get_repository()
return await repository.save_item(item)
except Exception as e:
logger.error("Failed to save item to database: %s", e)
return item
async def _set_error_in_database(
self,
item_id: str,
error: str,
) -> bool:
"""Set error message on an item in the database.
Args:
item_id: Download item ID
error: Error message
Returns:
True if update succeeded
"""
try:
repository = self._get_repository()
return await repository.set_error(item_id, error)
except Exception as e:
logger.error("Failed to set error in database: %s", e)
return False
async def _delete_from_database(self, item_id: str) -> bool:
"""Delete an item from the database.
Args:
item_id: Download item ID
Returns:
True if delete succeeded
"""
try:
repository = self._get_repository()
return await repository.delete_item(item_id)
except Exception as e:
logger.error("Failed to delete from database: %s", e)
return False
async def _remove_episode_from_missing_list(
self,
series_key: str,
season: int,
episode: int,
) -> bool:
"""Remove a downloaded episode from the missing episodes list.
Called when a download completes successfully to update the
database so the episode no longer appears as missing.
Args:
series_key: Unique provider key for the series
season: Season number
episode: Episode number within season
Returns:
True if episode was removed, False otherwise
"""
try:
from src.server.database.connection import get_db_session
from src.server.database.service import EpisodeService
async with get_db_session() as db:
deleted = await EpisodeService.delete_by_series_and_episode(
db=db,
series_key=series_key,
season=season,
episode_number=episode,
)
if deleted:
logger.info(
"Removed episode from missing list: "
"%s S%02dE%02d",
series_key,
season,
episode,
)
# Clear the anime service cache so list_missing
# returns updated data
try:
self._anime_service._cached_list_missing.cache_clear()
except Exception:
pass
return deleted
except Exception as e:
logger.error(
"Failed to remove episode from missing list: %s", e
)
return False
async def _init_queue_progress(self) -> None:
"""Initialize the download queue progress tracking.
@ -119,7 +272,7 @@ class DownloadService:
)
self._queue_progress_initialized = True
except Exception as e:
logger.error("Failed to initialize queue progress", error=str(e))
logger.error("Failed to initialize queue progress: %s", e)
def _add_to_pending_queue(
self, item: DownloadItem, front: bool = False
@ -165,69 +318,6 @@ class DownloadService:
"""Generate unique identifier for download items."""
return str(uuid.uuid4())
def _load_queue(self) -> None:
"""Load persisted queue from disk."""
try:
if self._persistence_path.exists():
with open(self._persistence_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Restore pending items
for item_dict in data.get("pending", []):
item = DownloadItem(**item_dict)
# Reset status if was downloading when saved
if item.status == DownloadStatus.DOWNLOADING:
item.status = DownloadStatus.PENDING
self._add_to_pending_queue(item)
# Restore failed items that can be retried
for item_dict in data.get("failed", []):
item = DownloadItem(**item_dict)
if item.retry_count < self._max_retries:
item.status = DownloadStatus.PENDING
self._add_to_pending_queue(item)
else:
self._failed_items.append(item)
logger.info(
"Queue restored from disk",
pending_count=len(self._pending_queue),
failed_count=len(self._failed_items),
)
except Exception as e:
logger.error("Failed to load persisted queue", error=str(e))
def _save_queue(self) -> None:
"""Persist current queue state to disk."""
try:
self._persistence_path.parent.mkdir(parents=True, exist_ok=True)
active_items = (
[self._active_download] if self._active_download else []
)
data = {
"pending": [
item.model_dump(mode="json")
for item in self._pending_queue
],
"active": [
item.model_dump(mode="json") for item in active_items
],
"failed": [
item.model_dump(mode="json")
for item in self._failed_items
],
"timestamp": datetime.now(timezone.utc).isoformat(),
}
with open(self._persistence_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logger.debug("Queue persisted to disk")
except Exception as e:
logger.error("Failed to persist queue", error=str(e))
async def add_to_queue(
self,
serie_id: str,
@ -274,22 +364,23 @@ class DownloadService:
added_at=datetime.now(timezone.utc),
)
# Always append to end (FIFO order)
self._add_to_pending_queue(item, front=False)
# Save to database first to get persistent ID
saved_item = await self._save_to_database(item)
created_ids.append(item.id)
# Add to in-memory cache
self._add_to_pending_queue(saved_item, front=False)
created_ids.append(saved_item.id)
logger.info(
"Item added to queue",
item_id=item.id,
item_id=saved_item.id,
serie_key=serie_id,
serie_name=serie_name,
season=episode.season,
episode=episode.episode,
)
self._save_queue()
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
@ -306,7 +397,7 @@ class DownloadService:
return created_ids
except Exception as e:
logger.error("Failed to add items to queue", error=str(e))
logger.error("Failed to add items to queue: %s", e)
raise DownloadServiceError(f"Failed to add items: {str(e)}") from e
async def remove_from_queue(self, item_ids: List[str]) -> List[str]:
@ -333,8 +424,10 @@ class DownloadService:
item.completed_at = datetime.now(timezone.utc)
self._failed_items.append(item)
self._active_download = None
# Delete cancelled item from database
await self._delete_from_database(item_id)
removed_ids.append(item_id)
logger.info("Cancelled active download", item_id=item_id)
logger.info("Cancelled active download: item_id=%s", item_id)
continue
# Check pending queue - O(1) lookup using helper dict
@ -342,13 +435,14 @@ class DownloadService:
item = self._pending_items_by_id[item_id]
self._pending_queue.remove(item)
del self._pending_items_by_id[item_id]
# Delete from database
await self._delete_from_database(item_id)
removed_ids.append(item_id)
logger.info(
"Removed from pending queue", item_id=item_id
)
if removed_ids:
self._save_queue()
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
@ -365,7 +459,7 @@ class DownloadService:
return removed_ids
except Exception as e:
logger.error("Failed to remove items", error=str(e))
logger.error("Failed to remove items: %s", e)
raise DownloadServiceError(
f"Failed to remove items: {str(e)}"
) from e
@ -379,6 +473,10 @@ class DownloadService:
Raises:
DownloadServiceError: If reordering fails
Note:
Reordering is done in-memory only. Database priority is not
updated since the in-memory queue defines the actual order.
"""
try:
# Build new queue based on specified order
@ -399,9 +497,6 @@ class DownloadService:
# Replace queue
self._pending_queue = new_queue
# Save updated queue
self._save_queue()
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
@ -418,7 +513,7 @@ class DownloadService:
logger.info("Queue reordered", reordered_count=len(item_ids))
except Exception as e:
logger.error("Failed to reorder queue", error=str(e))
logger.error("Failed to reorder queue: %s", e)
raise DownloadServiceError(
f"Failed to reorder queue: {str(e)}"
) from e
@ -462,7 +557,7 @@ class DownloadService:
return "queue_started"
except Exception as e:
logger.error("Failed to start queue processing", error=str(e))
logger.error("Failed to start queue processing: %s", e)
raise DownloadServiceError(
f"Failed to start queue processing: {str(e)}"
) from e
@ -692,13 +787,15 @@ class DownloadService:
Number of items cleared
"""
count = len(self._pending_queue)
# Delete all pending items from database
for item_id in list(self._pending_items_by_id.keys()):
await self._delete_from_database(item_id)
self._pending_queue.clear()
self._pending_items_by_id.clear()
logger.info("Cleared pending items", count=count)
# Save queue state
self._save_queue()
# Notify via progress service
if count > 0:
queue_status = await self.get_queue_status()
@ -749,14 +846,15 @@ class DownloadService:
self._add_to_pending_queue(item)
retried_ids.append(item.id)
# Status is now managed in-memory only
logger.info(
"Retrying failed item",
item_id=item.id,
retry_count=item.retry_count
"Retrying failed item: item_id=%s, retry_count=%d",
item.id,
item.retry_count,
)
if retried_ids:
self._save_queue()
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
@ -773,7 +871,7 @@ class DownloadService:
return retried_ids
except Exception as e:
logger.error("Failed to retry items", error=str(e))
logger.error("Failed to retry items: %s", e)
raise DownloadServiceError(
f"Failed to retry: {str(e)}"
) from e
@ -790,18 +888,17 @@ class DownloadService:
logger.info("Skipping download due to shutdown")
return
# Update status
# Update status in memory (status is now in-memory only)
item.status = DownloadStatus.DOWNLOADING
item.started_at = datetime.now(timezone.utc)
self._active_download = item
logger.info(
"Starting download",
item_id=item.id,
serie_key=item.serie_id,
serie_name=item.serie_name,
season=item.episode.season,
episode=item.episode.episode,
"Starting download: item_id=%s, serie_key=%s, S%02dE%02d",
item.id,
item.serie_id,
item.episode.season,
item.episode.episode,
)
# Execute download via anime service
@ -809,7 +906,8 @@ class DownloadService:
# - download started/progress/completed/failed events
# - All updates forwarded to ProgressService
# - ProgressService broadcasts to WebSocket clients
# Use serie_folder for filesystem operations and serie_id (key) for identification
# Use serie_folder for filesystem operations
# and serie_id (key) for identification
if not item.serie_folder:
raise DownloadServiceError(
f"Missing serie_folder for download item {item.id}. "
@ -835,8 +933,18 @@ class DownloadService:
self._completed_items.append(item)
# Delete completed item from database (status is in-memory)
await self._delete_from_database(item.id)
# Remove episode from missing episodes list in database
await self._remove_episode_from_missing_list(
series_key=item.serie_id,
season=item.episode.season,
episode=item.episode.episode,
)
logger.info(
"Download completed successfully", item_id=item.id
"Download completed successfully: item_id=%s", item.id
)
else:
raise AnimeServiceError("Download returned False")
@ -844,15 +952,36 @@ class DownloadService:
except asyncio.CancelledError:
# Handle task cancellation during shutdown
logger.info(
"Download cancelled during shutdown",
item_id=item.id,
"Download task cancelled: item_id=%s",
item.id,
)
item.status = DownloadStatus.CANCELLED
item.completed_at = datetime.now(timezone.utc)
# Delete cancelled item from database
await self._delete_from_database(item.id)
# Return item to pending queue if not shutting down
if not self._is_shutting_down:
self._add_to_pending_queue(item, front=True)
# Re-save to database as pending
await self._save_to_database(item)
raise # Re-raise to properly cancel the task
except InterruptedError:
# Handle download cancellation from provider
logger.info(
"Download interrupted/cancelled: item_id=%s",
item.id,
)
item.status = DownloadStatus.CANCELLED
item.completed_at = datetime.now(timezone.utc)
# Delete cancelled item from database
await self._delete_from_database(item.id)
# Return item to pending queue if not shutting down
if not self._is_shutting_down:
self._add_to_pending_queue(item, front=True)
# Re-save to database as pending
await self._save_to_database(item)
# Don't re-raise - this is handled gracefully
except Exception as e:
# Handle failure
@ -861,11 +990,14 @@ class DownloadService:
item.error = str(e)
self._failed_items.append(item)
# Set error in database
await self._set_error_in_database(item.id, str(e))
logger.error(
"Download failed",
item_id=item.id,
error=str(e),
retry_count=item.retry_count,
"Download failed: item_id=%s, error=%s, retry_count=%d",
item.id,
str(e),
item.retry_count,
)
# Note: Failure is already broadcast by AnimeService
# via ProgressService when SeriesApp fires failed event
@ -874,44 +1006,8 @@ class DownloadService:
# Remove from active downloads
if self._active_download and self._active_download.id == item.id:
self._active_download = None
self._save_queue()
async def start(self) -> None:
"""Initialize the download queue service (compatibility method).
Note: Downloads are started manually via start_next_download().
"""
logger.info("Download queue service initialized")
async def stop(self) -> None:
"""Stop the download queue service and cancel active downloads.
Cancels any active download and shuts down the thread pool immediately.
"""
logger.info("Stopping download queue service...")
# Set shutdown flag
self._is_shutting_down = True
self._is_stopped = True
# Cancel active download task if running
if self._active_download_task and not self._active_download_task.done():
logger.info("Cancelling active download task...")
self._active_download_task.cancel()
try:
await self._active_download_task
except asyncio.CancelledError:
logger.info("Active download task cancelled")
# Save final state
self._save_queue()
# Shutdown executor immediately, don't wait for tasks
logger.info("Shutting down thread pool executor...")
self._executor.shutdown(wait=False, cancel_futures=True)
logger.info("Download queue service stopped")
# Singleton instance

View File

@ -133,6 +133,30 @@ class ProgressServiceError(Exception):
"""Service-level exception for progress operations."""
# Mapping from ProgressType to WebSocket room names
# This ensures compatibility with the valid rooms defined in the WebSocket API:
# "downloads", "queue", "scan", "system", "errors"
_PROGRESS_TYPE_TO_ROOM: Dict[ProgressType, str] = {
ProgressType.DOWNLOAD: "downloads",
ProgressType.SCAN: "scan",
ProgressType.QUEUE: "queue",
ProgressType.SYSTEM: "system",
ProgressType.ERROR: "errors",
}
def _get_room_for_progress_type(progress_type: ProgressType) -> str:
"""Get the WebSocket room name for a progress type.
Args:
progress_type: The type of progress update
Returns:
The WebSocket room name to broadcast to
"""
return _PROGRESS_TYPE_TO_ROOM.get(progress_type, "system")
class ProgressService:
"""Manages real-time progress updates and broadcasting.
@ -293,7 +317,7 @@ class ProgressService:
)
# Emit event to subscribers
room = f"{progress_type.value}_progress"
room = _get_room_for_progress_type(progress_type)
event = ProgressEvent(
event_type=f"{progress_type.value}_progress",
progress_id=progress_id,
@ -370,7 +394,7 @@ class ProgressService:
should_broadcast = force_broadcast or percent_change >= 1.0
if should_broadcast:
room = f"{update.type.value}_progress"
room = _get_room_for_progress_type(update.type)
event = ProgressEvent(
event_type=f"{update.type.value}_progress",
progress_id=progress_id,
@ -427,7 +451,7 @@ class ProgressService:
)
# Emit completion event
room = f"{update.type.value}_progress"
room = _get_room_for_progress_type(update.type)
event = ProgressEvent(
event_type=f"{update.type.value}_progress",
progress_id=progress_id,
@ -483,7 +507,7 @@ class ProgressService:
)
# Emit failure event
room = f"{update.type.value}_progress"
room = _get_room_for_progress_type(update.type)
event = ProgressEvent(
event_type=f"{update.type.value}_progress",
progress_id=progress_id,
@ -533,7 +557,7 @@ class ProgressService:
)
# Emit cancellation event
room = f"{update.type.value}_progress"
room = _get_room_for_progress_type(update.type)
event = ProgressEvent(
event_type=f"{update.type.value}_progress",
progress_id=progress_id,

View File

@ -0,0 +1,471 @@
"""Queue repository adapter for database-backed download queue operations.
This module provides a repository adapter that wraps the DownloadQueueService
and provides the interface needed by DownloadService for queue persistence.
The repository pattern abstracts the database operations from the business
logic, allowing the DownloadService to work with domain models (DownloadItem)
while the repository handles conversion to/from database models.
Transaction Support:
Compound operations (save_item, clear_all) are wrapped in atomic()
context managers to ensure all-or-nothing behavior. If any part of
a compound operation fails, all changes are rolled back.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Callable, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.server.database.models import DownloadQueueItem as DBDownloadQueueItem
from src.server.database.service import (
AnimeSeriesService,
DownloadQueueService,
EpisodeService,
)
from src.server.database.transaction import atomic
from src.server.models.download import (
DownloadItem,
DownloadPriority,
DownloadStatus,
EpisodeIdentifier,
)
logger = logging.getLogger(__name__)
class QueueRepositoryError(Exception):
"""Repository-level exception for queue operations."""
class QueueRepository:
"""Repository adapter for database-backed download queue operations.
Provides clean interface for queue operations while handling
model conversion between Pydantic (DownloadItem) and SQLAlchemy
(DownloadQueueItem) models.
Note: The database model (DownloadQueueItem) is simplified and only
stores episode_id as a foreign key. Status, priority, progress, and
retry_count are managed in-memory by the DownloadService.
Transaction Support:
All compound operations are wrapped in atomic() transactions.
This ensures data consistency even if operations fail mid-way.
Attributes:
_db_session_factory: Factory function to create database sessions
"""
def __init__(
self,
db_session_factory: Callable[[], AsyncSession],
) -> None:
"""Initialize the queue repository.
Args:
db_session_factory: Factory function that returns AsyncSession
"""
self._db_session_factory = db_session_factory
logger.info("QueueRepository initialized")
# =========================================================================
# Model Conversion Methods
# =========================================================================
def _from_db_model(
self,
db_item: DBDownloadQueueItem,
item_id: Optional[str] = None,
) -> DownloadItem:
"""Convert database model to DownloadItem.
Note: Since the database model is simplified, status, priority,
progress, and retry_count default to initial values.
Args:
db_item: SQLAlchemy download queue item
item_id: Optional override for item ID
Returns:
Pydantic download item with default status/priority
"""
# Get episode info from the related Episode object
episode = db_item.episode
series = db_item.series
episode_identifier = EpisodeIdentifier(
season=episode.season if episode else 1,
episode=episode.episode_number if episode else 1,
title=episode.title if episode else None,
)
return DownloadItem(
id=item_id or str(db_item.id),
serie_id=series.key if series else "",
serie_folder=series.folder if series else "",
serie_name=series.name if series else "",
episode=episode_identifier,
status=DownloadStatus.PENDING, # Default - managed in-memory
priority=DownloadPriority.NORMAL, # Default - managed in-memory
added_at=db_item.created_at or datetime.now(timezone.utc),
started_at=db_item.started_at,
completed_at=db_item.completed_at,
progress=None, # Managed in-memory
error=db_item.error_message,
retry_count=0, # Managed in-memory
source_url=db_item.download_url,
)
# =========================================================================
# CRUD Operations
# =========================================================================
async def save_item(
self,
item: DownloadItem,
db: Optional[AsyncSession] = None,
) -> DownloadItem:
"""Save a download item to the database atomically.
Creates a new record if the item doesn't exist in the database.
This compound operation (series lookup/create, episode lookup/create,
queue item create) is wrapped in a transaction for atomicity.
Note: Status, priority, progress, and retry_count are NOT persisted.
Args:
item: Download item to save
db: Optional existing database session
Returns:
Saved download item with database ID
Raises:
QueueRepositoryError: If save operation fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
async with atomic(session):
# Find series by key
series = await AnimeSeriesService.get_by_key(session, item.serie_id)
if not series:
# Create series if it doesn't exist
# Use a placeholder site URL - will be updated later when actual URL is known
site_url = getattr(item, 'serie_site', None) or f"https://aniworld.to/anime/{item.serie_id}"
series = await AnimeSeriesService.create(
db=session,
key=item.serie_id,
name=item.serie_name,
site=site_url,
folder=item.serie_folder,
)
logger.info(
"Created new series for queue item: key=%s, name=%s",
item.serie_id,
item.serie_name,
)
# Find or create episode
episode = await EpisodeService.get_by_episode(
session,
series.id,
item.episode.season,
item.episode.episode,
)
if not episode:
# Create episode if it doesn't exist
episode = await EpisodeService.create(
db=session,
series_id=series.id,
season=item.episode.season,
episode_number=item.episode.episode,
title=item.episode.title,
)
logger.info(
"Created new episode for queue item: S%02dE%02d",
item.episode.season,
item.episode.episode,
)
# Create queue item
db_item = await DownloadQueueService.create(
db=session,
series_id=series.id,
episode_id=episode.id,
download_url=str(item.source_url) if item.source_url else None,
)
# Update the item ID with the database ID
item.id = str(db_item.id)
# Transaction committed by atomic() context manager
logger.debug(
"Saved queue item to database: item_id=%s, serie_key=%s",
item.id,
item.serie_id,
)
return item
except Exception as e:
# Rollback handled by atomic() context manager
logger.error("Failed to save queue item: %s", e)
raise QueueRepositoryError(f"Failed to save item: {e}") from e
finally:
if manage_session:
await session.close()
async def get_item(
self,
item_id: str,
db: Optional[AsyncSession] = None,
) -> Optional[DownloadItem]:
"""Get a download item by ID.
Args:
item_id: Download item ID (database ID as string)
db: Optional existing database session
Returns:
Download item or None if not found
Raises:
QueueRepositoryError: If query fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
db_item = await DownloadQueueService.get_by_id(
session, int(item_id)
)
if not db_item:
return None
return self._from_db_model(db_item, item_id)
except ValueError:
# Invalid ID format
return None
except Exception as e:
logger.error("Failed to get queue item: %s", e)
raise QueueRepositoryError(f"Failed to get item: {e}") from e
finally:
if manage_session:
await session.close()
async def get_all_items(
self,
db: Optional[AsyncSession] = None,
) -> List[DownloadItem]:
"""Get all download items regardless of status.
Note: All items are returned with default status (PENDING) since
status is now managed in-memory by the DownloadService.
Args:
db: Optional existing database session
Returns:
List of all download items
Raises:
QueueRepositoryError: If query fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
db_items = await DownloadQueueService.get_all(
session, with_series=True
)
return [self._from_db_model(item) for item in db_items]
except Exception as e:
logger.error("Failed to get all items: %s", e)
raise QueueRepositoryError(f"Failed to get all items: {e}") from e
finally:
if manage_session:
await session.close()
async def set_error(
self,
item_id: str,
error: str,
db: Optional[AsyncSession] = None,
) -> bool:
"""Set error message on a download item.
Args:
item_id: Download item ID
error: Error message
db: Optional existing database session
Returns:
True if update succeeded, False if item not found
Raises:
QueueRepositoryError: If update fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
result = await DownloadQueueService.set_error(
session,
int(item_id),
error,
)
if manage_session:
await session.commit()
success = result is not None
if success:
logger.debug(
"Set error on queue item: item_id=%s",
item_id,
)
return success
except ValueError:
return False
except Exception as e:
if manage_session:
await session.rollback()
logger.error("Failed to set error: %s", e)
raise QueueRepositoryError(f"Failed to set error: {e}") from e
finally:
if manage_session:
await session.close()
async def delete_item(
self,
item_id: str,
db: Optional[AsyncSession] = None,
) -> bool:
"""Delete a download item from the database.
Args:
item_id: Download item ID
db: Optional existing database session
Returns:
True if item was deleted, False if not found
Raises:
QueueRepositoryError: If delete fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
result = await DownloadQueueService.delete(session, int(item_id))
if manage_session:
await session.commit()
if result:
logger.debug("Deleted queue item: item_id=%s", item_id)
return result
except ValueError:
return False
except Exception as e:
if manage_session:
await session.rollback()
logger.error("Failed to delete item: %s", e)
raise QueueRepositoryError(f"Failed to delete item: {e}") from e
finally:
if manage_session:
await session.close()
async def clear_all(
self,
db: Optional[AsyncSession] = None,
) -> int:
"""Clear all download items from the queue atomically.
This bulk delete operation is wrapped in a transaction.
Either all items are deleted or none are.
Args:
db: Optional existing database session
Returns:
Number of items cleared
Raises:
QueueRepositoryError: If operation fails
"""
session = db or self._db_session_factory()
manage_session = db is None
try:
async with atomic(session):
# Use the bulk clear operation for efficiency and atomicity
count = await DownloadQueueService.clear_all(session)
# Transaction committed by atomic() context manager
logger.info("Cleared all items from queue: count=%d", count)
return count
except Exception as e:
# Rollback handled by atomic() context manager
logger.error("Failed to clear queue: %s", e)
raise QueueRepositoryError(f"Failed to clear queue: {e}") from e
finally:
if manage_session:
await session.close()
# Singleton instance
_queue_repository_instance: Optional[QueueRepository] = None
def get_queue_repository(
db_session_factory: Optional[Callable[[], AsyncSession]] = None,
) -> QueueRepository:
"""Get or create the QueueRepository singleton.
Args:
db_session_factory: Optional factory function for database sessions.
If not provided, uses default from connection module.
Returns:
QueueRepository singleton instance
"""
global _queue_repository_instance
if _queue_repository_instance is None:
if db_session_factory is None:
# Use default session factory
from src.server.database.connection import get_async_session_factory
db_session_factory = get_async_session_factory
_queue_repository_instance = QueueRepository(db_session_factory)
return _queue_repository_instance
def reset_queue_repository() -> None:
"""Reset the QueueRepository singleton.
Used for testing to ensure fresh state between tests.
"""
global _queue_repository_instance
_queue_repository_instance = None

View File

@ -13,20 +13,8 @@ from typing import Any, Callable, Dict, List, Optional
import structlog
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionCallback,
CompletionContext,
ErrorCallback,
ErrorContext,
OperationType,
ProgressCallback,
ProgressContext,
ProgressPhase,
)
from src.server.services.progress_service import (
ProgressService,
ProgressStatus,
ProgressType,
get_progress_service,
)
@ -104,173 +92,6 @@ class ScanProgress:
return result
class ScanServiceProgressCallback(ProgressCallback):
"""Callback implementation for forwarding scan progress to ScanService.
This callback receives progress events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_progress(self, context: ProgressContext) -> None:
"""Handle progress update from SerieScanner.
Args:
context: Progress context with key and folder information
"""
self._scan_progress.current = context.current
self._scan_progress.total = context.total
self._scan_progress.percentage = context.percentage
self._scan_progress.message = context.message
self._scan_progress.key = context.key
self._scan_progress.folder = context.folder
self._scan_progress.updated_at = datetime.now(timezone.utc)
if context.phase == ProgressPhase.STARTING:
self._scan_progress.status = "started"
elif context.phase == ProgressPhase.IN_PROGRESS:
self._scan_progress.status = "in_progress"
elif context.phase == ProgressPhase.COMPLETED:
self._scan_progress.status = "completed"
elif context.phase == ProgressPhase.FAILED:
self._scan_progress.status = "failed"
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_progress_update(self._scan_progress),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanServiceErrorCallback(ErrorCallback):
"""Callback implementation for handling scan errors.
This callback receives error events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_error(self, context: ErrorContext) -> None:
"""Handle error from SerieScanner.
Args:
context: Error context with key and folder information
"""
error_msg = context.message
if context.folder:
error_msg = f"[{context.folder}] {error_msg}"
self._scan_progress.errors.append(error_msg)
self._scan_progress.updated_at = datetime.now(timezone.utc)
logger.warning(
"Scan error",
key=context.key,
folder=context.folder,
error=str(context.error),
recoverable=context.recoverable,
)
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_scan_error(
self._scan_progress,
context,
),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanServiceCompletionCallback(CompletionCallback):
"""Callback implementation for handling scan completion.
This callback receives completion events from SerieScanner and forwards
them to the ScanService for processing and broadcasting.
"""
def __init__(
self,
service: "ScanService",
scan_progress: ScanProgress,
):
"""Initialize the callback.
Args:
service: Parent ScanService instance
scan_progress: ScanProgress to update
"""
self._service = service
self._scan_progress = scan_progress
def on_completion(self, context: CompletionContext) -> None:
"""Handle completion from SerieScanner.
Args:
context: Completion context with statistics
"""
self._scan_progress.status = "completed" if context.success else "failed"
self._scan_progress.message = context.message
self._scan_progress.updated_at = datetime.now(timezone.utc)
if context.statistics:
self._scan_progress.series_found = context.statistics.get(
"series_found", 0
)
# Forward to service for broadcasting
# Use run_coroutine_threadsafe if event loop is available
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._service._handle_scan_completion(
self._scan_progress,
context,
),
loop
)
except RuntimeError:
# No running event loop - likely in test or sync context
pass
class ScanService:
"""Manages anime library scan operations.
@ -376,13 +197,13 @@ class ScanService:
async def start_scan(
self,
scanner_factory: Callable[..., Any],
scanner: Any, # SerieScanner instance
) -> str:
"""Start a new library scan.
Args:
scanner_factory: Factory function that creates a SerieScanner.
The factory should accept a callback_manager parameter.
scanner: SerieScanner instance to use for scanning.
The service will subscribe to its events.
Returns:
Scan ID for tracking
@ -415,7 +236,7 @@ class ScanService:
message="Initializing scan...",
)
except Exception as e:
logger.error("Failed to start progress tracking", error=str(e))
logger.error("Failed to start progress tracking: %s", e)
# Emit scan started event
await self._emit_scan_event({
@ -423,42 +244,82 @@ class ScanService:
"scan_id": scan_id,
"message": "Library scan started",
})
# Create event handlers for the scanner
def on_progress_handler(progress_data: Dict[str, Any]) -> None:
"""Handle progress events from scanner."""
scan_progress.current = progress_data.get('current', 0)
scan_progress.total = progress_data.get('total', 0)
scan_progress.percentage = progress_data.get('percentage', 0.0)
scan_progress.message = progress_data.get('message', '')
scan_progress.updated_at = datetime.now(timezone.utc)
phase = progress_data.get('phase', '')
if phase == 'STARTING':
scan_progress.status = "started"
elif phase == 'IN_PROGRESS':
scan_progress.status = "in_progress"
# Schedule the progress update on the event loop
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._handle_progress_update(scan_progress),
loop
)
except RuntimeError:
pass
def on_error_handler(error_data: Dict[str, Any]) -> None:
"""Handle error events from scanner."""
error_msg = error_data.get('message', 'Unknown error')
scan_progress.errors.append(error_msg)
scan_progress.updated_at = datetime.now(timezone.utc)
logger.warning(
"Scan error",
error=str(error_data.get('error')),
recoverable=error_data.get('recoverable', True),
)
# Schedule the error handling on the event loop
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._handle_scan_error(scan_progress, error_data),
loop
)
except RuntimeError:
pass
def on_completion_handler(completion_data: Dict[str, Any]) -> None:
"""Handle completion events from scanner."""
success = completion_data.get('success', False)
scan_progress.status = "completed" if success else "failed"
scan_progress.message = completion_data.get('message', '')
scan_progress.updated_at = datetime.now(timezone.utc)
if 'statistics' in completion_data:
stats = completion_data['statistics']
scan_progress.series_found = stats.get('series_found', 0)
# Schedule the completion handling on the event loop
try:
loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._handle_scan_completion(scan_progress, completion_data),
loop
)
except RuntimeError:
pass
# Subscribe to scanner events
scanner.subscribe_on_progress(on_progress_handler)
scanner.subscribe_on_error(on_error_handler)
scanner.subscribe_on_completion(on_completion_handler)
return scan_id
def create_callback_manager(
self,
scan_progress: Optional[ScanProgress] = None,
) -> CallbackManager:
"""Create a callback manager for scan operations.
Args:
scan_progress: Optional scan progress to use. If None,
uses current scan progress.
Returns:
CallbackManager configured with scan callbacks
"""
progress = scan_progress or self._current_scan
if not progress:
progress = ScanProgress(str(uuid.uuid4()))
self._current_scan = progress
callback_manager = CallbackManager()
# Register callbacks
callback_manager.register_progress_callback(
ScanServiceProgressCallback(self, progress)
)
callback_manager.register_error_callback(
ScanServiceErrorCallback(self, progress)
)
callback_manager.register_completion_callback(
ScanServiceCompletionCallback(self, progress)
)
return callback_manager
async def _handle_progress_update(
self,
scan_progress: ScanProgress,
@ -475,11 +336,9 @@ class ScanService:
current=scan_progress.current,
total=scan_progress.total,
message=scan_progress.message,
key=scan_progress.key,
folder=scan_progress.folder,
)
except Exception as e:
logger.debug("Progress update skipped", error=str(e))
logger.debug("Progress update skipped: %s", e)
# Emit progress event with key as primary identifier
await self._emit_scan_event({
@ -490,36 +349,38 @@ class ScanService:
async def _handle_scan_error(
self,
scan_progress: ScanProgress,
error_context: ErrorContext,
error_data: Dict[str, Any],
) -> None:
"""Handle a scan error.
Args:
scan_progress: Current scan progress
error_context: Error context with key and folder metadata
error_data: Error data dictionary with error info
"""
# Emit error event with key as primary identifier
await self._emit_scan_event({
"type": "scan_error",
"scan_id": scan_progress.scan_id,
"key": error_context.key,
"folder": error_context.folder,
"error": str(error_context.error),
"message": error_context.message,
"recoverable": error_context.recoverable,
"error": str(error_data.get('error')),
"message": error_data.get('message', 'Unknown error'),
"recoverable": error_data.get('recoverable', True),
})
async def _handle_scan_completion(
self,
scan_progress: ScanProgress,
completion_context: CompletionContext,
completion_data: Dict[str, Any],
) -> None:
"""Handle scan completion.
Args:
scan_progress: Final scan progress
completion_context: Completion context with statistics
completion_data: Completion data dictionary with statistics
"""
success = completion_data.get('success', False)
message = completion_data.get('message', '')
statistics = completion_data.get('statistics', {})
async with self._lock:
self._is_scanning = False
@ -530,33 +391,33 @@ class ScanService:
# Complete progress tracking
try:
if completion_context.success:
if success:
await self._progress_service.complete_progress(
progress_id=f"scan_{scan_progress.scan_id}",
message=completion_context.message,
message=message,
)
else:
await self._progress_service.fail_progress(
progress_id=f"scan_{scan_progress.scan_id}",
error_message=completion_context.message,
error_message=message,
)
except Exception as e:
logger.debug("Progress completion skipped", error=str(e))
logger.debug("Progress completion skipped: %s", e)
# Emit completion event
await self._emit_scan_event({
"type": "scan_completed" if completion_context.success else "scan_failed",
"type": "scan_completed" if success else "scan_failed",
"scan_id": scan_progress.scan_id,
"success": completion_context.success,
"message": completion_context.message,
"statistics": completion_context.statistics,
"success": success,
"message": message,
"statistics": statistics,
"data": scan_progress.to_dict(),
})
logger.info(
"Scan completed",
scan_id=scan_progress.scan_id,
success=completion_context.success,
success=success,
series_found=scan_progress.series_found,
errors_count=len(scan_progress.errors),
)
@ -598,7 +459,7 @@ class ScanService:
error_message="Scan cancelled by user",
)
except Exception as e:
logger.debug("Progress cancellation skipped", error=str(e))
logger.debug("Progress cancellation skipped: %s", e)
logger.info("Scan cancelled")
return True

View File

@ -322,6 +322,85 @@ class ConnectionManager:
connection_id=connection_id,
)
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown all WebSocket connections.
Broadcasts a shutdown notification to all clients, then closes
each connection with proper close codes.
Args:
timeout: Maximum time (seconds) to wait for all closes to complete
"""
logger.info(
"Initiating WebSocket shutdown, connections=%d",
len(self._active_connections)
)
# Broadcast shutdown notification to all clients
shutdown_message = {
"type": "server_shutdown",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"message": "Server is shutting down",
"reason": "graceful_shutdown",
},
}
try:
await self.broadcast(shutdown_message)
except Exception as e:
logger.warning("Failed to broadcast shutdown message: %s", e)
# Close all connections gracefully
async with self._lock:
connection_ids = list(self._active_connections.keys())
close_tasks = []
for connection_id in connection_ids:
websocket = self._active_connections.get(connection_id)
if websocket:
close_tasks.append(
self._close_connection_gracefully(connection_id, websocket)
)
if close_tasks:
# Wait for all closes with timeout
try:
await asyncio.wait_for(
asyncio.gather(*close_tasks, return_exceptions=True),
timeout=timeout
)
except asyncio.TimeoutError:
logger.warning(
"WebSocket shutdown timed out after %.1f seconds", timeout
)
# Clear all data structures
async with self._lock:
self._active_connections.clear()
self._rooms.clear()
self._connection_metadata.clear()
logger.info("WebSocket shutdown complete")
async def _close_connection_gracefully(
self, connection_id: str, websocket: WebSocket
) -> None:
"""Close a single WebSocket connection gracefully.
Args:
connection_id: The connection identifier
websocket: The WebSocket connection to close
"""
try:
# Code 1001 = Going Away (server shutdown)
await websocket.close(code=1001, reason="Server shutdown")
logger.debug("Closed WebSocket connection: %s", connection_id)
except Exception as e:
logger.debug(
"Error closing WebSocket %s: %s", connection_id, str(e)
)
class WebSocketService:
"""High-level WebSocket service for application-wide messaging.
@ -498,6 +577,99 @@ class WebSocketService:
}
await self._manager.send_personal_message(message, connection_id)
async def broadcast_scan_started(
self, directory: str, total_items: int = 0
) -> None:
"""Broadcast that a library scan has started.
Args:
directory: The root directory path being scanned
total_items: Total number of items to scan (for progress display)
"""
message = {
"type": "scan_started",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"directory": directory,
"total_items": total_items,
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast scan_started",
directory=directory,
total_items=total_items,
)
async def broadcast_scan_progress(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
total_items: int = 0,
) -> None:
"""Broadcast scan progress update to all clients.
Args:
directories_scanned: Number of directories scanned so far
files_found: Number of MP4 files found so far
current_directory: Current directory being scanned
total_items: Total number of items to scan (for progress display)
"""
message = {
"type": "scan_progress",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"directories_scanned": directories_scanned,
"files_found": files_found,
"current_directory": current_directory,
"total_items": total_items,
},
}
await self._manager.broadcast(message)
async def broadcast_scan_completed(
self,
total_directories: int,
total_files: int,
elapsed_seconds: float,
) -> None:
"""Broadcast scan completion to all clients.
Args:
total_directories: Total number of directories scanned
total_files: Total number of MP4 files found
elapsed_seconds: Time taken for the scan in seconds
"""
message = {
"type": "scan_completed",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"total_directories": total_directories,
"total_files": total_files,
"elapsed_seconds": round(elapsed_seconds, 2),
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast scan_completed",
total_directories=total_directories,
total_files=total_files,
elapsed_seconds=round(elapsed_seconds, 2),
)
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service.
Broadcasts shutdown notification and closes all connections.
Args:
timeout: Maximum time (seconds) to wait for shutdown
"""
logger.info("Shutting down WebSocket service...")
await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete")
# Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None

View File

@ -65,6 +65,10 @@ def get_series_app() -> SeriesApp:
Raises:
HTTPException: If SeriesApp is not initialized or anime directory
is not configured
Note:
This creates a SeriesApp without database support. For database-
backed storage, use get_series_app_with_db() instead.
"""
global _series_app
@ -103,7 +107,6 @@ def reset_series_app() -> None:
_series_app = None
async def get_database_session() -> AsyncGenerator:
"""
Dependency to get database session.
@ -134,6 +137,38 @@ async def get_database_session() -> AsyncGenerator:
)
async def get_optional_database_session() -> AsyncGenerator:
"""
Dependency to get optional database session.
Unlike get_database_session(), this returns None if the database
is not available, allowing endpoints to fall back to other storage.
Yields:
AsyncSession or None: Database session if available, None otherwise
Example:
@app.post("/anime/add")
async def add_anime(
db: Optional[AsyncSession] = Depends(get_optional_database_session)
):
if db:
# Use database
await AnimeSeriesService.create(db, ...)
else:
# Fall back to file-based storage
series_app.list.add(serie)
"""
try:
from src.server.database import get_db_session
async with get_db_session() as session:
yield session
except (ImportError, RuntimeError):
# Database not available - yield None
yield None
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
http_bearer_security

View File

@ -0,0 +1,180 @@
"""Filesystem utilities for safe file and folder operations.
This module provides utility functions for safely handling filesystem
operations, including sanitizing folder names and path validation.
Security:
- All functions sanitize inputs to prevent path traversal attacks
- Invalid filesystem characters are removed or replaced
- Unicode characters are preserved for international titles
"""
import os
import re
import unicodedata
from typing import Optional
# Characters that are invalid in filesystem paths across platforms
# Windows: < > : " / \ | ? *
# Linux/Mac: / and null byte
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
# Additional characters to remove for cleaner folder names
EXTRA_CLEANUP_CHARS = '\r\n\t'
# Maximum folder name length (conservative for cross-platform compatibility)
MAX_FOLDER_NAME_LENGTH = 200
def sanitize_folder_name(
name: str,
replacement: str = "",
max_length: Optional[int] = None,
) -> str:
"""Sanitize a string for use as a filesystem folder name.
Removes or replaces characters that are invalid for filesystems while
preserving Unicode characters (for Japanese/Chinese titles, etc.).
Args:
name: The string to sanitize (e.g., anime display name)
replacement: Character to replace invalid chars with (default: "")
max_length: Maximum length for the result (default: MAX_FOLDER_NAME_LENGTH)
Returns:
str: A filesystem-safe folder name
Raises:
ValueError: If name is None, empty, or results in empty string
Examples:
>>> sanitize_folder_name("Attack on Titan: Final Season")
'Attack on Titan Final Season'
>>> sanitize_folder_name("What If...?")
'What If...'
>>> sanitize_folder_name("Re:Zero")
'ReZero'
>>> sanitize_folder_name("日本語タイトル")
'日本語タイトル'
"""
if name is None:
raise ValueError("Folder name cannot be None")
# Strip leading/trailing whitespace
name = name.strip()
if not name:
raise ValueError("Folder name cannot be empty")
max_len = max_length or MAX_FOLDER_NAME_LENGTH
# Normalize Unicode characters (NFC form for consistency)
name = unicodedata.normalize('NFC', name)
# Remove invalid filesystem characters
for char in INVALID_PATH_CHARS:
name = name.replace(char, replacement)
# Remove extra cleanup characters
for char in EXTRA_CLEANUP_CHARS:
name = name.replace(char, replacement)
# Remove control characters but preserve Unicode
name = ''.join(
char for char in name
if not unicodedata.category(char).startswith('C')
or char == ' ' # Preserve spaces
)
# Collapse multiple consecutive spaces
name = re.sub(r' +', ' ', name)
# Remove leading/trailing dots and whitespace
# (dots at start can make folders hidden on Unix)
name = name.strip('. ')
# Handle edge case: all characters were invalid
if not name:
raise ValueError(
"Folder name contains only invalid characters"
)
# Truncate to max length while avoiding breaking in middle of word
if len(name) > max_len:
# Try to truncate at a word boundary
truncated = name[:max_len]
last_space = truncated.rfind(' ')
if last_space > max_len // 2: # Only if we don't lose too much
truncated = truncated[:last_space]
name = truncated.rstrip()
return name
def is_safe_path(base_path: str, target_path: str) -> bool:
"""Check if target_path is safely within base_path.
Prevents path traversal attacks by ensuring the target path
is actually within the base path after resolution.
Args:
base_path: The base directory that should contain the target
target_path: The path to validate
Returns:
bool: True if target_path is safely within base_path
Example:
>>> is_safe_path("/anime", "/anime/Attack on Titan")
True
>>> is_safe_path("/anime", "/anime/../etc/passwd")
False
"""
# Resolve to absolute paths
base_resolved = os.path.abspath(base_path)
target_resolved = os.path.abspath(target_path)
# Check that target starts with base (with trailing separator)
base_with_sep = base_resolved + os.sep
return (
target_resolved == base_resolved or
target_resolved.startswith(base_with_sep)
)
def create_safe_folder(
base_path: str,
folder_name: str,
exist_ok: bool = True,
) -> str:
"""Create a folder with a sanitized name safely within base_path.
Args:
base_path: Base directory to create folder within
folder_name: Unsanitized folder name
exist_ok: If True, don't raise error if folder exists
Returns:
str: Full path to the created folder
Raises:
ValueError: If resulting path would be outside base_path
OSError: If folder creation fails
"""
# Sanitize the folder name
safe_name = sanitize_folder_name(folder_name)
# Construct full path
full_path = os.path.join(base_path, safe_name)
# Validate path safety
if not is_safe_path(base_path, full_path):
raise ValueError(
f"Folder name '{folder_name}' would create path outside "
f"base directory"
)
# Create the folder
os.makedirs(full_path, exist_ok=exist_ok)
return full_path

View File

@ -0,0 +1,33 @@
/**
* AniWorld - CSS Reset
*
* Normalize and reset default browser styles
* for consistent cross-browser rendering.
*/
* {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-family);
font-size: var(--font-size-body);
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
transition: background-color var(--transition-duration) var(--transition-easing),
color var(--transition-duration) var(--transition-easing);
}
/* App container */
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,51 @@
/**
* AniWorld - Typography Styles
*
* Font styles, headings, and text utilities.
*/
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-weight: 600;
color: var(--color-text-primary);
}
h1 {
font-size: var(--font-size-large-title);
}
h2 {
font-size: var(--font-size-title);
}
h3 {
font-size: var(--font-size-subtitle);
}
h4 {
font-size: var(--font-size-body);
}
p {
margin: 0;
color: var(--color-text-secondary);
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
small {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
.error-message {
color: var(--color-error);
font-weight: 500;
}

View File

@ -0,0 +1,114 @@
/**
* AniWorld - CSS Variables
*
* Fluent UI Design System custom properties for colors, typography,
* spacing, borders, shadows, and transitions.
* Includes both light and dark theme definitions.
*/
:root {
/* Light theme colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #faf9f8;
--color-bg-tertiary: #f3f2f1;
--color-surface: #ffffff;
--color-surface-hover: #f3f2f1;
--color-surface-pressed: #edebe9;
--color-text-primary: #323130;
--color-text-secondary: #605e5c;
--color-text-tertiary: #a19f9d;
--color-accent: #0078d4;
--color-accent-hover: #106ebe;
--color-accent-pressed: #005a9e;
--color-success: #107c10;
--color-warning: #ff8c00;
--color-error: #d13438;
--color-border: #e1dfdd;
--color-divider: #c8c6c4;
/* Dark theme colors (stored as variables for theme switching) */
--color-bg-primary-dark: #202020;
--color-bg-secondary-dark: #2d2d30;
--color-bg-tertiary-dark: #3e3e42;
--color-surface-dark: #292929;
--color-surface-hover-dark: #3e3e42;
--color-surface-pressed-dark: #484848;
--color-text-primary-dark: #ffffff;
--color-text-secondary-dark: #cccccc;
--color-text-tertiary-dark: #969696;
--color-accent-dark: #60cdff;
--color-accent-hover-dark: #4db8e8;
--color-accent-pressed-dark: #3aa0d1;
--color-border-dark: #484644;
--color-divider-dark: #605e5c;
/* Typography */
--font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
--font-size-caption: 12px;
--font-size-body: 14px;
--font-size-subtitle: 16px;
--font-size-title: 20px;
--font-size-large-title: 32px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-xxl: 24px;
/* Border radius */
--border-radius-sm: 2px;
--border-radius-md: 4px;
--border-radius-lg: 6px;
--border-radius-xl: 8px;
--border-radius: var(--border-radius-md);
/* Shadows */
--shadow-card: 0 1.6px 3.6px 0 rgba(0, 0, 0, 0.132), 0 0.3px 0.9px 0 rgba(0, 0, 0, 0.108);
--shadow-elevated: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108);
/* Transitions */
--transition-duration: 0.15s;
--transition-easing: cubic-bezier(0.1, 0.9, 0.2, 1);
--animation-duration-fast: 0.1s;
--animation-duration-normal: 0.15s;
--animation-easing-standard: cubic-bezier(0.1, 0.9, 0.2, 1);
/* Additional color aliases */
--color-primary: var(--color-accent);
--color-primary-light: #e6f2fb;
--color-primary-dark: #005a9e;
--color-text: var(--color-text-primary);
--color-text-disabled: #a19f9d;
--color-background: var(--color-bg-primary);
--color-background-secondary: var(--color-bg-secondary);
--color-background-tertiary: var(--color-bg-tertiary);
--color-background-subtle: var(--color-bg-secondary);
}
/* Dark theme */
[data-theme="dark"] {
--color-bg-primary: var(--color-bg-primary-dark);
--color-bg-secondary: var(--color-bg-secondary-dark);
--color-bg-tertiary: var(--color-bg-tertiary-dark);
--color-surface: var(--color-surface-dark);
--color-surface-hover: var(--color-surface-hover-dark);
--color-surface-pressed: var(--color-surface-pressed-dark);
--color-text-primary: var(--color-text-primary-dark);
--color-text-secondary: var(--color-text-secondary-dark);
--color-text-tertiary: var(--color-text-tertiary-dark);
--color-accent: var(--color-accent-dark);
--color-accent-hover: var(--color-accent-hover-dark);
--color-accent-pressed: var(--color-accent-pressed-dark);
--color-border: var(--color-border-dark);
--color-divider: var(--color-divider-dark);
--color-text: var(--color-text-primary-dark);
--color-text-disabled: #969696;
--color-background: var(--color-bg-primary-dark);
--color-background-secondary: var(--color-bg-secondary-dark);
--color-background-tertiary: var(--color-bg-tertiary-dark);
--color-background-subtle: var(--color-bg-tertiary-dark);
--color-primary-light: #1a3a5c;
}

View File

@ -0,0 +1,123 @@
/**
* AniWorld - Button Styles
*
* All button-related styles including variants,
* states, and sizes.
*/
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid transparent;
border-radius: var(--border-radius-md);
font-size: var(--font-size-body);
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all var(--transition-duration) var(--transition-easing);
background-color: transparent;
color: var(--color-text-primary);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Primary button */
.btn-primary {
background-color: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.btn-primary:active {
background-color: var(--color-accent-pressed);
}
/* Secondary button */
.btn-secondary {
background-color: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--color-surface-hover);
}
/* Success button */
.btn-success {
background-color: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #0e6b0e;
}
/* Warning button */
.btn-warning {
background-color: var(--color-warning);
color: white;
}
.btn-warning:hover:not(:disabled) {
background-color: #e67e00;
}
/* Danger/Error button */
.btn-danger {
background-color: var(--color-error);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #b52d30;
}
/* Icon button */
.btn-icon {
padding: var(--spacing-sm);
min-width: auto;
}
/* Small button */
.btn-small {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-caption);
}
/* Extra small button */
.btn-xs {
padding: 2px 6px;
font-size: 0.75em;
}
/* Filter button active state */
.series-filters .btn {
transition: all 0.2s ease;
}
.series-filters .btn[data-active="true"] {
background-color: var(--color-primary);
color: white;
border-color: var(--color-primary);
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
}
.series-filters .btn[data-active="true"]:hover {
background-color: var(--color-primary-dark);
}
/* Dark theme adjustments */
[data-theme="dark"] .series-filters .btn[data-active="true"] {
background-color: var(--color-primary);
color: white;
}

View File

@ -0,0 +1,271 @@
/**
* AniWorld - Card Styles
*
* Card and panel component styles including
* series cards and stat cards.
*/
/* Series Card */
.series-card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-card);
transition: all var(--transition-duration) var(--transition-easing);
position: relative;
display: flex;
flex-direction: column;
min-height: 120px;
}
.series-card:hover {
box-shadow: var(--shadow-elevated);
transform: translateY(-1px);
}
.series-card.selected {
border-color: var(--color-accent);
background-color: var(--color-surface-hover);
}
.series-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
position: relative;
}
.series-checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-accent);
}
.series-info h3 {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
line-height: 1.3;
}
.series-folder {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-sm);
}
.series-stats {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-top: auto;
}
.series-site {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
/* Series Card Status Indicators */
.series-status {
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
display: flex;
align-items: center;
}
.status-missing {
color: var(--color-warning);
font-size: 1.2em;
}
.status-complete {
color: var(--color-success);
font-size: 1.2em;
}
/* Series Card States */
.series-card.has-missing {
border-left: 4px solid var(--color-warning);
}
.series-card.complete {
border-left: 4px solid var(--color-success);
opacity: 0.8;
}
.series-card.complete .series-checkbox {
opacity: 0.5;
cursor: not-allowed;
}
.series-card.complete:not(.selected) {
background-color: var(--color-background-secondary);
}
/* Dark theme adjustments */
[data-theme="dark"] .series-card.complete:not(.selected) {
background-color: var(--color-background-tertiary);
}
/* Stat Card */
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
display: flex;
align-items: center;
gap: var(--spacing-lg);
transition: all var(--transition-duration) var(--transition-easing);
}
.stat-card:hover {
background: var(--color-surface-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-elevated);
}
.stat-icon {
font-size: 2rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--color-primary-rgb), 0.1);
}
.stat-value {
font-size: var(--font-size-title);
font-weight: 600;
color: var(--color-text-primary);
line-height: 1;
}
.stat-label {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Download Card */
.download-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-md);
transition: all var(--transition-duration) var(--transition-easing);
}
.download-card:hover {
background: var(--color-surface-hover);
transform: translateX(4px);
}
.download-card.active {
border-left: 4px solid var(--color-primary);
}
.download-card.completed {
border-left: 4px solid var(--color-success);
opacity: 0.8;
}
.download-card.failed {
border-left: 4px solid var(--color-error);
}
.download-card.pending {
border-left: 4px solid var(--color-warning);
position: relative;
}
.download-card.pending.high-priority {
border-left-color: var(--color-accent);
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%);
}
.download-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.download-info h4 {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.download-info p {
margin: 0 0 var(--spacing-xs) 0;
color: var(--color-text-secondary);
font-size: var(--font-size-body);
}
.download-info small {
color: var(--color-text-tertiary);
font-size: var(--font-size-caption);
}
.download-actions {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
.priority-indicator {
color: var(--color-accent);
margin-right: var(--spacing-sm);
}
/* Queue Position */
.queue-position {
position: absolute;
top: var(--spacing-sm);
left: 48px;
background: var(--color-warning);
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-caption);
font-weight: 600;
}
.download-card.pending .download-info {
margin-left: 80px;
}
.download-card.pending .download-header {
padding-left: 0;
}
/* Dark Theme Adjustments for Cards */
[data-theme="dark"] .stat-card {
background: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
[data-theme="dark"] .stat-card:hover {
background: var(--color-surface-hover-dark);
}
[data-theme="dark"] .download-card {
background: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
[data-theme="dark"] .download-card:hover {
background: var(--color-surface-hover-dark);
}

View File

@ -0,0 +1,224 @@
/**
* AniWorld - Form Styles
*
* Form inputs, labels, validation states,
* and form group layouts.
*/
/* Input fields */
.input-field {
width: 120px;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: var(--color-background);
color: var(--color-text-primary);
font-size: var(--font-size-body);
transition: border-color var(--animation-duration-fast) var(--animation-easing-standard);
}
.input-field:focus {
outline: none;
border-color: var(--color-accent);
}
/* Input groups */
.input-group {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.input-group .input-field {
flex: 1;
width: auto;
}
.input-group .btn {
flex-shrink: 0;
}
/* Search input */
.search-input {
flex: 1;
padding: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-size: var(--font-size-body);
background-color: var(--color-surface);
color: var(--color-text-primary);
transition: all var(--transition-duration) var(--transition-easing);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.search-input-group {
display: flex;
gap: var(--spacing-sm);
max-width: 600px;
}
/* Checkbox custom styling */
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkbox-custom {
display: inline-block;
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
flex-shrink: 0;
border: 2px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
position: relative;
transition: all var(--animation-duration-fast) var(--animation-easing-standard);
}
.checkbox-label input[type="checkbox"]:checked+.checkbox-custom {
background: var(--color-accent);
border-color: var(--color-accent);
}
.checkbox-label input[type="checkbox"]:checked+.checkbox-custom::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-label:hover .checkbox-custom {
border-color: var(--color-accent);
}
/* Form groups */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--color-text);
font-size: 0.9rem;
}
/* Config item styling */
.config-item {
margin-bottom: var(--spacing-lg);
}
.config-item:last-child {
margin-bottom: 0;
}
.config-item label {
display: block;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: var(--spacing-xs);
}
.config-value {
padding: var(--spacing-sm);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
font-family: monospace;
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
word-break: break-all;
}
.config-value input[readonly] {
background-color: var(--color-bg-secondary);
cursor: not-allowed;
}
[data-theme="dark"] .config-value input[readonly] {
background-color: var(--color-bg-secondary-dark);
}
/* Config description */
.config-description {
font-size: 0.9em;
color: var(--muted-text);
margin: 4px 0 8px 0;
line-height: 1.4;
}
/* Config actions */
.config-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
.config-actions .btn {
flex: 1;
min-width: 140px;
}
/* Validation styles */
.validation-results {
margin: 12px 0;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--card-bg);
}
.validation-results.hidden {
display: none;
}
.validation-error {
color: var(--color-error);
margin: 4px 0;
font-size: 0.9em;
}
.validation-warning {
color: var(--color-warning);
margin: 4px 0;
font-size: 0.9em;
}
.validation-success {
color: var(--color-success);
margin: 4px 0;
font-size: 0.9em;
}
/* Responsive form adjustments */
@media (max-width: 768px) {
.config-actions {
flex-direction: column;
}
.config-actions .btn {
flex: none;
width: 100%;
}
}

View File

@ -0,0 +1,264 @@
/**
* AniWorld - Modal Styles
*
* Modal and overlay styles including
* config modal and confirmation dialogs.
*/
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2000;
display: flex;
justify-content: center;
align-items: center;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-elevated);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
margin: 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
}
/* Config Section within modals */
.config-section {
border-top: 1px solid var(--color-divider);
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
}
.config-section h4 {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-subtitle);
font-weight: 600;
color: var(--color-text-primary);
}
/* Scheduler info box */
.scheduler-info {
background: var(--color-background-subtle);
border-radius: var(--border-radius);
padding: var(--spacing-md);
margin: var(--spacing-sm) 0;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.info-row:last-child {
margin-bottom: 0;
}
.info-value {
font-weight: 500;
color: var(--color-text-secondary);
}
/* Status badge */
.status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-caption);
font-weight: 600;
}
.status-badge.running {
background: var(--color-accent);
color: white;
}
.status-badge.stopped {
background: var(--color-text-disabled);
color: white;
}
/* Rescan time config */
#rescan-time-config {
margin-left: var(--spacing-lg);
opacity: 0.6;
transition: opacity var(--animation-duration-normal) var(--animation-easing-standard);
}
#rescan-time-config.enabled {
opacity: 1;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.loading-spinner {
text-align: center;
color: white;
}
.loading-spinner i {
font-size: 48px;
margin-bottom: var(--spacing-md);
}
.loading-spinner p {
margin: 0;
font-size: var(--font-size-subtitle);
}
/* Backup list */
.backup-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 6px;
margin: 8px 0;
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.backup-item:last-child {
border-bottom: none;
}
.backup-info {
flex: 1;
}
.backup-name {
font-weight: 500;
color: var(--text-color);
}
.backup-details {
font-size: 0.8em;
color: var(--muted-text);
margin-top: 2px;
}
.backup-actions {
display: flex;
gap: 4px;
}
.backup-actions .btn {
padding: 4px 8px;
font-size: 0.8em;
}
/* Log files container */
.log-files-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
margin-top: 8px;
}
.log-file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.log-file-item:last-child {
border-bottom: none;
}
.log-file-info {
flex: 1;
}
.log-file-name {
font-weight: 500;
color: var(--text-color);
}
.log-file-details {
font-size: 0.8em;
color: var(--muted-text);
margin-top: 2px;
}
.log-file-actions {
display: flex;
gap: 4px;
}
.log-file-actions .btn {
padding: 4px 8px;
font-size: 0.8em;
min-width: auto;
}
.log-file-actions .btn-xs {
padding: 2px 6px;
font-size: 0.75em;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.info-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}

View File

@ -0,0 +1,218 @@
/**
* AniWorld - Navigation Styles
*
* Header, nav, and navigation link styles.
*/
/* Header */
.header {
background-color: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-lg) var(--spacing-xl);
box-shadow: var(--shadow-card);
transition: background-color var(--transition-duration) var(--transition-easing);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
min-height: 60px;
position: relative;
width: 100%;
box-sizing: border-box;
}
.header-title {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex-shrink: 1;
min-width: 150px;
}
.header-title i {
font-size: var(--font-size-title);
color: var(--color-accent);
}
.header-title h1 {
margin: 0;
font-size: var(--font-size-title);
font-weight: 600;
color: var(--color-text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-lg);
flex-shrink: 0;
flex-wrap: nowrap;
justify-content: flex-end;
}
/* Main content */
.main-content {
flex: 1;
padding: var(--spacing-xl);
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Section headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.section-header h2 {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: 0;
font-size: var(--font-size-title);
color: var(--color-text-primary);
}
.section-actions {
display: flex;
gap: var(--spacing-sm);
}
/* Series section */
.series-section {
margin-bottom: var(--spacing-xxl);
}
.series-header {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.series-header h2 {
margin: 0;
font-size: var(--font-size-title);
color: var(--color-text-primary);
}
.series-filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.series-actions {
display: flex;
gap: var(--spacing-md);
}
.series-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-lg);
}
/* Search section */
.search-section {
margin-bottom: var(--spacing-xxl);
}
.search-container {
margin-bottom: var(--spacing-lg);
}
/* Dark theme adjustments */
[data-theme="dark"] .section-header {
border-bottom-color: var(--color-border-dark);
}
/* Responsive design */
@media (min-width: 768px) {
.series-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.series-filters {
margin-bottom: 0;
}
}
@media (max-width: 1024px) {
.header-title {
min-width: 120px;
}
.header-title h1 {
font-size: 1.4rem;
}
.header-actions {
gap: var(--spacing-sm);
}
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: var(--spacing-md);
min-height: auto;
}
.header-title {
text-align: center;
min-width: auto;
justify-content: center;
}
.header-actions {
justify-content: center;
flex-wrap: wrap;
width: 100%;
gap: var(--spacing-sm);
}
.main-content {
padding: var(--spacing-md);
}
.series-header {
flex-direction: column;
gap: var(--spacing-md);
align-items: stretch;
}
.series-actions {
justify-content: center;
}
.series-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-md);
}
.download-header {
flex-direction: column;
gap: var(--spacing-md);
}
.download-actions {
justify-content: flex-end;
}
}

View File

@ -0,0 +1,148 @@
/**
* AniWorld - Notification Styles
*
* Toast notifications, alerts, and messages.
*/
/* Toast container */
.toast-container {
position: fixed;
top: var(--spacing-xl);
right: var(--spacing-xl);
z-index: 1100;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
/* Toast base */
.toast {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-elevated);
min-width: 300px;
animation: slideIn var(--transition-duration) var(--transition-easing);
}
/* Toast variants */
.toast.success {
border-left: 4px solid var(--color-success);
}
.toast.error {
border-left: 4px solid var(--color-error);
}
.toast.warning {
border-left: 4px solid var(--color-warning);
}
.toast.info {
border-left: 4px solid var(--color-accent);
}
/* Status panel */
.status-panel {
position: fixed;
bottom: var(--spacing-xl);
right: var(--spacing-xl);
width: 400px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-elevated);
z-index: 1000;
transition: all var(--transition-duration) var(--transition-easing);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.status-header h3 {
margin: 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.status-content {
padding: var(--spacing-lg);
}
.status-message {
margin-bottom: var(--spacing-md);
color: var(--color-text-secondary);
}
/* Status indicator */
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-error);
margin-right: var(--spacing-xs);
}
.status-indicator.connected {
background-color: var(--color-success);
}
/* Download controls */
.download-controls {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
justify-content: center;
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--spacing-xxl);
color: var(--color-text-tertiary);
}
.empty-state i {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state p {
margin: 0;
font-size: var(--font-size-subtitle);
}
.empty-state small {
display: block;
margin-top: var(--spacing-sm);
font-size: var(--font-size-small);
opacity: 0.7;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.status-panel {
bottom: var(--spacing-md);
right: var(--spacing-md);
left: var(--spacing-md);
width: auto;
}
.toast-container {
top: var(--spacing-md);
right: var(--spacing-md);
left: var(--spacing-md);
}
.toast {
min-width: auto;
}
}

View File

@ -0,0 +1,196 @@
/**
* AniWorld - Progress Styles
*
* Progress bars, loading indicators,
* and download progress displays.
*/
/* Progress bar base */
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--color-bg-tertiary);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width var(--transition-duration) var(--transition-easing);
width: 0%;
}
.progress-text {
margin-top: var(--spacing-xs);
text-align: center;
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
}
/* Progress container */
.progress-container {
margin-top: var(--spacing-md);
}
/* Mini progress bar */
.progress-bar-mini {
width: 80px;
height: 4px;
background-color: var(--color-bg-tertiary);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.progress-fill-mini {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width var(--transition-duration) var(--transition-easing);
width: 0%;
}
.progress-text-mini {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
font-weight: 500;
min-width: 35px;
}
/* Download progress */
.download-progress {
display: flex;
align-items: center;
gap: var(--spacing-sm);
min-width: 120px;
margin-top: var(--spacing-lg);
}
/* Progress bar gradient style */
.download-progress .progress-bar {
width: 100%;
height: 8px;
background: var(--color-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.download-progress .progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
}
.download-speed {
color: var(--color-primary);
font-weight: 500;
}
/* Missing episodes status */
.missing-episodes {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-secondary);
font-size: var(--font-size-caption);
}
.missing-episodes i {
color: var(--color-warning);
}
.missing-episodes.has-missing {
color: var(--color-warning);
font-weight: 500;
}
.missing-episodes.complete {
color: var(--color-success);
font-weight: 500;
}
.missing-episodes.has-missing i {
color: var(--color-warning);
}
.missing-episodes.complete i {
color: var(--color-success);
}
/* Speed and ETA section */
.speed-eta-section {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
}
.speed-info {
display: flex;
gap: var(--spacing-xl);
}
.speed-current,
.speed-average,
.eta-info {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.speed-info .label,
.eta-info .label {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
text-transform: uppercase;
}
.speed-info .value,
.eta-info .value {
font-size: var(--font-size-subtitle);
font-weight: 500;
color: var(--color-text-primary);
}
/* Dark theme adjustments */
[data-theme="dark"] .speed-eta-section {
background: var(--color-surface-dark);
border-color: var(--color-border-dark);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.current-download-item {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm);
}
.download-progress {
justify-content: space-between;
}
.speed-eta-section {
flex-direction: column;
gap: var(--spacing-lg);
text-align: center;
}
.speed-info {
justify-content: center;
}
}

View File

@ -0,0 +1,128 @@
/**
* AniWorld - Process Status Styles
*
* Process status indicators for scan and download operations.
*/
/* Process Status Indicators */
.process-status {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.status-indicator {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border-radius: var(--border-radius);
border: none;
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
min-width: 0;
flex-shrink: 0;
}
.status-indicator:hover {
background: transparent;
color: var(--color-text-primary);
}
.status-indicator i {
font-size: 24px;
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
}
/* Status dots */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: all var(--animation-duration-normal) var(--animation-easing-standard);
}
.status-dot.idle {
background-color: var(--color-text-disabled);
}
.status-dot.running {
background-color: var(--color-accent);
animation: pulse 2s infinite;
}
.status-dot.error {
background-color: #e74c3c;
}
/* Rescan icon specific styling */
#rescan-status {
cursor: pointer;
}
#rescan-status i {
color: var(--color-text-disabled);
}
#rescan-status.running i {
color: #22c55e;
animation: iconPulse 2s infinite;
}
#rescan-status.running {
cursor: pointer;
}
/* Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
@keyframes iconPulse {
0%,
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
50% {
opacity: 0.7;
transform: scale(1.1) rotate(180deg);
}
}
/* Mobile view */
@media (max-width: 1024px) {
.process-status {
gap: 4px;
}
}
@media (max-width: 768px) {
.process-status {
order: -1;
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
.status-indicator {
font-size: 11px;
padding: 6px 8px;
gap: 4px;
}
.status-indicator i {
font-size: 20px;
}
}

View File

@ -0,0 +1,255 @@
/**
* AniWorld - Table Styles
*
* Table, list, and queue item styles.
*/
/* Search results */
.search-results {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-card);
margin-top: var(--spacing-lg);
}
.search-results h3 {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
}
.search-results-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius-md);
transition: background-color var(--transition-duration) var(--transition-easing);
}
.search-result-item:hover {
background-color: var(--color-surface-hover);
}
.search-result-name {
font-weight: 500;
color: var(--color-text-primary);
}
/* Download Queue Section */
.download-queue-section {
margin-bottom: var(--spacing-xxl);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
background-color: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.queue-header h2 {
margin: 0;
font-size: var(--font-size-subtitle);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.queue-header i {
color: var(--color-accent);
}
.queue-progress {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
font-weight: 500;
}
/* Current download */
.current-download {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-surface);
}
.current-download-header {
margin-bottom: var(--spacing-md);
}
.current-download-header h3 {
margin: 0;
font-size: var(--font-size-body);
color: var(--color-text-primary);
font-weight: 600;
}
.current-download-item {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-md);
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius-md);
border-left: 4px solid var(--color-accent);
}
.download-info {
flex: 1;
}
.serie-name {
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-xs);
}
.episode-info {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
}
/* Queue list */
.queue-list-container {
padding: var(--spacing-lg);
}
.queue-list-container h3 {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-body);
color: var(--color-text-primary);
font-weight: 600;
}
.queue-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.queue-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius-md);
border-left: 3px solid var(--color-divider);
}
.queue-item-index {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
font-weight: 600;
min-width: 20px;
}
.queue-item-name {
flex: 1;
color: var(--color-text-secondary);
}
.queue-item-status {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
.queue-empty {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-tertiary);
font-style: italic;
}
/* Stats grid */
.queue-stats-section {
margin-bottom: var(--spacing-xl);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
/* Drag and Drop Styles */
.draggable-item {
cursor: move;
user-select: none;
}
.draggable-item.dragging {
opacity: 0.5;
transform: scale(0.98);
cursor: grabbing;
}
.draggable-item.drag-over {
border-top: 3px solid var(--color-primary);
margin-top: 8px;
}
.drag-handle {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-tertiary);
cursor: grab;
font-size: 1.2rem;
padding: var(--spacing-xs);
transition: color var(--transition-duration);
}
.drag-handle:hover {
color: var(--color-primary);
}
.drag-handle:active {
cursor: grabbing;
}
.sortable-list {
position: relative;
min-height: 100px;
}
.pending-queue-list {
position: relative;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.queue-item {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-xs);
}
.queue-item-index {
min-width: auto;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
}

View File

@ -0,0 +1,230 @@
/**
* AniWorld - Index Page Styles
*
* Index/library page specific styles including
* series grid, search, and scan overlay.
*/
/* Scan Progress Overlay */
.scan-progress-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.scan-progress-overlay.visible {
opacity: 1;
visibility: visible;
}
.scan-progress-container {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-elevated);
padding: var(--spacing-xxl);
max-width: 450px;
width: 90%;
text-align: center;
animation: scanProgressSlideIn 0.3s ease;
}
@keyframes scanProgressSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.scan-progress-header {
margin-bottom: var(--spacing-lg);
}
.scan-progress-header h3 {
margin: 0;
font-size: var(--font-size-title);
color: var(--color-text-primary);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
.scan-progress-spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid var(--color-bg-tertiary);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: scanSpinner 1s linear infinite;
}
@keyframes scanSpinner {
to {
transform: rotate(360deg);
}
}
/* Progress bar for scan */
.scan-progress-bar-container {
width: 100%;
height: 8px;
background-color: var(--color-bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.scan-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover, var(--color-accent)));
border-radius: 4px;
transition: width 0.3s ease;
}
.scan-progress-container.completed .scan-progress-bar {
background: linear-gradient(90deg, var(--color-success), var(--color-success));
}
.scan-progress-text {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-md);
}
.scan-progress-text #scan-current-count {
font-weight: 600;
color: var(--color-accent);
}
.scan-progress-text #scan-total-count {
font-weight: 600;
color: var(--color-text-primary);
}
.scan-progress-container.completed .scan-progress-text #scan-current-count {
color: var(--color-success);
}
.scan-progress-stats {
display: flex;
justify-content: space-around;
margin: var(--spacing-lg) 0;
padding: var(--spacing-md) 0;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
.scan-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
}
.scan-stat-value {
font-size: var(--font-size-large-title);
font-weight: 600;
color: var(--color-accent);
line-height: 1;
}
.scan-stat-label {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.scan-current-directory {
margin-top: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius-md);
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.scan-current-directory-label {
font-weight: 500;
color: var(--color-text-tertiary);
margin-right: var(--spacing-xs);
}
/* Scan completed state */
.scan-progress-container.completed .scan-progress-spinner {
display: none;
}
.scan-progress-container.completed .scan-progress-header h3 {
color: var(--color-success);
}
.scan-completed-icon {
display: none;
width: 24px;
height: 24px;
color: var(--color-success);
}
.scan-progress-container.completed .scan-completed-icon {
display: inline-block;
}
.scan-progress-container.completed .scan-stat-value {
color: var(--color-success);
}
.scan-elapsed-time {
margin-top: var(--spacing-md);
font-size: var(--font-size-body);
color: var(--color-text-secondary);
}
.scan-elapsed-time i {
margin-right: var(--spacing-xs);
color: var(--color-text-tertiary);
}
/* Responsive adjustments for scan overlay */
@media (max-width: 768px) {
.scan-progress-container {
padding: var(--spacing-lg);
max-width: 95%;
}
.scan-progress-stats {
flex-direction: column;
gap: var(--spacing-md);
}
.scan-stat {
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 0 var(--spacing-md);
}
.scan-stat-value {
font-size: var(--font-size-title);
}
}

View File

@ -0,0 +1,168 @@
/**
* AniWorld - Login Page Styles
*
* Login page specific styles including login card,
* form elements, and branding.
*/
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%);
padding: 1rem;
}
.login-card {
background: var(--color-surface);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
border: 1px solid var(--color-border);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header .logo {
font-size: 3rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.login-header h1 {
margin: 0;
color: var(--color-text);
font-size: 1.5rem;
font-weight: 600;
}
.login-header p {
margin: 0.5rem 0 0 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Password input group */
.password-input-group {
position: relative;
}
.password-input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 1rem;
border: 2px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
background: var(--color-background);
color: var(--color-text);
transition: all 0.2s ease;
box-sizing: border-box;
}
.password-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
}
.password-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.password-toggle:hover {
color: var(--color-text-primary);
}
/* Login button */
.login-btn {
width: 100%;
padding: 0.875rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-btn:hover:not(:disabled) {
background: var(--color-accent-hover);
transform: translateY(-1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error message */
.login-error {
background: rgba(var(--color-error-rgb, 209, 52, 56), 0.1);
border: 1px solid var(--color-error);
border-radius: 8px;
padding: 0.75rem;
color: var(--color-error);
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Remember me checkbox */
.remember-me {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.remember-me input {
accent-color: var(--color-primary);
}
/* Footer links */
.login-footer {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.login-footer a {
color: var(--color-primary);
text-decoration: none;
}
.login-footer a:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,46 @@
/**
* AniWorld - Queue Page Styles
*
* Queue page specific styles for download management.
*/
/* Active downloads section */
.active-downloads-section {
margin-bottom: var(--spacing-xl);
}
.active-downloads-list {
min-height: 100px;
}
/* Pending queue section */
.pending-queue-section {
margin-bottom: var(--spacing-xl);
}
/* Completed downloads section */
.completed-downloads-section {
margin-bottom: var(--spacing-xl);
}
/* Failed downloads section */
.failed-downloads-section {
margin-bottom: var(--spacing-xl);
}
/* Queue page text color utilities */
.text-primary {
color: var(--color-primary);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-error {
color: var(--color-error);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
/**
* AniWorld - Animation Styles
*
* Keyframes and animation utility classes.
*/
/* Slide in animation */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Fade out animation */
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Slide up animation */
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Slide down animation */
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Scale in animation */
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Spin animation for loading */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Bounce animation */
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
/* Pulse animation */
@keyframes pulsate {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Animation utility classes */
.animate-slide-in {
animation: slideIn var(--transition-duration) var(--transition-easing);
}
.animate-fade-in {
animation: fadeIn var(--transition-duration) var(--transition-easing);
}
.animate-fade-out {
animation: fadeOut var(--transition-duration) var(--transition-easing);
}
.animate-slide-up {
animation: slideUp var(--transition-duration) var(--transition-easing);
}
.animate-slide-down {
animation: slideDown var(--transition-duration) var(--transition-easing);
}
.animate-scale-in {
animation: scaleIn var(--transition-duration) var(--transition-easing);
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-bounce {
animation: bounce 1s ease;
}
.animate-pulse {
animation: pulsate 2s ease-in-out infinite;
}

View File

@ -0,0 +1,368 @@
/**
* AniWorld - Helper Utilities
*
* Utility classes for visibility, spacing, flexbox, and text.
*/
/* Display utilities */
.hidden {
display: none !important;
}
.visible {
visibility: visible !important;
}
.invisible {
visibility: hidden !important;
}
.block {
display: block !important;
}
.inline-block {
display: inline-block !important;
}
.inline {
display: inline !important;
}
.flex {
display: flex !important;
}
.inline-flex {
display: inline-flex !important;
}
.grid {
display: grid !important;
}
/* Flexbox utilities */
.flex-row {
flex-direction: row !important;
}
.flex-column {
flex-direction: column !important;
}
.flex-wrap {
flex-wrap: wrap !important;
}
.flex-nowrap {
flex-wrap: nowrap !important;
}
.justify-start {
justify-content: flex-start !important;
}
.justify-end {
justify-content: flex-end !important;
}
.justify-center {
justify-content: center !important;
}
.justify-between {
justify-content: space-between !important;
}
.justify-around {
justify-content: space-around !important;
}
.align-start {
align-items: flex-start !important;
}
.align-end {
align-items: flex-end !important;
}
.align-center {
align-items: center !important;
}
.align-stretch {
align-items: stretch !important;
}
.flex-1 {
flex: 1 !important;
}
.flex-auto {
flex: auto !important;
}
.flex-none {
flex: none !important;
}
/* Text alignment */
.text-left {
text-align: left !important;
}
.text-center {
text-align: center !important;
}
.text-right {
text-align: right !important;
}
/* Text transformation */
.text-uppercase {
text-transform: uppercase !important;
}
.text-lowercase {
text-transform: lowercase !important;
}
.text-capitalize {
text-transform: capitalize !important;
}
/* Font weight */
.font-normal {
font-weight: 400 !important;
}
.font-medium {
font-weight: 500 !important;
}
.font-semibold {
font-weight: 600 !important;
}
.font-bold {
font-weight: 700 !important;
}
/* Margins */
.m-0 {
margin: 0 !important;
}
.mt-0 {
margin-top: 0 !important;
}
.mb-0 {
margin-bottom: 0 !important;
}
.ml-0 {
margin-left: 0 !important;
}
.mr-0 {
margin-right: 0 !important;
}
.mb-1 {
margin-bottom: var(--spacing-xs) !important;
}
.mb-2 {
margin-bottom: var(--spacing-sm) !important;
}
.mb-3 {
margin-bottom: var(--spacing-md) !important;
}
.mb-4 {
margin-bottom: var(--spacing-lg) !important;
}
.mt-1 {
margin-top: var(--spacing-xs) !important;
}
.mt-2 {
margin-top: var(--spacing-sm) !important;
}
.mt-3 {
margin-top: var(--spacing-md) !important;
}
.mt-4 {
margin-top: var(--spacing-lg) !important;
}
.mx-auto {
margin-left: auto !important;
margin-right: auto !important;
}
/* Padding */
.p-0 {
padding: 0 !important;
}
.p-1 {
padding: var(--spacing-xs) !important;
}
.p-2 {
padding: var(--spacing-sm) !important;
}
.p-3 {
padding: var(--spacing-md) !important;
}
.p-4 {
padding: var(--spacing-lg) !important;
}
/* Width utilities */
.w-full {
width: 100% !important;
}
.w-auto {
width: auto !important;
}
/* Height utilities */
.h-full {
height: 100% !important;
}
.h-auto {
height: auto !important;
}
/* Overflow */
.overflow-hidden {
overflow: hidden !important;
}
.overflow-auto {
overflow: auto !important;
}
.overflow-scroll {
overflow: scroll !important;
}
/* Position */
.relative {
position: relative !important;
}
.absolute {
position: absolute !important;
}
.fixed {
position: fixed !important;
}
.sticky {
position: sticky !important;
}
/* Cursor */
.cursor-pointer {
cursor: pointer !important;
}
.cursor-not-allowed {
cursor: not-allowed !important;
}
/* User select */
.select-none {
user-select: none !important;
}
.select-text {
user-select: text !important;
}
.select-all {
user-select: all !important;
}
/* Border radius */
.rounded {
border-radius: var(--border-radius-md) !important;
}
.rounded-lg {
border-radius: var(--border-radius-lg) !important;
}
.rounded-full {
border-radius: 9999px !important;
}
.rounded-none {
border-radius: 0 !important;
}
/* Shadow */
.shadow {
box-shadow: var(--shadow-card) !important;
}
.shadow-lg {
box-shadow: var(--shadow-elevated) !important;
}
.shadow-none {
box-shadow: none !important;
}
/* Opacity */
.opacity-0 {
opacity: 0 !important;
}
.opacity-50 {
opacity: 0.5 !important;
}
.opacity-100 {
opacity: 1 !important;
}
/* Transition */
.transition {
transition: all var(--transition-duration) var(--transition-easing) !important;
}
.transition-none {
transition: none !important;
}
/* Z-index */
.z-0 {
z-index: 0 !important;
}
.z-10 {
z-index: 10 !important;
}
.z-50 {
z-index: 50 !important;
}
.z-100 {
z-index: 100 !important;
}

View File

@ -0,0 +1,117 @@
/**
* AniWorld - Responsive Styles
*
* Media queries and breakpoint-specific styles.
* Note: Component-specific responsive styles are in their respective files.
* This file contains global responsive utilities and overrides.
*/
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) {
.container-sm {
max-width: 540px;
}
}
/* Medium devices (tablets, 768px and up) */
@media (min-width: 768px) {
.container-md {
max-width: 720px;
}
.hide-md-up {
display: none !important;
}
}
/* Large devices (desktops, 992px and up) */
@media (min-width: 992px) {
.container-lg {
max-width: 960px;
}
.hide-lg-up {
display: none !important;
}
}
/* Extra large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) {
.container-xl {
max-width: 1140px;
}
.hide-xl-up {
display: none !important;
}
}
/* Hide on small screens */
@media (max-width: 575.98px) {
.hide-sm-down {
display: none !important;
}
}
/* Hide on medium screens and below */
@media (max-width: 767.98px) {
.hide-md-down {
display: none !important;
}
}
/* Hide on large screens and below */
@media (max-width: 991.98px) {
.hide-lg-down {
display: none !important;
}
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
body {
background: white;
color: black;
}
.header,
.toast-container,
.status-panel {
display: none !important;
}
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
:root {
--color-border: #000000;
--color-text-primary: #000000;
--color-text-secondary: #333333;
}
[data-theme="dark"] {
--color-border: #ffffff;
--color-text-primary: #ffffff;
--color-text-secondary: #cccccc;
}
}

View File

@ -26,6 +26,8 @@ class AniWorldApp {
this.loadSeries();
this.initTheme();
this.updateConnectionStatus();
// Check scan status on page load (in case socket connect event is delayed)
this.checkActiveScanStatus();
}
async checkAuthentication() {
@ -186,12 +188,16 @@ class AniWorldApp {
console.log('Connected to server');
// Subscribe to rooms for targeted updates
this.socket.join('scan_progress');
this.socket.join('download_progress');
// Valid rooms: downloads, queue, scan, system, errors
this.socket.join('scan');
this.socket.join('downloads');
this.socket.join('queue');
this.showToast(this.localization.getText('connected-server'), 'success');
this.updateConnectionStatus();
// Check if a scan is currently in progress (e.g., after page reload)
this.checkActiveScanStatus();
});
this.socket.on('disconnect', () => {
@ -201,19 +207,22 @@ class AniWorldApp {
this.updateConnectionStatus();
});
// Scan events
this.socket.on('scan_started', () => {
this.showStatus('Scanning series...', true);
// Scan events - handle new detailed scan progress overlay
this.socket.on('scan_started', (data) => {
console.log('Scan started:', data);
this.showScanProgressOverlay(data);
this.updateProcessStatus('rescan', true);
});
this.socket.on('scan_progress', (data) => {
this.updateStatus(`Scanning: ${data.folder} (${data.counter})`);
console.log('Scan progress:', data);
this.updateScanProgressOverlay(data);
});
// Handle both 'scan_completed' (legacy) and 'scan_complete' (new backend)
const handleScanComplete = () => {
this.hideStatus();
const handleScanComplete = (data) => {
console.log('Scan completed:', data);
this.hideScanProgressOverlay(data);
this.showToast('Scan completed successfully', 'success');
this.updateProcessStatus('rescan', false);
this.loadSeries();
@ -410,6 +419,16 @@ class AniWorldApp {
this.rescanSeries();
});
// Click on rescan status indicator to reopen scan overlay
const rescanStatus = document.getElementById('rescan-status');
if (rescanStatus) {
rescanStatus.addEventListener('click', (e) => {
e.stopPropagation();
console.log('Rescan status clicked');
this.reopenScanOverlay();
});
}
// Configuration modal
document.getElementById('config-btn').addEventListener('click', () => {
this.showConfigModal();
@ -564,7 +583,8 @@ class AniWorldApp {
site: anime.site,
folder: anime.folder,
episodeDict: episodeDict,
missing_episodes: totalMissing
missing_episodes: totalMissing,
has_missing: anime.has_missing || totalMissing > 0
};
});
} else if (data.status === 'success') {
@ -1008,33 +1028,39 @@ class AniWorldApp {
async rescanSeries() {
try {
this.showToast('Scanning directory...', 'info');
// Show the overlay immediately before making the API call
this.showScanProgressOverlay({
directory: 'Starting scan...',
total_items: 0
});
this.updateProcessStatus('rescan', true);
const response = await this.makeAuthenticatedRequest('/api/anime/rescan', {
method: 'POST'
});
if (!response) return;
if (!response) {
this.removeScanProgressOverlay();
this.updateProcessStatus('rescan', false);
return;
}
const data = await response.json();
// Debug logging
console.log('Rescan response:', data);
console.log('Success value:', data.success, 'Type:', typeof data.success);
if (data.success === true) {
const seriesCount = data.series_count || 0;
this.showToast(
`Rescan complete! Found ${seriesCount} series with missing episodes.`,
'success'
);
// Reload the series list to show the updated data
await this.loadSeries();
} else {
// Note: The scan progress will be updated via WebSocket events
// The overlay will be closed when scan_completed is received
if (data.success !== true) {
this.removeScanProgressOverlay();
this.updateProcessStatus('rescan', false);
this.showToast(`Rescan error: ${data.message}`, 'error');
}
} catch (error) {
console.error('Rescan error:', error);
this.removeScanProgressOverlay();
this.updateProcessStatus('rescan', false);
this.showToast('Failed to start rescan', 'error');
}
}
@ -1072,6 +1098,314 @@ class AniWorldApp {
document.getElementById('status-panel').classList.add('hidden');
}
/**
* Show the scan progress overlay with spinner and initial state
* @param {Object} data - Scan started event data
*/
showScanProgressOverlay(data) {
// Remove existing overlay if present
this.removeScanProgressOverlay();
// Store total items for progress calculation
this.scanTotalItems = data?.total_items || 0;
// Store last scan data for reopening
this._lastScanData = data;
// Create overlay element
const overlay = document.createElement('div');
overlay.id = 'scan-progress-overlay';
overlay.className = 'scan-progress-overlay';
const totalDisplay = this.scanTotalItems > 0 ? this.scanTotalItems : '...';
overlay.innerHTML = `
<div class="scan-progress-container">
<div class="scan-progress-header">
<h3>
<span class="scan-progress-spinner"></span>
<i class="fas fa-check-circle scan-completed-icon"></i>
<span class="scan-title-text">Scanning Library</span>
</h3>
</div>
<div class="scan-progress-bar-container">
<div class="scan-progress-bar" id="scan-progress-bar" style="width: 0%"></div>
</div>
<div class="scan-progress-text" id="scan-progress-text">
<span id="scan-current-count">0</span> / <span id="scan-total-count">${totalDisplay}</span> directories
</div>
<div class="scan-progress-stats">
<div class="scan-stat">
<span class="scan-stat-value" id="scan-directories-count">0</span>
<span class="scan-stat-label">Scanned</span>
</div>
<div class="scan-stat">
<span class="scan-stat-value" id="scan-files-count">0</span>
<span class="scan-stat-label">Series Found</span>
</div>
</div>
<div class="scan-current-directory" id="scan-current-directory">
<span class="scan-current-directory-label">Current:</span>
<span id="scan-current-path">${this.escapeHtml(data?.directory || 'Initializing...')}</span>
</div>
<div class="scan-elapsed-time hidden" id="scan-elapsed-time">
<i class="fas fa-clock"></i>
<span id="scan-elapsed-value">0.0s</span>
</div>
</div>
`;
document.body.appendChild(overlay);
// Add click-outside-to-close handler
overlay.addEventListener('click', (e) => {
// Only close if clicking the overlay background, not the container
if (e.target === overlay) {
this.removeScanProgressOverlay();
}
});
// Trigger animation by adding visible class after a brief delay
requestAnimationFrame(() => {
overlay.classList.add('visible');
});
}
/**
* Update the scan progress overlay with current progress
* @param {Object} data - Scan progress event data
*/
updateScanProgressOverlay(data) {
const overlay = document.getElementById('scan-progress-overlay');
if (!overlay) return;
// Update total items if provided (in case it wasn't available at start)
if (data.total_items && data.total_items > 0) {
this.scanTotalItems = data.total_items;
const totalCount = document.getElementById('scan-total-count');
if (totalCount) {
totalCount.textContent = this.scanTotalItems;
}
}
// Update progress bar
const progressBar = document.getElementById('scan-progress-bar');
if (progressBar && this.scanTotalItems > 0 && data.directories_scanned !== undefined) {
const percentage = Math.min(100, (data.directories_scanned / this.scanTotalItems) * 100);
progressBar.style.width = `${percentage}%`;
}
// Update current/total count display
const currentCount = document.getElementById('scan-current-count');
if (currentCount && data.directories_scanned !== undefined) {
currentCount.textContent = data.directories_scanned;
}
// Update directories count
const dirCount = document.getElementById('scan-directories-count');
if (dirCount && data.directories_scanned !== undefined) {
dirCount.textContent = data.directories_scanned;
}
// Update files/series count
const filesCount = document.getElementById('scan-files-count');
if (filesCount && data.files_found !== undefined) {
filesCount.textContent = data.files_found;
}
// Update current directory (truncate if too long)
const currentPath = document.getElementById('scan-current-path');
if (currentPath && data.current_directory) {
const maxLength = 50;
let displayPath = data.current_directory;
if (displayPath.length > maxLength) {
displayPath = '...' + displayPath.slice(-maxLength + 3);
}
currentPath.textContent = displayPath;
currentPath.title = data.current_directory; // Full path on hover
}
}
/**
* Hide the scan progress overlay with completion summary
* @param {Object} data - Scan completed event data
*/
hideScanProgressOverlay(data) {
const overlay = document.getElementById('scan-progress-overlay');
if (!overlay) return;
const container = overlay.querySelector('.scan-progress-container');
if (container) {
container.classList.add('completed');
}
// Update title
const titleText = overlay.querySelector('.scan-title-text');
if (titleText) {
titleText.textContent = 'Scan Complete';
}
// Complete the progress bar
const progressBar = document.getElementById('scan-progress-bar');
if (progressBar) {
progressBar.style.width = '100%';
}
// Update final stats
if (data) {
const dirCount = document.getElementById('scan-directories-count');
if (dirCount && data.total_directories !== undefined) {
dirCount.textContent = data.total_directories;
}
const filesCount = document.getElementById('scan-files-count');
if (filesCount && data.total_files !== undefined) {
filesCount.textContent = data.total_files;
}
// Update progress text to show final count
const currentCount = document.getElementById('scan-current-count');
const totalCount = document.getElementById('scan-total-count');
if (currentCount && data.total_directories !== undefined) {
currentCount.textContent = data.total_directories;
}
if (totalCount && data.total_directories !== undefined) {
totalCount.textContent = data.total_directories;
}
// Show elapsed time
const elapsedTimeEl = document.getElementById('scan-elapsed-time');
const elapsedValueEl = document.getElementById('scan-elapsed-value');
if (elapsedTimeEl && elapsedValueEl && data.elapsed_seconds !== undefined) {
elapsedValueEl.textContent = `${data.elapsed_seconds.toFixed(1)}s`;
elapsedTimeEl.classList.remove('hidden');
}
// Update current directory to show completion message
const currentPath = document.getElementById('scan-current-path');
if (currentPath) {
currentPath.textContent = 'Scan finished successfully';
}
}
// Auto-dismiss after 3 seconds
setTimeout(() => {
this.removeScanProgressOverlay();
}, 3000);
}
/**
* Remove the scan progress overlay from the DOM
*/
removeScanProgressOverlay() {
const overlay = document.getElementById('scan-progress-overlay');
if (overlay) {
overlay.classList.remove('visible');
// Wait for fade out animation before removing
setTimeout(() => {
if (overlay.parentElement) {
overlay.remove();
}
}, 300);
}
}
/**
* Reopen the scan progress overlay if a scan is in progress
* Called when user clicks on the rescan status indicator
*/
async reopenScanOverlay() {
// Check if overlay already exists
const existingOverlay = document.getElementById('scan-progress-overlay');
if (existingOverlay) {
// Overlay is already open, do nothing
return;
}
// Check if scan is running via API
try {
const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
if (!response || !response.ok) {
console.log('Could not fetch scan status');
return;
}
const data = await response.json();
console.log('Scan status for reopen:', data);
if (data.is_scanning) {
// A scan is in progress, show the overlay
this.showScanProgressOverlay({
directory: data.directory,
total_items: data.total_items
});
// Update with current progress
this.updateScanProgressOverlay({
directories_scanned: data.directories_scanned,
files_found: data.directories_scanned,
current_directory: data.current_directory,
total_items: data.total_items
});
}
} catch (error) {
console.error('Error checking scan status for reopen:', error);
}
}
/**
* Check if a scan is currently in progress (useful after page reload)
* and show the progress overlay if so
*/
async checkActiveScanStatus() {
try {
const response = await this.makeAuthenticatedRequest('/api/anime/scan/status');
if (!response || !response.ok) {
console.log('Could not fetch scan status, response:', response?.status);
return;
}
const data = await response.json();
console.log('Scan status check result:', data);
if (data.is_scanning) {
console.log('Scan is active, updating UI indicators');
// Update the process status indicator FIRST before showing overlay
// This ensures the header icon shows the running state immediately
this.updateProcessStatus('rescan', true);
// A scan is in progress, show the overlay
this.showScanProgressOverlay({
directory: data.directory,
total_items: data.total_items
});
// Update with current progress
this.updateScanProgressOverlay({
directories_scanned: data.directories_scanned,
files_found: data.directories_scanned,
current_directory: data.current_directory,
total_items: data.total_items
});
// Double-check the status indicator was updated
const statusElement = document.getElementById('rescan-status');
if (statusElement) {
console.log('Rescan status element classes:', statusElement.className);
} else {
console.warn('Rescan status element not found in DOM');
}
} else {
console.log('No active scan detected');
// Ensure indicator shows idle state
this.updateProcessStatus('rescan', false);
}
} catch (error) {
console.error('Error checking scan status:', error);
}
}
showLoading() {
document.getElementById('loading-overlay').classList.remove('hidden');
}
@ -1146,10 +1480,16 @@ class AniWorldApp {
updateProcessStatus(processName, isRunning, hasError = false) {
const statusElement = document.getElementById(`${processName}-status`);
if (!statusElement) return;
if (!statusElement) {
console.warn(`Process status element not found: ${processName}-status`);
return;
}
const statusDot = statusElement.querySelector('.status-dot');
if (!statusDot) return;
if (!statusDot) {
console.warn(`Status dot not found in ${processName}-status element`);
return;
}
// Remove all status classes from both dot and element
statusDot.classList.remove('idle', 'running', 'error');
@ -1171,6 +1511,8 @@ class AniWorldApp {
statusElement.classList.add('idle');
statusElement.title = `${displayName} is idle`;
}
console.log(`Process status updated: ${processName} = ${isRunning ? 'running' : (hasError ? 'error' : 'idle')}`);
}
async showConfigModal() {

View File

@ -0,0 +1,74 @@
/**
* AniWorld - Advanced Config Module
*
* Handles advanced configuration settings like concurrent downloads,
* timeouts, and debug mode.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.AdvancedConfig = (function() {
'use strict';
const API = AniWorld.Constants.API;
/**
* Load advanced configuration
*/
async function load() {
try {
const response = await AniWorld.ApiClient.get(API.CONFIG_SECTION + '/advanced');
if (!response) return;
const data = await response.json();
if (data.success) {
const config = data.config;
document.getElementById('max-concurrent-downloads').value = config.max_concurrent_downloads || 3;
document.getElementById('provider-timeout').value = config.provider_timeout || 30;
document.getElementById('enable-debug-mode').checked = config.enable_debug_mode === true;
}
} catch (error) {
console.error('Error loading advanced config:', error);
}
}
/**
* Save advanced configuration
*/
async function save() {
try {
const config = {
max_concurrent_downloads: parseInt(document.getElementById('max-concurrent-downloads').value),
provider_timeout: parseInt(document.getElementById('provider-timeout').value),
enable_debug_mode: document.getElementById('enable-debug-mode').checked
};
const response = await AniWorld.ApiClient.request(API.CONFIG_SECTION + '/advanced', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response) return;
const data = await response.json();
if (data.success) {
AniWorld.UI.showToast('Advanced configuration saved successfully', 'success');
} else {
AniWorld.UI.showToast('Failed to save config: ' + data.error, 'error');
}
} catch (error) {
console.error('Error saving advanced config:', error);
AniWorld.UI.showToast('Failed to save advanced configuration', 'error');
}
}
// Public API
return {
load: load,
save: save
};
})();

Some files were not shown because too many files have changed in this diff Show More